Skip to content
Skip to the article
In Frappe: 20 articles
Frappe

Testing on Live Databases

Run Frappe integration tests against a live database safely by suppressing commits, patching side effects, and rolling back.

Updated
Tags
  • frappe
  • testing
  • python
  • database
Reading time
4 min

Frappe integration tests run against the live database. This makes them powerful but dangerous — business logic often calls frappe.db.commit(), which permanently cements test data even if tearDown attempts a rollback().

The Core Problem

During normal operation, code paths like workflow actions, messaging group creation, and payment processing call frappe.db.commit(). When tests trigger these paths, the commit makes test artifacts permanent.

Test creates Season → triggers validate() → validate() calls commit() → data is permanent
↓
tearDownClass calls rollback() → but commit already persisted the data!

The Solution: Commit Suppression

Patch frappe.db.commit to a no-op for the duration of the test class:

from typing import Any
from unittest.mock import MagicMock, patch

import frappe
from frappe.tests.classes import IntegrationTestCase


class MyTestCase(IntegrationTestCase):
    _commit_patcher: Any = None

    @classmethod
    def setUpClass(cls) -> None:
        super().setUpClass()
        # Suppress all commits — rollback will actually undo everything
        cls._commit_patcher = patch.object(frappe.db, "commit", MagicMock())
        cls._commit_patcher.start()
        cls.addClassCleanup(cls._commit_patcher.stop)

    @classmethod
    def tearDownClass(cls) -> None:
        frappe.db.rollback()  # Undo all test changes
        if cls._commit_patcher:
            cls._commit_patcher.stop()
        super().tearDownClass()

With commit patched to a no-op, rollback() in tearDownClass actually undoes all changes made during tests.

Patching External Side Effects

Tests shouldn't send real emails, create messaging groups, or trigger workflows. Patch these alongside the commit:

from frappe.email.doctype.notification.notification import Notification
from frappe.workflow.doctype.workflow_action import workflow_action
from messaging.messaging.custom.contact import Contact


class AppTestCase(IntegrationTestCase):
    _commit_patcher: Any = None
    _add_to_group_patcher: Any = None
    _notification_send_patcher: Any = None
    _workflow_email_patcher: Any = None

    @classmethod
    def setUpClass(cls) -> None:
        super().setUpClass()

        # Patch commit
        cls._commit_patcher = patch.object(frappe.db, "commit", MagicMock())
        cls._commit_patcher.start()
        cls.addClassCleanup(cls._commit_patcher.stop)

        # Patch messaging side effects
        cls._add_to_group_patcher = patch.object(
            Contact, "add_to_group", MagicMock()
        )
        cls._add_to_group_patcher.start()
        cls.addClassCleanup(cls._add_to_group_patcher.stop)

        # Patch notification sending
        cls._notification_send_patcher = patch.object(
            Notification, "send", MagicMock()
        )
        cls._notification_send_patcher.start()
        cls.addClassCleanup(cls._notification_send_patcher.stop)

        # Patch workflow email sending
        cls._workflow_email_patcher = patch.object(
            workflow_action, "send_workflow_action_email", MagicMock()
        )
        cls._workflow_email_patcher.start()
        cls.addClassCleanup(cls._workflow_email_patcher.stop)

    @classmethod
    def tearDownClass(cls) -> None:
        frappe.db.rollback()
        if cls._commit_patcher:
            cls._commit_patcher.stop()
        super().tearDownClass()

Saving and Restoring Critical State

If business logic modifies shared state via direct SQL (outside the ORM), save and restore it:

def save_current_season() -> str | None:
    val = frappe.db.get_value("Season", {"current": 1}, "name")
    return str(val) if val else None


def restore_current_season(original_name: str | None) -> None:
    if not original_name:
        return
    current = frappe.db.get_value("Season", {"current": 1}, "name")
    if current == original_name:
        return
    frappe.db.sql("UPDATE `tabSeason` SET `current` = 0 WHERE `current` = 1")
    frappe.db.set_value("Season", original_name, "current", 1, update_modified=False)
    frappe.db.commit()  # Real commit — patch is stopped by now


class AppTestCase(IntegrationTestCase):
    _original_current_season: str | None = None

    @classmethod
    def setUpClass(cls) -> None:
        super().setUpClass()
        cls._original_current_season = save_current_season()
        # ... start patches

    @classmethod
    def tearDownClass(cls) -> None:
        frappe.db.rollback()
        if cls._commit_patcher:
            cls._commit_patcher.stop()  # Stop commit patch BEFORE restoring
        restore_current_season(cls._original_current_season)
        super().tearDownClass()

Order matters: Stop the commit patcher before restoring state, so the restore's commit() actually executes.

Test Fixtures and Idempotent Setup

Create test data idempotently — check existence before inserting:

class TestPlayerApplication(AppTestCase):
    def setUp(self) -> None:
        self._ensure_test_fixtures()

    def _ensure_test_fixtures(self) -> None:
        if not frappe.db.exists("Season", {"season_name": "Test Season"}):
            season = frappe.new_doc("Season")
            season.season_name = "Test Season"
            season.current = 1
            season.insert()

        if not frappe.db.exists("Division", {"division_name": "Test Division"}):
            division = frappe.new_doc("Division")
            division.division_name = "Test Division"
            division.insert()

Testing Guest/Public Access

Simulate guest users for web form and public API testing:

def test_guest_can_submit(self) -> None:
    frappe.set_user("Guest")
    try:
        app = frappe.get_doc({
            "doctype": "Player Application",
            "name_player_first": "Test",
            ...
        })
        app.insert()
        self.assertTrue(frappe.db.exists("Player Application", app.name))
    finally:
        frappe.set_user("Administrator")

Testing Web Form Payment Flow

def test_webform_accept_for_payment(self) -> None:
    from payments.overrides.payment_webform import accept

    web_form = frappe.get_doc("Web Form", "player-enrollment")
    data = self.get_test_enrollment_data()

    # Simulate form submission
    result = accept(web_form, data)

    # Result should contain a payment redirect
    self.assertIn("stripe.com", result)

    # Parse and validate the payment URL
    parsed = urlparse(result)
    params = parse_qs(parsed.query)
    self.assertEqual(params["amount"][0], "150.0")
    self.assertEqual(params["currency"][0], "USD")

Manual Cleanup Within Tests

Even with rollback, explicit cleanup helps when tests in the same class depend on clean state:

def _cleanup_linked_documents(self, docs: dict) -> None:
    """Reverse-order cleanup respecting foreign key constraints."""
    frappe.set_user("Administrator")

    if docs.get("application"):
        if frappe.db.exists("Player Application", docs["application"]):
            app = frappe.get_doc("Player Application", docs["application"])
            if app.docstatus == 1:
                app.cancel()
            app.delete()

    if docs.get("player"):
        if frappe.db.exists("Player", docs["player"]):
            frappe.delete_doc("Player", docs["player"])

    if docs.get("family"):
        if frappe.db.exists("Family", docs["family"]):
            frappe.delete_doc("Family", docs["family"])

    # Continue in reverse creation order...

Running Tests

# Run all tests for an app
bench --site mysite run-tests --app myapp

# Run tests for a specific doctype
bench --site mysite run-tests --doctype "Player Application"

# Run a specific test file
bench --site mysite run-tests --module my_app.my_module.doctype.player_application.test_player_application

This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.