From c09e8f5fd8f977bf16e9ec5d11b370151fc81ea8 Mon Sep 17 00:00:00 2001 From: David Wobrock Date: Wed, 27 Dec 2023 17:02:27 +0100 Subject: [PATCH] Fixed #32114 -- Fixed parallel test crash on non-picklable objects in subtests. --- django/test/testcases.py | 30 ++++++++++++++++++++++++++++++ tests/test_runner/test_parallel.py | 17 +++++++++++++++++ tests/test_utils/test_testcase.py | 10 +++++++++- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/django/test/testcases.py b/django/test/testcases.py index bf035bd531..911a2d50ac 100644 --- a/django/test/testcases.py +++ b/django/test/testcases.py @@ -1,6 +1,7 @@ import difflib import json import logging +import pickle import posixpath import sys import threading @@ -92,6 +93,18 @@ def to_list(value): return value +def is_pickable(obj): + """ + Returns true if the object can be dumped and loaded through the pickle + module. + """ + try: + pickle.loads(pickle.dumps(obj)) + except (AttributeError, TypeError): + return False + return True + + def assert_and_parse_html(self, html, user_msg, msg): try: dom = parse_html(html) @@ -303,6 +316,23 @@ class SimpleTestCase(unittest.TestCase): """ self._setup_and_call(result) + def __getstate__(self): + """ + Make SimpleTestCase picklable for parallel tests using subtests. + """ + state = super().__dict__ + # _outcome and _subtest cannot be tested on picklability, since they + # contain the TestCase itself, leading to an infinite recursion. + if state["_outcome"]: + pickable_state = {"_outcome": None, "_subtest": None} + for key, value in state.items(): + if key in pickable_state or not is_pickable(value): + continue + pickable_state[key] = value + return pickable_state + + return state + def debug(self): """Perform the same as __call__(), without catching the exception.""" debug_result = _DebugResult() diff --git a/tests/test_runner/test_parallel.py b/tests/test_runner/test_parallel.py index e83f53bf4e..8f7bfd5cf8 100644 --- a/tests/test_runner/test_parallel.py +++ b/tests/test_runner/test_parallel.py @@ -51,6 +51,13 @@ class SampleFailingSubtest(SimpleTestCase): with self.subTest(index=i): self.assertEqual(i, 1) + # This method name doesn't begin with "test" to prevent test discovery + # from seeing it. + def pickle_error_test(self): + with self.subTest("TypeError: cannot pickle memoryview object"): + self.x = memoryview(b"") + self.fail("expected failure") + class RemoteTestResultTest(SimpleTestCase): def _test_error_exc_info(self): @@ -106,6 +113,16 @@ class RemoteTestResultTest(SimpleTestCase): with self.assertRaisesMessage(TypeError, msg): result._confirm_picklable(not_unpicklable_error) + def test_unpicklable_subtest(self): + result = RemoteTestResult() + subtest_test = SampleFailingSubtest(methodName="pickle_error_test") + subtest_test.run(result=result) + + events = result.events + subtest_event = events[1] + assertion_error = subtest_event[3] + self.assertEqual(str(assertion_error[1]), "expected failure") + @unittest.skipUnless(tblib is not None, "requires tblib to be installed") def test_add_failing_subtests(self): """ diff --git a/tests/test_utils/test_testcase.py b/tests/test_utils/test_testcase.py index eb6ca80036..0f41f29a23 100644 --- a/tests/test_utils/test_testcase.py +++ b/tests/test_utils/test_testcase.py @@ -1,12 +1,20 @@ +import pickle from functools import wraps from django.db import IntegrityError, connections, transaction from django.test import TestCase, skipUnlessDBFeature -from django.test.testcases import DatabaseOperationForbidden, TestData +from django.test.testcases import DatabaseOperationForbidden, SimpleTestCase, TestData from .models import Car, Person, PossessedCar +class TestSimpleTestCase(SimpleTestCase): + def test_is_picklable_with_non_picklable_properties(self): + """ParallelTestSuite requires that all TestCases are picklable.""" + self.non_picklable = lambda: 0 + self.assertEqual(self, pickle.loads(pickle.dumps(self))) + + class TestTestCase(TestCase): @skipUnlessDBFeature("can_defer_constraint_checks") @skipUnlessDBFeature("supports_foreign_keys")