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
This commit is contained in:
barredterra 2026-04-21 01:49:08 +02:00
parent 166bb914c1
commit 38e140df22
2 changed files with 45 additions and 4 deletions

View file

@ -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"""

View file

@ -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