From 38e140df22ba9ea8dda0159ac5ff335f63b0433f Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:49:08 +0200 Subject: [PATCH] fix(tests): skip uninstalled doctypes in test record dependency walk The test runner walks link-field dependencies recursively to pre-generate test records via `get_missing_records_doctypes`. If any DocType in the transitive link graph belonged to an app not installed on the test site, the walk crashed with `DoesNotExistError`, aborting the entire suite before a single test ran. Treat such link targets as dead-end leaves instead: - `get_modules` now returns `(None, None)` when the DocType row does not exist, instead of falling through into `load_doctype_module` which raises. - `get_missing_records_doctypes` checks for `module is None`, logs a warning naming the parent DocType that linked to it, and returns without descending further. This restores the ability to run downstream test suites that link (directly or transitively) to optional/uninstalled apps without forcing every CI environment to know the full transitive link graph. Fixes #38747 --- frappe/tests/test_test_utils.py | 26 ++++++++++++++++++++++++++ frappe/tests/utils/generators.py | 23 +++++++++++++++++++---- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/frappe/tests/test_test_utils.py b/frappe/tests/test_test_utils.py index 5e0cdc6393..7eade85aad 100644 --- a/frappe/tests/test_test_utils.py +++ b/frappe/tests/test_test_utils.py @@ -1,7 +1,9 @@ +import logging from datetime import timedelta import frappe from frappe.tests import IntegrationTestCase +from frappe.tests.utils.generators import get_missing_records_doctypes, get_modules from frappe.utils.data import now_datetime @@ -38,6 +40,30 @@ class TestTestUtils(IntegrationTestCase): with self.freeze_time(tomorrow): self.assertEqual(now_datetime(), tomorrow) + def test_get_modules_returns_none_for_missing_doctype(self): + """DocTypes from uninstalled apps should resolve to (None, None) instead of raising.""" + get_modules.cache_clear() + try: + module, test_module = get_modules("Definitely Not A Real DocType") + finally: + get_modules.cache_clear() + self.assertIsNone(module) + self.assertIsNone(test_module) + + def test_get_missing_records_doctypes_skips_missing_doctype(self): + """Missing link targets should be skipped with a warning, not crash the walk.""" + get_modules.cache_clear() + try: + with self.assertLogs("frappe.testing.generators", level=logging.WARNING) as log_ctx: + result = get_missing_records_doctypes("Definitely Not A Real DocType") + finally: + get_modules.cache_clear() + self.assertEqual(result, []) + self.assertTrue( + any("Definitely Not A Real DocType" in line for line in log_ctx.output), + f"Expected warning mentioning the missing doctype, got: {log_ctx.output}", + ) + def tearDownModule(): """assertions for ensuring tests didn't leave state behind""" diff --git a/frappe/tests/utils/generators.py b/frappe/tests/utils/generators.py index 1564aabee2..523174e8bb 100644 --- a/frappe/tests/utils/generators.py +++ b/frappe/tests/utils/generators.py @@ -35,9 +35,13 @@ __all__ = [ @cache -def get_modules(doctype) -> tuple[str, ModuleType]: +def get_modules(doctype) -> tuple[str | None, ModuleType | None]: """Get the modules for the specified doctype""" module = frappe.db.get_value("DocType", doctype, "module") + if not module: + # DocType is not installed on this site (e.g. belongs to an uninstalled app); + # treat it as a dead-end leaf rather than raising DoesNotExistError. + return None, None try: test_module = load_doctype_module(doctype, module, "test_") if test_module: @@ -49,7 +53,7 @@ def get_modules(doctype) -> tuple[str, ModuleType]: # @cache - don't cache the recursion, code depends on its recurn value declining -def get_missing_records_doctypes(doctype, visited=None) -> list[str]: +def get_missing_records_doctypes(doctype, visited=None, _parent=None) -> list[str]: """Get the dependencies for the specified doctype in a depth-first manner""" if visited is None: @@ -62,7 +66,18 @@ def get_missing_records_doctypes(doctype, visited=None) -> list[str]: # Mark as visited visited.add(doctype) - _module, test_module = get_modules(doctype) + module, test_module = get_modules(doctype) + if module is None: + # DocType is not installed on this site; skip it instead of crashing the + # whole test run. This typically means a Link field points to a DocType + # from an optional/uninstalled app. + testing_logger.warning( + "Skipping test record generation for %r (linked from %r): DocType not installed on this site", + doctype, + _parent, + ) + return [] + meta = frappe.get_meta(doctype) link_fields = meta.get_link_fields() @@ -79,7 +94,7 @@ def get_missing_records_doctypes(doctype, visited=None) -> list[str]: # Recursive depth-first traversal result = [] for dep_doctype in unique_doctypes: - result.extend(get_missing_records_doctypes(dep_doctype, visited)) + result.extend(get_missing_records_doctypes(dep_doctype, visited, _parent=doctype)) result.append(doctype) return result