Testing on Live Databases
Run Frappe integration tests against a live database safely by suppressing commits, patching side effects, and rolling back.
On this page
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_applicationThis article is in the public domain (CC0 1.0), code samples included. Use it however helps you.