Repository: django/django

Issue:
# Fixed #35402 -- Fixed crash in DatabaseFeatures.django_test_skips when running a subset of tests.

#### Trac ticket number
ticket-35402

#### Branch description
Continued from #18203.

Because the prior implementation of this skip machinery sent module names to `import_string()`, it was relying on a fragile behavior that depended on, in the case of submodules, the parent modules having already been imported, which isn't guaranteed.

We might want to take up Tim's suggestion in the ticket of deprecating providing module names to `import_string()` at some point.

#### AI Assistance Disclosure (REQUIRED)
<!-- Please select exactly ONE of the following: -->
- [x] **No AI tools were used** in preparing this PR.
- [ ] **If AI tools were used**, I have disclosed which ones, and fully reviewed and verified their output.

#### Checklist
- [x] This PR follows the [contribution guidelines](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/submitting-patches/).
- [x] This PR **does not** disclose a security vulnerability (see [vulnerability reporting](https://docs.djangoproject.com/en/stable/internals/security/)).
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [x] I have added or updated relevant docs, including release notes if applicable.
- [x] I have attached screenshots in both light and dark modes for any UI changes.


Implementation patch:
diff --git a/django/db/backends/base/creation.py b/django/db/backends/base/creation.py
index ed2b0738a5a1..5087f80b7ebd 100644
--- a/django/db/backends/base/creation.py
+++ b/django/db/backends/base/creation.py
@@ -358,27 +358,39 @@ def mark_expected_failures_and_skips(self):
         Mark tests in Django's test suite which are expected failures on this
         database and test which should be skipped on this database.
         """
-        # Only load unittest if we're actually testing.
-        from unittest import expectedFailure, skip
-
         for test_name in self.connection.features.django_test_expected_failures:
-            test_case_name, _, test_method_name = test_name.rpartition(".")
-            test_app = test_name.split(".")[0]
-            # Importing a test app that isn't installed raises RuntimeError.
-            if test_app in settings.INSTALLED_APPS:
-                test_case = import_string(test_case_name)
-                test_method = getattr(test_case, test_method_name)
-                setattr(test_case, test_method_name, expectedFailure(test_method))
+            self._mark_test(test_name)
         for reason, tests in self.connection.features.django_test_skips.items():
             for test_name in tests:
-                test_case_name, _, test_method_name = test_name.rpartition(".")
-                test_app = test_name.split(".")[0]
-                # Importing a test app that isn't installed raises
-                # RuntimeError.
-                if test_app in settings.INSTALLED_APPS:
-                    test_case = import_string(test_case_name)
-                    test_method = getattr(test_case, test_method_name)
-                    setattr(test_case, test_method_name, skip(reason)(test_method))
+                self._mark_test(test_name, reason)
+
+    def _mark_test(self, test_name, skip_reason=None):
+        # Only load unittest during testing.
+        from unittest import expectedFailure, skip
+
+        module_or_class_name, _, name_to_mark = test_name.rpartition(".")
+        test_app = test_name.split(".")[0]
+        # Importing a test app that isn't installed raises RuntimeError.
+        if test_app in settings.INSTALLED_APPS:
+            try:
+                test_frame = import_string(module_or_class_name)
+            except ImportError:
+                # import_string() can raise ImportError if a submodule's parent
+                # module hasn't already been imported during test discovery.
+                # This can happen in at least two cases:
+                # 1. When running a subset of tests in a module, the test
+                #    runner won't import tests in that module's other
+                #    submodules.
+                # 2. When the parallel test runner spawns workers with an empty
+                #    import cache.
+                test_to_mark = import_string(test_name)
+                test_frame = sys.modules.get(test_to_mark.__module__)
+            else:
+                test_to_mark = getattr(test_frame, name_to_mark)
+            if skip_reason:
+                setattr(test_frame, name_to_mark, skip(skip_reason)(test_to_mark))
+            else:
+                setattr(test_frame, name_to_mark, expectedFailure(test_to_mark))
 
     def sql_table_creation_suffix(self):
         """


Test patch:
diff --git a/tests/backends/base/test_creation.py b/tests/backends/base/test_creation.py
index 2542407f0bc4..8fd8f490b28f 100644
--- a/tests/backends/base/test_creation.py
+++ b/tests/backends/base/test_creation.py
@@ -1,6 +1,7 @@
 import copy
 import datetime
 import os
+import sys
 from unittest import mock
 
 from django.db import DEFAULT_DB_ALIAS, connection, connections
@@ -333,6 +334,10 @@ def test_mark_expected_failures_and_skips(self):
                 "backends.base.test_creation.skip_test_function",
             },
         }
+        # Emulate the scenario where the parent module for
+        # backends.base.test_creation has not been imported yet.
+        popped_module = sys.modules.pop("backends.base")
+        self.addCleanup(sys.modules.__setitem__, "backends.base", popped_module)
         creation.mark_expected_failures_and_skips()
         self.assertIs(
             expected_failure_test_function.__unittest_expecting_failure__,