Skip to content

Generating Test Records in Frappe

ruthra kumar edited this page Oct 14, 2024 · 1 revision

Generating Test Records in Frappe: A Detailed Guide [Generation]

This article provides an in-depth explanation of how test records are generated in Frappe using the make_test_records entry point. This process is crucial for testing and development, allowing developers to create sample data for their applications.

Overview

The make_test_records function is responsible for generating test records for a specified DocType and its dependencies. This function ensures that all necessary records are created to facilitate comprehensive testing of the application's functionality. However, it's important to note that users typically do not need to call this function directly, as test records are automatically loaded by an IntegrationTestCase.

Key Components

1. make_test_records Function

The make_test_records function serves as the entry point for generating test records. It takes the following parameters:

  • doctype: The name of the DocType for which test records need to be generated.
  • force: A boolean indicating whether existing records should be reset and regenerated.
  • commit: A boolean indicating whether changes should be committed to the database.
def make_test_records(doctype, force=False, commit=False):
    """Generate test records for the given doctype and its dependencies."""
    return list(_generate_all_records_towards(doctype, reset=force, commit=commit))

Note: This function is typically invoked indirectly through an IntegrationTestCase, which uses the current document as the entry point for loading test records.

2. _generate_all_records_towards Function

This function generates test records for the specified DocType and its dependencies. It performs a depth-first traversal to ensure all dependencies are resolved before creating records.

def _generate_all_records_towards(index_doctype, reset=False, commit=False) -> Generator[tuple[str, int], None, None]:
    """Generate test records for the given doctype and its dependencies."""
    visited = set(frappe.local.test_objects.keys())
    for _index_doctype in get_missing_records_doctypes(index_doctype, visited):
        res = list(_generate_records_for(_index_doctype, reset=reset, commit=commit, initial_doctype=index_doctype))
        yield (_index_doctype, len(res))

3. _generate_records_for Function

This function creates and yields test records for a specific DocType. It handles custom logic defined in modules and loads test records from JSON or TOML files if available.

def _generate_records_for(index_doctype: str, reset: bool = False, commit: bool = False, initial_doctype: str | None = None) -> Generator[tuple[str, "Document"], None, None]:
    """Create and yield test records for a specific doctype."""
    module, test_module = get_modules(index_doctype)
    
    if hasattr(test_module, "_make_test_records"):
        yield from test_module._make_test_records()
    else:
        if hasattr(test_module, "test_records"):
            test_records = test_module.test_records
        else:
            test_records = load_test_records_for(index_doctype)
        if isinstance(test_records, list):
            test_records = _transform_legacy_json_records(test_records, index_doctype)
        
        yield from _sync_records(index_doctype, test_records, reset=reset, commit=commit)

Note: Upstream developers may use the module level's test_records attribute, when downstream applications disagree over these test records, so that they can be monkey patched:

# Upstream test_my_doctype.py
test_records = frappe.tests.utils.load_test_records_for("My Doctype")
# Donwnstream app's before_tests hook
import upstream.some.doctype.my_doctype.test_my_doctype
upstream.some.doctype.my_doctype.test_my_doctype.test_records =  [] # overrides

4. _sync_records Function

This function synchronizes the provided test records with the database. It handles caching and persistence to ensure efficient record management. When regenerating records with force=True, it may not necessarily reset existing records but instead create additional ones if no name is provided.

def _sync_records(index_doctype: str, test_records: dict[str, list], reset: bool = False, commit: bool = False) -> Generator[tuple[str, "Document"], None, None]:
    """Generate test objects for a register doctype from provided records."""
    global test_record_manager_instance
    if test_record_manager_instance is None:
        test_record_manager_instance = TestRecordManager()

    def _load(do_create=True):
        created, loaded = [], []
        for _sub_doctype, records in test_records.items():
            for record in records:
                if "doctype" not in record:
                    record["doctype"] = _sub_doctype

                if do_create:
                    doc, was_created = _try_create(record, reset, commit)
                    if was_created:
                        created.append(doc)
                    else:
                        loaded.append(doc)

                frappe.local.test_objects[index_doctype].append(MappingProxyType(record))

        if not do_create:
            loaded.extend(test_record_manager_instance.get_records(index_doctype))
        else:
            test_record_manager_instance.add(index_doctype, created)

        for item in created:
            yield ("created", item)
        for item in loaded:
            yield ("loaded", item)

    if index_doctype in test_record_manager_instance.get():
        if reset:
            frappe.local.test_objects[index_doctype] = []
            test_record_manager_instance.remove(index_doctype)
            yield from _load()
        else:
            if index_doctype not in frappe.local.test_objects:
                yield from _load(do_create=False)
            else:
                yield from _load()
    else:
        yield from _load()

5. _try_create Function

The _try_create function is responsible for creating a single document from the given record data. It uses a naming series to construct names if no name is explicitly provided. This behavior thus falls back to adding new entries rather than overwriting existing ones when regenerating records without explicit name attributes.

def _try_create(record, reset=False, commit=False) -> tuple["Document", bool]:
    """Create a single test document from the given record data."""

    def revert_naming(d):
        if getattr(d, "naming_series", None):
            revert_series_if_last(d.naming_series, d.name)

    if not reset:
        frappe.db.savepoint("creating_test_record")

    d = frappe.copy_doc(record)

    # Use naming series to construct names if none is provided
    if d.meta.get_field("naming_series"):
        if not d.naming_series:
            d.naming_series = "_T-" + d.doctype + "-"

    if record.get("name"):
        d.name = record.get("name")
    else:
        d.set_new_name()

    if frappe.db.exists(d.doctype, d.name) and not reset:
        frappe.db.rollback(save_point="creating_test_record")
        return frappe.get_doc(d.doctype, d.name), False

    docstatus = d.docstatus
    d.docstatus = 0

    try:
        d.run_method("before_test_insert")
        d.insert(ignore_if_duplicate=True)

        if docstatus == 1:
            d.submit()

    except frappe.NameError:
        revert_naming(d)

    except Exception as e:
        if d.flags.ignore_these_exceptions_in_test and e.__class__ in d.flags.ignore_these_exceptions_in_test:
            revert_naming(d)
        else:
            logger.debug(f"Error in making test record for {d.doctype} {d.name}")
            raise

    if commit:
        frappe.db.commit()

    return d, True

6. TestRecordManager and JSONL File

The TestRecordManager class manages a persistent log of created test records using a JSONL file (.test_records.jsonl). This file serves as proof of prior processing of each DocType's records and helps track creation across re-executions.

class TestRecordManager:
    def __init__(self):
        self.log_file = Path(frappe.get_site_path(PERSISTENT_TEST_LOG_FILE))
    
    def get(self):
        # Read log file contents
        ...

    def add(self, index_doctype, records: list["Document"]):
        # Append new entries to log file
        ...
    
    def remove(self, index_doctype):
        # Remove entries from log file
        ...

Example Usage

To generate test records for a specific DocType named "Customer," you can use the following code:

# Generate test records for the Customer DocType
test_records = make_test_records("Customer")

# Force regeneration of existing records
test_records_with_force = make_test_records("Customer", force=True)

# Generate and commit changes to the database
test_records_with_commit = make_test_records("Customer", commit=True)

Important Considerations

  • Automatic Invocation: The make_test_records function is typically called automatically by an IntegrationTestCase, which uses the current document as the entry point.
  • Record Regeneration: When using force=True, existing records might not be reset but instead additional ones could be created due to naming series logic.
  • Naming Series: If no name attribute is provided on the record then a naming series is used to construct names automatically.
  • Persistent Log: The JSONL file managed by TestRecordManager helps track created records across tests and re-executions.

Conclusion

The make_test_records function is the core load-bearing function for test record generation in Frappe's testing framework. Since it is automatically invoked by IntegrationTestCase, manual invocation is generally unnecessary. However, understanding its components and workflow allows developers to effectively manage test data for their applications. This guide provides a comprehensive overview of this functionality while highlighting scenarios where direct invocation might be necessary—such as when recreating records after manipulation for cleanup purposes with force=True. Additionally, the persistent JSONL log file aids in maintaining consistency across multiple runs by tracking all generated test data.

Clone this wiki locally