
Frappe Testing Unit
- 1 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Write Frappe unit and integration tests with IntegrationTestCase, UnitTestCase, and fixtures, running them via bench run-tests.
About
Guides writing unit and integration tests for Frappe apps and running them with bench run-tests. A developer uses it when adding test coverage and fixtures to a Frappe app.
- Write unit and integration tests with frappe.tests.utils
- Covers IntegrationTestCase, UnitTestCase, fixtures, and bench run-tests
Frappe Testing Unit by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,750 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/erpnext_anthropic_claude_development_skill_package --skill frappe-testing-unitAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Write Frappe unit and integration tests with IntegrationTestCase, UnitTestCase, and fixtures, running them via bench run-tests.
Files
Unit & Integration Testing
Quick Reference
| Task | Command / Class |
|---|---|
| Run all tests | bench --site test_site run-tests |
| Run tests for app | bench --site test_site run-tests --app myapp |
| Run tests for doctype | bench --site test_site run-tests --doctype "Sales Order" |
| Run single test method | bench --site test_site run-tests --doctype "Sales Order" --test test_submit |
| Run tests for module | bench --site test_site run-tests --module "myapp.mymodule.doctype.mydt.test_mydt" |
| Run with profiler | bench --site test_site run-tests --doctype "Task" --profile |
| Run with failfast | bench --site test_site run-tests --failfast |
| Generate JUnit XML | bench --site test_site run-tests --junit-xml-output /path/report.xml |
| Skip fixture loading | bench --site test_site run-tests --skip-test-records --skip-before-tests |
| Base class (v14) | from frappe.tests.utils import FrappeTestCase |
| Unit test class (v15+) | from frappe.tests.classes import UnitTestCase |
| Integration test class (v15+) | from frappe.tests.classes import IntegrationTestCase |
Decision Tree: Which Test Base Class?
Need to test a function or method in isolation?
├─ YES → Does it require database access?
│ ├─ NO → UnitTestCase (v15+) or FrappeTestCase (v14)
│ └─ YES → IntegrationTestCase (v15+) or FrappeTestCase (v14)
└─ NO → Need to test document lifecycle (create/submit/cancel)?
├─ YES → IntegrationTestCase (v15+) or FrappeTestCase (v14)
└─ NO → Need to test permissions or user context?
├─ YES → IntegrationTestCase (v15+) or FrappeTestCase (v14)
└─ NO → UnitTestCase (v15+) or FrappeTestCase (v14)Version note: In v14, FrappeTestCase is the ONLY base class. In v15+, it still works (deprecated wrapper) but ALWAYS prefer UnitTestCase or IntegrationTestCase for new code.
Test Base Classes
FrappeTestCase (v14: still works in v15+ as compatibility wrapper)
from frappe.tests.utils import FrappeTestCase
class TestMyDoctype(FrappeTestCase):
def test_something(self):
doc = frappe.get_doc({"doctype": "My Doctype", "field": "value"})
doc.insert()
self.assertEqual(doc.field, "value")Behavior: Resets frappe.local.flags after each test. Database transactions start before each test and rollback afterward. ALWAYS call super().setUpClass() if you override setUpClass.
UnitTestCase (v15+): No Database Access
from frappe.tests.classes import UnitTestCase
class TestMyUtils(UnitTestCase):
def test_calculation(self):
result = my_calculation(10, 20)
self.assertEqual(result, 30)
def test_html_output(self):
html = generate_html()
self.assertEqual(self.normalize_html(html), self.normalize_html(expected))Behavior: Sets frappe.set_user("Administrator") in setUpClass. Auto-detects doctype from module path. Provides normalize_html(), normalize_sql(), assertDocumentEqual(), assertQueryEqual(), assertSequenceSubset().
IntegrationTestCase (v15+): Full Database Access
from frappe.tests.classes import IntegrationTestCase
class TestSalesOrder(IntegrationTestCase):
def test_submit_order(self):
so = frappe.get_doc({
"doctype": "Sales Order",
"customer": "_Test Customer",
"items": [{"item_code": "_Test Item", "qty": 1, "rate": 100}]
}).insert()
so.submit()
self.assertEqual(so.docstatus, 1)Behavior: Extends UnitTestCase. Calls frappe.init() and sets up site connection. Loads test record dependencies via make_test_records(). Provides primary_connection() and secondary_connection() context managers. maxDiff = 10_000.
Test File Structure
ALWAYS place test files in the doctype directory following this naming convention:
myapp/
└── mymodule/
└── doctype/
└── my_doctype/
├── my_doctype.py # DocType controller
├── my_doctype.json # DocType definition
├── test_my_doctype.py # Test file (MUST start with test_)
└── test_records.json # Optional: test fixturesRules:
- ALWAYS prefix test files with
test_— the test runner ignores files without this prefix - ALWAYS use
test_{doctype_in_snake_case}.pyfor doctype tests - NEVER place test files outside the doctype directory for doctype-specific tests
- Non-doctype tests can live in any module, but MUST follow the
test_*.pynaming
Test Fixtures
Method 1: test_records.json (Static Fixtures)
Create a test_records.json file in the doctype directory:
[
{
"doctype": "My Doctype",
"field1": "_Test Value 1",
"field2": 100
},
{
"doctype": "My Doctype",
"field1": "_Test Value 2",
"field2": 200
}
]Rules:
- ALWAYS prefix test data values with
_Testto distinguish from production data - The test runner auto-loads these before running tests for the doctype
- Link field dependencies are resolved automatically — the runner builds records for linked DocTypes first
Method 2: _test_records List (In-Module Fixtures)
_test_records = [
{"doctype": "My Doctype", "field1": "_Test Value 1"},
{"doctype": "My Doctype", "field1": "_Test Value 2"},
]Method 3: Programmatic Fixtures (Recommended for Complex Data)
def create_test_data():
if frappe.flags.test_data_created:
return
frappe.set_user("Administrator")
frappe.get_doc({
"doctype": "My Doctype",
"field1": "_Test Value",
}).insert()
frappe.flags.test_data_created = True
class TestMyDoctype(IntegrationTestCase):
def setUp(self):
create_test_data()ALWAYS use frappe.flags to prevent duplicate fixture creation across test methods.
Testing Patterns
Testing Document Lifecycle
class TestInvoice(IntegrationTestCase):
def test_full_lifecycle(self):
# Create
doc = frappe.get_doc({"doctype": "Sales Invoice", ...}).insert()
self.assertEqual(doc.docstatus, 0) # Draft
# Submit
doc.submit()
self.assertEqual(doc.docstatus, 1) # Submitted
# Cancel
doc.cancel()
self.assertEqual(doc.docstatus, 2) # CancelledTesting Permissions
class TestPermissions(IntegrationTestCase):
def test_user_cannot_read_private(self):
frappe.set_user("test1@example.com")
doc = frappe.get_doc("Event", {"subject": "_Test Private Event"})
self.assertFalse(frappe.has_permission("Event", doc=doc))
def tearDown(self):
# ALWAYS reset user in tearDown
frappe.set_user("Administrator")Testing with User Context (v15+ Context Manager)
class TestAccess(IntegrationTestCase):
def test_restricted_access(self):
with self.set_user("test1@example.com"):
self.assertRaises(
frappe.PermissionError,
frappe.get_doc, "Salary Slip", "SAL-001"
)
# User automatically restored after context manager exitsTesting Whitelisted Methods
class TestAPI(IntegrationTestCase):
def test_whitelisted_method(self):
frappe.set_user("test1@example.com")
result = frappe.call("myapp.api.get_dashboard_data", filters={})
self.assertIsInstance(result, dict)
self.assertIn("total", result)Mocking External Services
from unittest.mock import patch, MagicMock
class TestIntegration(IntegrationTestCase):
@patch("myapp.integrations.stripe.requests.post")
def test_payment_gateway(self, mock_post):
mock_post.return_value = MagicMock(
status_code=200,
json=lambda: {"status": "success", "id": "ch_123"}
)
result = process_payment(amount=1000, currency="USD")
self.assertEqual(result["status"], "success")
mock_post.assert_called_once()Testing with Settings Changes
class TestFeature(IntegrationTestCase):
def test_with_modified_settings(self):
with self.change_settings("Selling Settings", {"so_required": 1}):
# Settings temporarily changed
self.assertRaises(frappe.ValidationError, create_delivery_note)
# Settings automatically revertedTesting with Hook Overrides
class TestHooks(IntegrationTestCase):
def test_custom_hook(self):
with self.patch_hooks({"on_submit": ["myapp.hooks.custom_on_submit"]}):
doc = create_and_submit_doc()
# Verify hook was executedContext Managers Reference
| Context Manager | Available On | Purpose |
|---|---|---|
set_user(user) | UnitTestCase, IntegrationTestCase | Temporarily switch user context |
change_settings(dt, **kw) | UnitTestCase, IntegrationTestCase | Temporarily modify settings |
patch_hooks(overrides) | UnitTestCase, IntegrationTestCase | Temporarily override hooks |
freeze_time(time) | UnitTestCase, IntegrationTestCase | Freeze time for deterministic tests |
debug_on(*exceptions) | UnitTestCase, IntegrationTestCase | Drop into debugger on exception |
timeout(seconds) | Decorator | Fail test if it exceeds time limit |
enable_safe_exec() | IntegrationTestCase | Enable server scripts temporarily |
switch_site(site) | IntegrationTestCase | Switch to a different site |
assertQueryCount(n) | IntegrationTestCase | Assert exact SQL query count |
assertRedisCallCounts(**kw) | IntegrationTestCase | Assert Redis command counts |
assertRowsRead(n) | IntegrationTestCase | Assert row-level DB access limits |
Database State Management
- IntegrationTestCase: ALWAYS rolls back database after each test — no cleanup needed
- UnitTestCase: No database connection — NEVER use
frappe.dbcalls - Each test gets a clean state: transactions start in
setUpand rollback intearDown - NEVER call
frappe.db.commit()in tests — this breaks test isolation - Use
frappe.flags.in_testto check if code is running under the test runner
Detecting Test Mode
if frappe.flags.in_test:
# Skip external API calls, emails, etc.
return mock_response()NEVER use frappe.flags.in_test to skip validation logic — tests MUST exercise the same code paths as production.
Common Pitfalls
1. NEVER forget `super().setUpClass()` — omitting this breaks fixture loading and user setup 2. NEVER call `frappe.db.commit()` in tests — this persists data across tests and breaks isolation 3. ALWAYS reset user in `tearDown` if you called frappe.set_user() directly (v14 pattern) 4. ALWAYS prefix test data with `_Test` — makes cleanup and identification easy 5. NEVER rely on test execution order — each test MUST be independent 6. ALWAYS use `frappe.flags` to guard fixture creation — prevents duplicate inserts
See Also
- references/examples.md — Complete test examples
- references/anti-patterns.md — Common mistakes and fixes
- references/fixtures.md — Fixture patterns in depth
- references/api-reference.md — Full API reference for test utilities
- frappe-testing-cicd — CI/CD pipeline setup
Unit & Integration Test Anti-Patterns
Anti-Pattern 1: Calling frappe.db.commit() in Tests
# WRONG — breaks test isolation, data persists across tests
class TestBadCommit(IntegrationTestCase):
def test_something(self):
doc = frappe.get_doc({"doctype": "ToDo", "description": "test"}).insert()
frappe.db.commit() # NEVER do this# CORRECT — let the test framework handle rollback
class TestGoodRollback(IntegrationTestCase):
def test_something(self):
doc = frappe.get_doc({"doctype": "ToDo", "description": "test"}).insert()
# No commit — IntegrationTestCase rolls back automaticallyAnti-Pattern 2: Forgetting super().setUpClass()
# WRONG — breaks fixture loading and user setup
class TestBroken(IntegrationTestCase):
@classmethod
def setUpClass(cls):
cls.custom_data = "something"
# Missing super().setUpClass() — tests will fail unpredictably# CORRECT — ALWAYS call super first
class TestFixed(IntegrationTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.custom_data = "something"Anti-Pattern 3: Tests Depending on Execution Order
# WRONG — test_b depends on test_a having run first
class TestOrderDependent(IntegrationTestCase):
def test_a_create(self):
frappe.get_doc({"doctype": "ToDo", "description": "_Order Test"}).insert()
def test_b_read(self):
# This WILL fail because test_a's insert was rolled back
doc = frappe.get_doc("ToDo", {"description": "_Order Test"})# CORRECT — each test is self-contained
class TestIndependent(IntegrationTestCase):
def test_create_and_read(self):
doc = frappe.get_doc({"doctype": "ToDo", "description": "_Order Test"}).insert()
fetched = frappe.get_doc("ToDo", doc.name)
self.assertEqual(fetched.description, "_Order Test")Anti-Pattern 4: Not Resetting User Context (v14)
# WRONG — user context leaks to next test
class TestLeakyUser(FrappeTestCase):
def test_as_user(self):
frappe.set_user("test@example.com")
# ... test logic ...
# No tearDown to reset user!# CORRECT — ALWAYS reset user in tearDown (v14 pattern)
class TestCleanUser(FrappeTestCase):
def tearDown(self):
frappe.set_user("Administrator")
def test_as_user(self):
frappe.set_user("test@example.com")
# ... test logic ...# BEST (v15+) — use context manager, auto-resets
class TestContextUser(IntegrationTestCase):
def test_as_user(self):
with self.set_user("test@example.com"):
# ... test logic ...
# User automatically restoredAnti-Pattern 5: Using UnitTestCase with Database Calls
# WRONG — UnitTestCase has no database connection
class TestBadUnit(UnitTestCase):
def test_fetch(self):
doc = frappe.get_doc("ToDo", "TODO-001") # WILL fail — no DB# CORRECT — use IntegrationTestCase for database operations
class TestGoodIntegration(IntegrationTestCase):
def test_fetch(self):
doc = frappe.get_doc({"doctype": "ToDo", "description": "test"}).insert()
fetched = frappe.get_doc("ToDo", doc.name)
self.assertEqual(fetched.description, "test")Anti-Pattern 6: Hardcoded Test Data Names
# WRONG — may conflict with production data
class TestHardcoded(IntegrationTestCase):
def test_customer(self):
doc = frappe.get_doc({"doctype": "Customer", "customer_name": "Acme Corp"})# CORRECT — ALWAYS prefix with _Test
class TestPrefixed(IntegrationTestCase):
def test_customer(self):
doc = frappe.get_doc({"doctype": "Customer", "customer_name": "_Test Acme Corp"})Anti-Pattern 7: Duplicate Fixture Creation
# WRONG — fixtures created multiple times, causing insert errors
def create_fixtures():
frappe.get_doc({"doctype": "Item", "item_code": "_Test Item"}).insert()
class TestDuplicate(IntegrationTestCase):
def setUp(self):
create_fixtures() # Called before EVERY test — fails on 2nd test# CORRECT — guard with frappe.flags
def create_fixtures():
if frappe.flags.test_fixtures_created:
return
frappe.get_doc({"doctype": "Item", "item_code": "_Test Item"}).insert()
frappe.flags.test_fixtures_created = True
class TestGuarded(IntegrationTestCase):
def setUp(self):
create_fixtures() # Safe — only runs onceAnti-Pattern 8: Skipping Validation with frappe.flags.in_test
# WRONG — skipping validation defeats the purpose of testing
class MyDoctype(Document):
def validate(self):
if frappe.flags.in_test:
return # NEVER skip validation logic in tests
self.validate_amounts()# CORRECT — use frappe.flags.in_test only for external side effects
class MyDoctype(Document):
def validate(self):
self.validate_amounts() # ALWAYS validate
def after_insert(self):
if not frappe.flags.in_test:
send_notification_email() # OK to skip emails in testsAnti-Pattern 9: Testing Private Methods Directly
# WRONG — testing implementation details
class TestPrivate(UnitTestCase):
def test_internal_calculation(self):
obj = MyClass()
result = obj._calculate_internal_value() # Testing private method# CORRECT — test through public interface
class TestPublic(UnitTestCase):
def test_public_result(self):
obj = MyClass()
result = obj.get_total() # Test the public method that uses _calculate_internal_value
self.assertEqual(result, expected_value)Anti-Pattern 10: Not Using assertDocumentEqual
# WRONG — manually checking each field
class TestManual(IntegrationTestCase):
def test_fields(self):
doc = frappe.get_doc("ToDo", name)
self.assertEqual(doc.description, "test")
self.assertEqual(doc.status, "Open")
self.assertEqual(doc.priority, "Medium")
# ... 20 more assertions# CORRECT — use assertDocumentEqual for bulk comparison
class TestBulk(IntegrationTestCase):
def test_fields(self):
doc = frappe.get_doc("ToDo", name)
self.assertDocumentEqual(
{"description": "test", "status": "Open", "priority": "Medium"},
doc,
)Test API Reference
Test Base Classes
UnitTestCase (v15+)
Import: from frappe.tests.classes import UnitTestCase
Inherits: unittest.TestCase, BaseTestCase
Class Attributes:
doctype— Auto-detected from module path (e.g.,myapp.mymodule.doctype.my_dt.test_my_dt→My Dt)module— The test module reference
Setup Methods:
| Method | When Called | Purpose |
|---|---|---|
setUpClass() | Once per class | Sets frappe.set_user("Administrator"), detects doctype |
setUp() | Before each test | Override for per-test setup |
tearDown() | After each test | Override for per-test cleanup |
tearDownClass() | Once after all tests | Override for class-level cleanup |
Assertion Methods:
| Method | Signature | Purpose |
|---|---|---|
assertDocumentEqual | (expected: dict, actual: BaseDocument) | Compare document fields — handles floats (precision-aware), ints, dates, child tables |
assertQueryEqual | (first: str, second: str) | Compare SQL queries after normalization |
assertSequenceSubset | (larger: Sequence, smaller: Sequence) | Assert smaller is subset of larger |
Utility Methods:
| Method | Signature | Returns | Purpose |
|---|---|---|---|
normalize_html | (code: str) | str | Format HTML for comparison (uses BeautifulSoup) |
normalize_sql | (query: str) | str | Format SQL for comparison (uses sqlparse, handles PostgreSQL) |
IntegrationTestCase (v15+)
Import: from frappe.tests.classes import IntegrationTestCase
Inherits: UnitTestCase
Class Attributes:
TEST_SITE = "test_site"— Default test site nameSHOW_TRANSACTION_COMMIT_WARNINGS = False— Log commits with stack traces when TruemaxDiff = 10_000— Maximum diff output size
Setup Methods:
| Method | When Called | Purpose |
|---|---|---|
setUpClass() | Once per class | frappe.init(), DB connection, make_test_records(), snapshot globals |
setUp() | Before each test | Enables fault handler (300s timeout) |
tearDown() | After each test | Rollback DB, restore context |
Connection Methods:
| Method | Type | Purpose |
|---|---|---|
primary_connection() | Context manager | Switch to primary DB connection |
secondary_connection() | Context manager | Use secondary DB connection (lazy-initialized) |
Assertion Context Managers:
| Method | Signature | Purpose |
|---|---|---|
assertQueryCount | (count: int, *, query_type=None) | Assert exact number of SQL queries executed |
assertRedisCallCounts | (**commands) | Assert Redis command counts (exact or approximate) |
assertRowsRead | (count: int) | Assert maximum rows read from DB |
FrappeTestCase (v14, deprecated in v15+)
Import: from frappe.tests.utils import FrappeTestCase
Behavior: In v15+, this is a compatibility wrapper that maps to the old behavior. In v14, it is the primary test class.
Key Differences from v15+ classes:
- Single class for both unit and integration tests
- No distinction between DB and non-DB tests
- Same rollback behavior as IntegrationTestCase
Context Managers
All context managers are available as static methods on UnitTestCase and IntegrationTestCase.
set_user
with self.set_user("test@example.com"):
# Code runs as test@example.com
pass
# User automatically restored to previous userchange_settings
# Positional dict style
with self.change_settings("Selling Settings", {"so_required": 1}):
pass
# Keyword argument style
with self.change_settings("Selling Settings", so_required=1):
pass
# With commit (ONLY use when testing code that reads from a separate transaction)
with self.change_settings("HR Settings", commit=True, auto_leave_encashment=1):
passpatch_hooks
with self.patch_hooks({"doc_events": {"Sales Order": {"on_submit": ["myapp.hooks.custom_submit"]}}}):
# Custom hook is active
passfreeze_time
with self.freeze_time("2024-06-15 10:30:00"):
now = frappe.utils.now_datetime()
# now == datetime(2024, 6, 15, 10, 30, 0)
# With UTC flag
with self.freeze_time("2024-06-15 10:30:00", is_utc=True):
passenable_safe_exec
with self.enable_safe_exec():
# Server scripts can execute
frappe.safe_eval("1 + 1")debug_on
with self.debug_on(AssertionError, ValueError):
# Drops into pdb on AssertionError or ValueError
risky_function()timeout (Decorator)
from frappe.tests.classes.context_managers import timeout
@timeout(seconds=10)
def test_must_be_fast(self):
# Fails if test takes longer than 10 seconds
passCLI Reference: bench run-tests
| Flag | Purpose | Example |
|---|---|---|
--app APP | Run tests for specific app | --app erpnext |
--doctype DOCTYPE | Run tests for specific DocType | --doctype "Sales Order" |
--test TEST | Run specific test method | --test test_submit |
--module MODULE | Run tests in specific module | --module "myapp.tests.test_utils" |
--profile | Run Python profiler | --profile |
--failfast | Stop on first failure | --failfast |
--junit-xml-output PATH | Generate JUnit XML report | --junit-xml-output report.xml |
--skip-test-records | Skip loading test_records | --skip-test-records |
--skip-before-tests | Skip before_tests hooks | --skip-before-tests |
--verbose | Verbose output | bench --verbose run-tests |
CLI Reference: bench run-parallel-tests
| Flag | Purpose | Example |
|---|---|---|
--total-builds N | Total number of parallel builds | --total-builds 4 |
--build-number N | This build's index (0-based) | --build-number 0 |
--use-orchestrator | Use orchestrator for distribution | --use-orchestrator |
Environment variables for orchestrator mode:
CI_BUILD_ID— Unique build identifierORCHESTRATOR_URL— URL of the test orchestrator service
Utility Functions
frappe.tests.utils
| Function | Purpose |
|---|---|
toggle_test_mode(enable: bool) | Enable/disable frappe.in_test flag |
whitelist_for_tests(**kwargs) | Decorator: whitelist function for test access only |
check_orphaned_doctypes() | Validate all DocTypes have controllers (post-patch) |
change_settings(dt, settings) | Context manager for temporary settings changes |
patch_hooks(overrides) | Context manager for temporary hook overrides |
debug_on(*exceptions) | Context manager for interactive debugging |
timeout(seconds) | Decorator for test timeout |
frappe.flags for Testing
| Flag | Purpose |
|---|---|
frappe.flags.in_test | True when running under test runner |
frappe.flags.in_import | True during data import |
frappe.flags.print_messages | Set False to suppress messages in tests |
frappe.in_test
Boolean property — True when the test runner is active. Equivalent to frappe.flags.in_test but preferred in v15+.
Unit & Integration Test Examples
Complete DocType Test (v15+ IntegrationTestCase)
import frappe
from frappe.tests.classes import IntegrationTestCase
class TestTodoItem(IntegrationTestCase):
"""Tests for the Todo Item DocType."""
@classmethod
def setUpClass(cls):
# ALWAYS call super() first
super().setUpClass()
cls.test_user = "test_todo@example.com"
def test_create_todo(self):
"""Test basic document creation."""
doc = frappe.get_doc({
"doctype": "ToDo",
"description": "_Test Todo Description",
"status": "Open",
}).insert()
self.assertEqual(doc.status, "Open")
self.assertTrue(doc.name)
def test_update_status(self):
"""Test status transition."""
doc = frappe.get_doc({
"doctype": "ToDo",
"description": "_Test Todo Update",
}).insert()
doc.status = "Closed"
doc.save()
doc.reload()
self.assertEqual(doc.status, "Closed")
def test_permission_restricted_user(self):
"""Test that restricted user cannot access other users' todos."""
doc = frappe.get_doc({
"doctype": "ToDo",
"description": "_Test Private Todo",
"allocated_to": "Administrator",
}).insert()
with self.set_user(self.test_user):
self.assertFalse(
frappe.has_permission("ToDo", doc=doc.name, ptype="read")
)
def test_validation_error_on_empty_description(self):
"""Test that validation prevents empty description."""
self.assertRaises(
frappe.ValidationError,
frappe.get_doc,
{"doctype": "ToDo", "description": ""},
)
def test_query_count_on_list(self):
"""Test that listing todos uses reasonable query count."""
# Create test data
for i in range(5):
frappe.get_doc({
"doctype": "ToDo",
"description": f"_Test Query Count {i}",
}).insert()
with self.assertQueryCount(5):
frappe.get_list("ToDo", filters={"description": ["like", "_Test Query%"]})
class TestTodoWorkflow(IntegrationTestCase):
"""Tests for Todo workflow transitions."""
def test_complete_lifecycle(self):
"""Test Open → Closed lifecycle."""
doc = frappe.get_doc({
"doctype": "ToDo",
"description": "_Test Lifecycle",
"status": "Open",
}).insert()
self.assertEqual(doc.docstatus, 0)
doc.status = "Closed"
doc.save()
self.assertEqual(doc.status, "Closed")Complete DocType Test (v14 FrappeTestCase)
import frappe
from frappe.tests.utils import FrappeTestCase
class TestEvent(FrappeTestCase):
def setUp(self):
create_events()
def tearDown(self):
# ALWAYS reset user in v14 pattern
frappe.set_user("Administrator")
def test_allowed_public(self):
frappe.set_user("test1@example.com")
doc = frappe.get_doc("Event", frappe.db.get_value(
"Event", {"subject": "_Test Event 1"}
))
self.assertTrue(frappe.has_permission("Event", doc=doc))
def test_not_allowed_private(self):
frappe.set_user("test1@example.com")
doc = frappe.get_doc("Event", frappe.db.get_value(
"Event", {"subject": "_Test Event 2"}
))
self.assertFalse(frappe.has_permission("Event", doc=doc))
def create_events():
if frappe.flags.test_events_created:
return
frappe.set_user("Administrator")
frappe.get_doc({
"doctype": "Event",
"subject": "_Test Event 1",
"starts_on": "2024-01-01",
"event_type": "Public",
}).insert()
frappe.get_doc({
"doctype": "Event",
"subject": "_Test Event 2",
"starts_on": "2024-01-02",
"event_type": "Private",
}).insert()
frappe.flags.test_events_created = TruePure Unit Test (v15+ UnitTestCase)
from frappe.tests.classes import UnitTestCase
from myapp.utils.calculations import calculate_discount, format_currency
class TestCalculations(UnitTestCase):
"""Pure unit tests — no database access."""
def test_discount_percentage(self):
result = calculate_discount(1000, 10)
self.assertEqual(result, 900)
def test_discount_zero(self):
result = calculate_discount(1000, 0)
self.assertEqual(result, 1000)
def test_discount_exceeds_total(self):
self.assertRaises(ValueError, calculate_discount, 1000, 150)
def test_format_currency(self):
self.assertEqual(format_currency(1234.56, "USD"), "$1,234.56")Testing with Mocked External Services
from unittest.mock import patch, MagicMock
from frappe.tests.classes import IntegrationTestCase
class TestPaymentIntegration(IntegrationTestCase):
@patch("myapp.integrations.stripe_client.requests.post")
def test_successful_payment(self, mock_post):
mock_post.return_value = MagicMock(
status_code=200,
json=lambda: {"status": "succeeded", "id": "pi_123"},
)
result = process_stripe_payment(amount=5000, currency="eur")
self.assertEqual(result["status"], "succeeded")
mock_post.assert_called_once()
@patch("myapp.integrations.stripe_client.requests.post")
def test_failed_payment(self, mock_post):
mock_post.return_value = MagicMock(
status_code=402,
json=lambda: {"error": {"message": "Card declined"}},
)
self.assertRaises(
PaymentError,
process_stripe_payment, amount=5000, currency="eur",
)Testing with Time Freeze
from frappe.tests.classes import IntegrationTestCase
class TestScheduledTask(IntegrationTestCase):
def test_overdue_detection(self):
doc = frappe.get_doc({
"doctype": "ToDo",
"description": "_Test Overdue",
"date": "2024-01-15",
}).insert()
with self.freeze_time("2024-01-20"):
overdue = get_overdue_todos()
self.assertIn(doc.name, [t.name for t in overdue])
def test_not_overdue_before_deadline(self):
doc = frappe.get_doc({
"doctype": "ToDo",
"description": "_Test Not Overdue",
"date": "2024-01-15",
}).insert()
with self.freeze_time("2024-01-10"):
overdue = get_overdue_todos()
self.assertNotIn(doc.name, [t.name for t in overdue])Testing Submittable Documents
import frappe
from frappe.tests.classes import IntegrationTestCase
class TestSalesInvoice(IntegrationTestCase):
def test_submit_creates_gl_entries(self):
si = create_test_sales_invoice()
si.submit()
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_no": si.name},
fields=["account", "debit", "credit"],
)
self.assertTrue(len(gl_entries) > 0)
total_debit = sum(e.debit for e in gl_entries)
total_credit = sum(e.credit for e in gl_entries)
self.assertEqual(total_debit, total_credit)
def test_cancel_reverses_gl_entries(self):
si = create_test_sales_invoice()
si.submit()
si.cancel()
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_no": si.name, "is_cancelled": 0},
)
self.assertEqual(len(gl_entries), 0)
def create_test_sales_invoice():
return frappe.get_doc({
"doctype": "Sales Invoice",
"customer": "_Test Customer",
"items": [{
"item_code": "_Test Item",
"qty": 1,
"rate": 100,
}],
}).insert()Test Fixtures in Depth
Fixture Loading Order
The Frappe test runner loads fixtures in this order:
1. Dependency resolution: Link fields are inspected to determine which DocTypes must be created first 2. test_records.json: Loaded from the doctype directory (if present) 3. _test_records: Loaded from the test module (if present) 4. make_test_records(): Called by IntegrationTestCase.setUpClass()
ALWAYS ensure linked DocTypes have their own test records — the runner builds them automatically but ONLY if test_records.json or _test_records exists for those DocTypes.
test_records.json Format
Basic Records
[
{
"doctype": "Item",
"item_code": "_Test Item",
"item_name": "_Test Item",
"item_group": "_Test Item Group",
"stock_uom": "_Test UOM"
},
{
"doctype": "Item",
"item_code": "_Test Item 2",
"item_name": "_Test Item 2",
"item_group": "_Test Item Group",
"stock_uom": "_Test UOM"
}
]Records with Child Tables
[
{
"doctype": "Sales Order",
"customer": "_Test Customer",
"delivery_date": "2024-12-31",
"items": [
{
"item_code": "_Test Item",
"qty": 10,
"rate": 100
}
],
"taxes": [
{
"charge_type": "On Net Total",
"account_head": "_Test Account VAT - _TC",
"rate": 21
}
]
}
]Naming Convention Rules
- ALWAYS prefix values with
_Test— e.g.,_Test Customer,_Test Item - ALWAYS use
_Testprefix for names that could collide with real data - Child table rows do NOT need
doctypefield — it is inferred from the parent - Date fields SHOULD use a future date to avoid past-date validation errors
_test_records In-Module Format
# In test_my_doctype.py
_test_records = [
{
"doctype": "My Doctype",
"title": "_Test Record 1",
"status": "Active",
},
{
"doctype": "My Doctype",
"title": "_Test Record 2",
"status": "Inactive",
},
]This format is equivalent to test_records.json but lives in the Python test file. Use this when:
- You need to generate dynamic fixture values
- You want fixtures co-located with test logic
- The DocType has no dedicated directory (non-DocType tests)
Programmatic Fixtures
Simple Pattern with Flag Guard
def create_test_items():
if frappe.flags.test_items_created:
return
frappe.set_user("Administrator")
items = [
{"item_code": "_Test Widget A", "item_group": "Products", "stock_uom": "Nos"},
{"item_code": "_Test Widget B", "item_group": "Products", "stock_uom": "Nos"},
]
for item_data in items:
if not frappe.db.exists("Item", item_data["item_code"]):
frappe.get_doc({"doctype": "Item", **item_data}).insert()
frappe.flags.test_items_created = TrueComplex Fixtures with Dependencies
def create_test_sales_setup():
"""Create a complete sales test environment."""
if frappe.flags.test_sales_setup_done:
return
frappe.set_user("Administrator")
# 1. Create customer group (dependency)
if not frappe.db.exists("Customer Group", "_Test Group"):
frappe.get_doc({
"doctype": "Customer Group",
"customer_group_name": "_Test Group",
}).insert()
# 2. Create customer (depends on customer group)
if not frappe.db.exists("Customer", "_Test Customer"):
frappe.get_doc({
"doctype": "Customer",
"customer_name": "_Test Customer",
"customer_group": "_Test Group",
}).insert()
# 3. Create items
for i in range(3):
code = f"_Test Sale Item {i}"
if not frappe.db.exists("Item", code):
frappe.get_doc({
"doctype": "Item",
"item_code": code,
"item_group": "Products",
"stock_uom": "Nos",
}).insert()
frappe.flags.test_sales_setup_done = TrueUsing setUpClass for One-Time Setup
class TestSalesWorkflow(IntegrationTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
create_test_sales_setup()
cls.customer = "_Test Customer"
cls.items = [f"_Test Sale Item {i}" for i in range(3)]Fixture Cleanup
Automatic (Recommended)
IntegrationTestCase rolls back all database changes after each test. NEVER manually delete test records — the rollback handles it.
Manual Cleanup (Only for Non-Standard Cases)
class TestWithCleanup(IntegrationTestCase):
def setUp(self):
self.created_docs = []
def tearDown(self):
for doc_name in reversed(self.created_docs):
frappe.delete_doc("My Doctype", doc_name, force=True)
def _create_doc(self, **kwargs):
doc = frappe.get_doc({"doctype": "My Doctype", **kwargs}).insert()
self.created_docs.append(doc.name)
return docNEVER use manual cleanup with IntegrationTestCase — the automatic rollback is sufficient and more reliable.
Fixture Best Practices
1. ALWAYS use `frappe.db.exists()` before inserting — prevents duplicate key errors when fixtures are shared across test classes 2. ALWAYS use flag guards (frappe.flags.test_X_created) — prevents re-insertion 3. ALWAYS set user to Administrator before creating fixtures — avoids permission errors 4. NEVER hardcode auto-generated names (like ACC-001) — use frappe.db.get_value() to look up 5. ALWAYS create fixtures in dependency order — parent records before child records 6. NEVER use `frappe.db.commit()` in fixture creation — let the framework handle transactions