| name | testing-patterns |
| description | How to write robust Frappe tests — FrappeTestCase, factories, mocking frappe.sendmail / external HTTP, permission tests, change_settings, anti-patterns to avoid. Use when writing or reviewing test_*.py files, designing test fixtures, or thinking about test isolation. The /frappe-test command runs tests; this skill teaches how to write them. |
Frappe Testing Patterns
Reference for writing maintainable tests in Frappe v14+. Focused on the patterns that hold up as your test suite grows past a few dozen tests — factory functions, fixture isolation, mocking, and the anti-patterns that cause flaky failures in CI.
Which base class
from frappe.tests.utils import FrappeTestCase
Use FrappeTestCase (not unittest.TestCase) for anything that touches Frappe — controllers, APIs, DB. It handles:
- Per-test transaction rollback (you don't need to clean up created docs in
tearDown)
frappe.flags.in_test = True (so production code can branch on test mode if needed)
- Test record loading from
test_records.json
- Permission isolation (resets
frappe.session.user between tests)
Use plain unittest.TestCase only for pure-Python helpers with no Frappe touchpoints (string utilities, math, etc.).
File location and naming
my_app/
└── my_module/
└── doctype/
└── expense_claim/
├── expense_claim.py
├── expense_claim.js
├── expense_claim.json
├── test_expense_claim.py ← convention
└── test_records.json ← optional: seed data for the suite
Frappe discovers tests by walking each app's <module>/doctype/<doctype>/test_*.py. For non-DocType tests (utilities, integrations), put them in my_app/tests/ and Frappe still finds them.
Test methods must start with test_. Helper methods on the test class (factories, common assertions) should not — name them _make_invoice, _assert_balanced, etc.
Test data lifecycle
test_records.json — suite-level fixtures
[
{
"doctype": "Customer",
"customer_name": "_Test Customer Alpha",
"customer_type": "Company",
"territory": "_Test Territory"
},
{
"doctype": "Item",
"item_code": "_Test Item Widget",
"item_name": "Widget",
"stock_uom": "Nos"
}
]
Loaded once per test run, available to every test in the file via frappe.get_doc("Customer", "_Test Customer Alpha"). Use the _Test prefix on names — it's the team convention so test data is visually distinct from production seed data.
test_records.json is loaded with ignore_permissions=True, so it can create docs the test user doesn't normally have access to.
setUp vs setUpClass
class TestExpenseClaim(FrappeTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.approver_user = "_test_approver@example.com"
cls.create_approver_if_missing()
def setUp(self):
frappe.set_user("Administrator")
def tearDown(self):
pass
Most tests need no setUp or tearDown at all — the per-test rollback handles it.
Factory pattern
For docs that vary per test, write a _make_<thing> helper that takes overrides:
class TestExpenseClaim(FrappeTestCase):
def _make_expense_claim(self, **overrides):
defaults = {
"doctype": "Expense Claim",
"employee": "_Test Employee Alpha",
"expenses": [{
"expense_type": "Travel",
"amount": 100.0,
"expense_date": frappe.utils.nowdate(),
}],
}
defaults.update(overrides)
doc = frappe.get_doc(defaults)
doc.insert()
return doc
def test_total_calculation(self):
claim = self._make_expense_claim(expenses=[
{"expense_type": "Travel", "amount": 100.0, "expense_date": frappe.utils.nowdate()},
{"expense_type": "Food", "amount": 50.0, "expense_date": frappe.utils.nowdate()},
])
self.assertEqual(claim.total_claimed_amount, 150.0)
def test_default_status(self):
claim = self._make_expense_claim()
self.assertEqual(claim.status, "Draft")
Factories should be idempotent on the parts that don't change between tests. The Customer and Item are seed data; only the Expense Claim is created per test.
Permission tests
def test_employee_cannot_approve_own_claim(self):
frappe.set_user("alice@example.com")
claim = self._make_expense_claim(employee="HR-EMP-Alice")
with self.assertRaises(frappe.PermissionError):
from frappe.model.workflow import apply_workflow
apply_workflow(claim, "Approve")
frappe.set_user("Administrator")
def test_manager_can_approve(self):
self._make_employee("alice@example.com", roles=["Employee"])
self._make_employee("bob@example.com", roles=["Employee", "Expense Approver"])
frappe.set_user("alice@example.com")
claim = self._make_expense_claim()
apply_workflow(claim, "Submit")
frappe.set_user("bob@example.com")
apply_workflow(claim, "Approve")
self.assertEqual(claim.workflow_state, "Approved")
Always reset to Administrator (or Guest for unauthenticated tests) explicitly — even though FrappeTestCase does it for you, it makes the intent obvious and protects against test ordering changes.
Mocking external calls
Use unittest.mock.patch from the standard library. Frappe ships no special mocking framework.
Mock frappe.sendmail
Most tests should not actually send mail. Patch it for the duration of the test:
from unittest.mock import patch
class TestExpenseClaim(FrappeTestCase):
@patch("frappe.sendmail")
def test_approval_sends_notification(self, mock_sendmail):
claim = self._make_expense_claim()
apply_workflow(claim, "Approve")
mock_sendmail.assert_called_once()
call_kwargs = mock_sendmail.call_args.kwargs
self.assertIn("approved", call_kwargs["subject"].lower())
self.assertEqual(call_kwargs["recipients"], [claim.employee_email])
Mock external HTTP
@patch("requests.post")
def test_webhook_fires_on_submit(self, mock_post):
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = {"received": True}
claim = self._make_expense_claim()
claim.submit()
mock_post.assert_called_once()
self.assertEqual(mock_post.call_args.args[0], "https://hooks.example.com/expense")
Mock frappe.publish_realtime
@patch("frappe.publish_realtime")
def test_progress_published_during_long_job(self, mock_publish):
run_my_long_job(args)
self.assertTrue(mock_publish.called)
frappe.flags.in_test
Set automatically by FrappeTestCase. Use it sparingly in production code:
def send_notification(recipient, message):
if frappe.flags.in_test:
return
frappe.sendmail(recipients=[recipient], message=message)
Prefer mocking the function in the test over branching on in_test in production code. Branching pollutes business logic with test concerns; mocking keeps the production path clean.
change_settings context manager
For tests that depend on a Single doctype's setting:
from frappe.tests.utils import change_settings
class TestStockNegative(FrappeTestCase):
def test_negative_stock_blocked(self):
with self.assertRaises(frappe.ValidationError):
self._make_delivery_note_exceeding_stock()
def test_negative_stock_allowed_when_setting_on(self):
with change_settings("Stock Settings", {"allow_negative_stock": 1}):
doc = self._make_delivery_note_exceeding_stock()
self.assertEqual(doc.docstatus, 1)
Settings are restored after the with block, even if the test fails. Don't manually save and restore — change_settings handles transactions correctly.
Background job tests
By default, frappe.enqueue queues the job for a worker — but in tests, you want it to run synchronously:
def test_nightly_sync_creates_records(self):
from my_app.tasks import sync_external_data
sync_external_data(account="ACC-001")
frappe.enqueue("my_app.tasks.sync_external_data", now=True, account="ACC-001")
records = frappe.get_all("Sync Log", filters={"account": "ACC-001"})
self.assertEqual(len(records), 1)
now=True does not actually go through Redis; it runs the function in the current process. Safe in tests.
Test API endpoints
Whitelisted methods are just functions. Test them by importing and calling:
def test_get_outstanding_returns_dict(self):
from my_app.api import get_customer_outstanding
result = get_customer_outstanding(customer="_Test Customer Alpha")
self.assertIsInstance(result, dict)
self.assertIn("outstanding", result)
self.assertGreaterEqual(result["outstanding"], 0)
def test_api_blocks_guest(self):
from my_app.api import get_customer_outstanding
frappe.set_user("Guest")
with self.assertRaises(frappe.PermissionError):
get_customer_outstanding(customer="_Test Customer Alpha")
frappe.set_user("Administrator")
For testing the HTTP layer (CSRF, query parsing, response serialization), use frappe.tests.test_api patterns or call through frappe.client — but most API logic tests don't need to go through HTTP.
Coverage strategy
What to test:
validate() and lifecycle hooks — every branch, especially the frappe.throw paths
on_submit / on_cancel side effects — the linked docs, ledger entries, notifications
- Whitelisted methods — happy path + each
frappe.throw + permission boundary
- Custom queries — especially anything with
frappe.db.sql or aggregation
- Complex calculations — taxes, discounts, currency conversions
- Permission boundaries — at least one test per role per restricted action
What not to test:
- Frappe internals — don't test that
frappe.get_doc works
- Generated CRUD — Frappe handles insert / update / delete; only test your overrides
- Stylistic choices — don't assert on field order, label casing, etc.
- Implementation details — assert on observable behavior, not internal method names. If you rename a private helper, the test should still pass.
A useful target: test every frappe.throw and every if branch in validate / on_submit. Coverage of pure data transformation is easy; coverage of error paths is what catches regressions.
Anti-patterns to avoid
| Anti-pattern | Why it bites | What to do |
|---|
| Test depends on prior test having run | Test ordering changes break the suite | Each test creates its own data via factories |
Hardcoded user emails like "badal@example.com" | Breaks when run on a different machine | Use the _Test prefix or a fixture user |
Asserting on auto-generated names (SINV-2026-00001) | Names change with naming series, year, prior data | Assert on doc.name existing, not on its value |
setUp does expensive DB setup that all tests redo | Slow suite | Move shared setup to setUpClass |
Test catches a generic Exception | Hides the real failure mode | Catch specific exceptions: frappe.ValidationError, frappe.PermissionError |
time.sleep in tests | Flaky in CI | Mock time-dependent functions instead |
Test creates docs with ignore_permissions=True everywhere | Hides permission bugs | Use Administrator; switch users explicitly when testing perms |
Test commits to DB explicitly (frappe.db.commit()) | Breaks transaction rollback; pollutes DB across tests | Never call commit() in a test |
assertEqual(doc.status, "Submitted") without first asserting doc.docstatus == 1 | Status field can be set independently of submission | Assert on the lifecycle state, not just denormalized fields |
Full example: controller test
from unittest.mock import patch
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.model.workflow import apply_workflow
class TestExpenseClaim(FrappeTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._ensure_user("alice@example.com", ["Employee"])
cls._ensure_user("bob@example.com", ["Employee", "Expense Approver"])
@staticmethod
def _ensure_user(email, roles):
if frappe.db.exists("User", email):
return
user = frappe.get_doc({
"doctype": "User",
"email": email,
"first_name": email.split("@")[0].title(),
"send_welcome_email": 0,
"roles": [{"role": r} for r in roles],
})
user.insert(ignore_permissions=True)
def _make_claim(self, **overrides):
defaults = {
"doctype": "Expense Claim",
"employee": "_Test Employee Alpha",
"expenses": [{
"expense_type": "Travel",
"amount": 100.0,
"expense_date": frappe.utils.nowdate(),
}],
}
defaults.update(overrides)
doc = frappe.get_doc(defaults)
doc.insert()
return doc
def test_total_is_sum_of_expenses(self):
claim = self._make_claim(expenses=[
{"expense_type": "Travel", "amount": 100.0, "expense_date": frappe.utils.nowdate()},
{"expense_type": "Food", "amount": 50.0, "expense_date": frappe.utils.nowdate()},
])
self.assertEqual(claim.total_claimed_amount, 150.0)
def test_negative_amount_rejected(self):
with self.assertRaises(frappe.ValidationError):
self._make_claim(expenses=[
{"expense_type": "Travel", "amount": -10.0, "expense_date": frappe.utils.nowdate()},
])
def test_future_expense_date_rejected(self):
future = frappe.utils.add_days(frappe.utils.nowdate(), 7)
with self.assertRaises(frappe.ValidationError):
self._make_claim(expenses=[
{"expense_type": "Travel", "amount": 100.0, "expense_date": future},
])
@patch("frappe.sendmail")
def test_approve_submits_and_notifies(self, mock_sendmail):
frappe.set_user("alice@example.com")
claim = self._make_claim()
apply_workflow(claim, "Submit")
frappe.set_user("bob@example.com")
apply_workflow(claim, "Approve")
claim.reload()
self.assertEqual(claim.workflow_state, "Approved")
self.assertEqual(claim.docstatus, 1)
mock_sendmail.assert_called()
frappe.set_user("Administrator")
def test_self_approval_blocked(self):
frappe.set_user("bob@example.com")
claim = self._make_claim(employee="HR-EMP-Bob")
apply_workflow(claim, "Submit")
with self.assertRaises(frappe.PermissionError):
apply_workflow(claim, "Approve")
frappe.set_user("Administrator")
def test_cancel_reverses_payment(self):
claim = self._make_claim()
apply_workflow(claim, "Submit")
apply_workflow(claim, "Approve")
payment_name = frappe.db.get_value(
"Payment Entry",
{"reference_doctype": "Expense Claim", "reference_name": claim.name},
"name",
)
self.assertIsNotNone(payment_name)
claim.cancel()
payment_status = frappe.db.get_value("Payment Entry", payment_name, "docstatus")
self.assertEqual(payment_status, 2, "Payment should be cancelled")
See also
/frappe-test command — runs the tests this skill teaches you to write
server-scripts skill — controller hooks (validate, on_submit, on_cancel) you'll be testing
references/frappe-python.md (in frappe-review skill) — review-time checks for tests (test isolation, hardcoded users, missing tests for new APIs)