Testing Strategies
Automated testing ensures your customizations and integrations keep working after
every change. Frappe ships a unittest-based testing framework that integrates
with bench commands, supports test fixtures for linked DocTypes, and rolls back
the database transaction between tests. If you’ve written Jest tests against an
Express app with a test database, the shape is familiar — the difference is that
Frappe gives you a base class that wraps each test in a savepoint so you never
hand-roll teardown.
Frappe test framework basics
Section titled “Frappe test framework basics”Frappe tests are standard Python unittest tests with a custom base class —
FrappeTestCase — that manages Frappe-specific state: database transactions,
local flags, and site context.
Test file structure
Section titled “Test file structure”Test files follow a naming convention and live alongside the DocType they test.
The runner discovers any file matching test_*.py:
Directoryscoopjoy/
Directoryscoopjoy/
Directorydoctype/
Directorysj_outlet/
- sj_outlet.json
- sj_outlet.py
- test_sj_outlet.py tests go here
- test_records.json optional fixture data
Directorysj_agreement/
- sj_agreement.json
- sj_agreement.py
- test_sj_agreement.py
Directorytests/
- __init__.py
- test_royalty_calculator.py non-doctype tests
- test_api.py
The FrappeTestCase base class
Section titled “The FrappeTestCase base class”import frappefrom frappe.tests.utils import FrappeTestCase
class TestSJOutlet(FrappeTestCase): @classmethod def setUpClass(cls): """Run once before all tests in this class.""" super().setUpClass() # IMPORTANT: always call super() # Create shared test data cls.test_company = create_test_company()
def setUp(self): """Run before each individual test method.""" # FrappeTestCase starts a DB savepoint here automatically pass
def tearDown(self): """Run after each individual test method.""" # FrappeTestCase rolls back to the savepoint automatically pass
@classmethod def tearDownClass(cls): """Run once after all tests in this class.""" super().tearDownClass()FrappeTestCase provides:
- Automatic savepoint/rollback per test method — each test runs in isolation.
- Flag reset —
frappe.local.flagsis cleared between tests. - A new DB transaction per test — no cross-contamination between test cases.
- No manual
frappe.db.rollback()needed — the base class handles it.
Test records and fixtures
Section titled “Test records and fixtures”Frappe supports two approaches for test data: test_records.json files and
factory functions.
A test_records.json file holds static fixture data automatically loaded by the
test runner before the tests in that DocType’s folder execute:
[ { "doctype": "SJ Outlet", "outlet_name": "Test Outlet Alpha", "company": "ScoopJoy - Outlet North", "city": "Mumbai", "state": "Maharashtra", "opening_date": "2025-01-01", "status": "Active", "franchise_manager": "test@example.com" }, { "doctype": "SJ Outlet", "outlet_name": "Test Outlet Beta", "company": "ScoopJoy - Outlet South", "city": "Bangalore", "state": "Karnataka", "opening_date": "2025-03-01", "status": "Active" }]Factory functions create test data dynamically, and are preferred for complex records where you want to vary one or two fields per test:
def create_test_outlet(**kwargs): """Create a test SJ Outlet with sensible defaults.""" defaults = { "doctype": "SJ Outlet", "outlet_name": f"Test Outlet {frappe.generate_hash(length=6)}", "company": "_Test Company", "city": "Mumbai", "state": "Maharashtra", "opening_date": "2025-01-01", "status": "Active", } defaults.update(kwargs) outlet = frappe.get_doc(defaults) outlet.insert(ignore_permissions=True) return outlet
def create_test_company(): """Create a test company if it doesn't exist.""" if not frappe.db.exists("Company", "_Test Company"): company = frappe.get_doc({ "doctype": "Company", "company_name": "_Test Company", "abbr": "_TC", "country": "India", "default_currency": "INR" }) company.insert(ignore_permissions=True) return "_Test Company"The _Test Company naming (leading underscore) is a Frappe convention for test
fixtures that should be skipped by normal list views and ignored by some
validation paths.
Running tests
Section titled “Running tests”# Run all tests for an appbench --site test_site run-tests --app scoopjoy
# Run tests for a specific DocTypebench --site test_site run-tests --doctype "SJ Outlet"
# Run tests from a specific modulebench --site test_site run-tests \ --module scoopjoy.scoopjoy.doctype.sj_outlet.test_sj_outlet
# Run a single test methodbench --site test_site run-tests \ --module scoopjoy.tests.test_royalty_calculator \ --test test_monthly_royalty_computation
# Run with verbose outputbench --site test_site run-tests --app scoopjoy --verbose
# Stop at first failurebench --site test_site run-tests --app scoopjoy --failfast
# Skip building test records for faster iterationbench --site test_site run-tests --doctype "SJ Outlet" \ --skip-test-records --skip-before-tests
# Generate JUnit XML output for CI systemsbench --site test_site run-tests --app scoopjoy \ --junit-xml-output /tmp/test-results.xml
# Profile tests to find slow onesbench --site test_site run-tests --doctype "SJ Outlet" --profileThe --junit-xml-output flag is what you wire into your CI pipeline — see
Chapter 34 for the full pipeline. --skip-test-records and
--skip-before-tests are your friends during local TDD loops: they shave seconds
off each run by reusing existing fixtures.
Unit testing patterns
Section titled “Unit testing patterns”Testing controller methods
Section titled “Testing controller methods”Controller tests assert that validate() and the document’s lifecycle hooks
behave as designed — the closest analog to testing an Express route’s business
logic in isolation.
class TestSJOutlet(FrappeTestCase): def test_validate_opening_date_not_in_future(self): """Opening date cannot be in the future.""" from frappe.utils import add_days, today
outlet = create_test_outlet(opening_date=add_days(today(), 30))
# The validate() method should raise self.assertRaises(frappe.ValidationError, outlet.insert)
def test_franchise_code_auto_generated(self): """Franchise code is auto-generated on insert.""" outlet = create_test_outlet( outlet_name="Bandra West Premium", city="Mumbai" ) self.assertTrue(outlet.franchise_code) self.assertTrue(outlet.franchise_code.startswith("FR-"))
def test_status_transition_active_to_suspended(self): """Status can transition from Active to Suspended.""" outlet = create_test_outlet(status="Active") outlet.status = "Suspended" outlet.save() self.assertEqual(outlet.status, "Suspended")
def test_status_transition_closed_to_active_blocked(self): """Cannot reactivate a closed outlet.""" outlet = create_test_outlet(status="Closed") outlet.status = "Active" self.assertRaises(frappe.ValidationError, outlet.save)Testing whitelisted APIs
Section titled “Testing whitelisted APIs”Call the whitelisted function directly — you don’t need an HTTP client. Use
frappe.set_user(...) to assert that permission checks fire, then reset to
Administrator so later tests aren’t run as the restricted user.
class TestFranchiseAPI(FrappeTestCase): def test_get_outlet_performance(self): """Test the whitelisted API for outlet performance.""" outlet = create_test_outlet() create_test_sales_invoices(outlet.name, count=5, total=50000)
# Call the whitelisted function directly from scoopjoy.api import get_outlet_performance result = get_outlet_performance( outlet=outlet.name, from_date="2025-01-01", to_date="2025-12-31" )
self.assertIsInstance(result, dict) self.assertEqual(result["total_invoices"], 5) self.assertAlmostEqual(result["total_revenue"], 50000)
def test_get_outlet_performance_unauthorized(self): """Non-franchise-manager cannot access other outlet's data.""" outlet = create_test_outlet()
# Switch to a restricted user frappe.set_user("restricted_user@example.com")
from scoopjoy.api import get_outlet_performance self.assertRaises( frappe.PermissionError, get_outlet_performance, outlet=outlet.name, from_date="2025-01-01", to_date="2025-12-31" )
# Reset to admin frappe.set_user("Administrator")Testing permissions
Section titled “Testing permissions”Permission tests are the highest-value tests in an ERP — a leaked row is a real
business problem. The pattern is: create a doc owned by one user, switch to
another, and assert the framework raises frappe.PermissionError.
class TestFranchisePermissions(FrappeTestCase): def test_franchise_manager_can_read_own_outlet(self): """Franchise manager can read their own outlet.""" outlet = create_test_outlet(franchise_manager="test_fm@example.com")
frappe.set_user("test_fm@example.com") doc = frappe.get_doc("SJ Outlet", outlet.name) self.assertEqual(doc.name, outlet.name) frappe.set_user("Administrator")
def test_franchise_manager_cannot_read_other_outlet(self): """Franchise manager cannot read another manager's outlet.""" outlet = create_test_outlet(franchise_manager="other@example.com")
frappe.set_user("test_fm@example.com") self.assertRaises( frappe.PermissionError, frappe.get_doc, "SJ Outlet", outlet.name ) frappe.set_user("Administrator")
def test_franchise_manager_cannot_delete(self): """Franchise managers should not be able to delete outlets.""" outlet = create_test_outlet(franchise_manager="test_fm@example.com")
frappe.set_user("test_fm@example.com") self.assertRaises( frappe.PermissionError, frappe.delete_doc, "SJ Outlet", outlet.name ) frappe.set_user("Administrator")Mocking external services
Section titled “Mocking external services”Never hit a real payment gateway or messaging API from a test. Use
unittest.mock.patch to replace the client at its import path and assert your
code called it correctly — exactly like stubbing an SDK in a Node test with
jest.mock.
from unittest.mock import patch, MagicMock
class TestRazorpayIntegration(FrappeTestCase): @patch("scoopjoy.integrations.razorpay.razorpay.Client") def test_create_payment_link(self, mock_razorpay_class): """Test Razorpay payment link creation without hitting the API.""" # Set up mock mock_client = MagicMock() mock_razorpay_class.return_value = mock_client mock_client.payment_link.create.return_value = { "id": "plink_test123", "short_url": "https://rzp.io/test", "status": "created" }
from scoopjoy.integrations.razorpay import create_payment_link result = create_payment_link( amount=5000, customer_name="Test Customer", description="Franchise Fee" )
self.assertEqual(result["id"], "plink_test123") mock_client.payment_link.create.assert_called_once()
@patch("scoopjoy.integrations.whatsapp.send_whatsapp_message") def test_notification_sent_on_agreement_approval(self, mock_send): """WhatsApp notification is sent when agreement is approved.""" mock_send.return_value = {"status": "sent"}
agreement = create_test_agreement(status="Draft") agreement.submit() # triggers approval workflow
mock_send.assert_called_once() call_args = mock_send.call_args[1] self.assertIn("approved", call_args["message"].lower())The patch target is the name where it’s used, not where it’s defined — patch
scoopjoy.integrations.razorpay.razorpay.Client, the reference your module
imported, not razorpay.Client itself.
Integration testing
Section titled “Integration testing”Testing document workflows
Section titled “Testing document workflows”Submittable documents move through a docstatus lifecycle: 0 (Draft) →
1 (Submitted) → 2 (Cancelled). An integration test walks that whole path and
asserts the side effects at each stage.
class TestFranchiseAgreement(FrappeTestCase): def test_complete_agreement_lifecycle(self): """Test the full lifecycle: Draft → Submitted → Cancelled.""" agreement = frappe.get_doc({ "doctype": "SJ Agreement", "franchise_outlet": create_test_outlet().name, "start_date": "2025-01-01", "end_date": "2027-12-31", "royalty_percentage": 5, "monthly_minimum_royalty": 10000, "agreement_value": 500000 }) agreement.insert() self.assertEqual(agreement.docstatus, 0) # Draft
# Submit agreement.submit() agreement.reload() self.assertEqual(agreement.docstatus, 1) # Submitted self.assertEqual(agreement.status, "Active")
# Cancel agreement.cancel() agreement.reload() self.assertEqual(agreement.docstatus, 2) # Cancelled self.assertEqual(agreement.status, "Cancelled")
def test_submitting_creates_linked_records(self): """Submitting an agreement should create a warehouse and POS profile.""" outlet = create_test_outlet() agreement = create_and_submit_agreement(outlet.name)
# Verify warehouse was created warehouse_exists = frappe.db.exists("Warehouse", { "warehouse_name": outlet.outlet_name, "company": outlet.company }) self.assertTrue(warehouse_exists)
# Verify POS profile was created pos_profile = frappe.db.exists("POS Profile", { "franchise_outlet": outlet.name }) self.assertTrue(pos_profile)Testing accounting entries
Section titled “Testing accounting entries”Accounting is unforgiving, so assert the actual debit/credit amounts that land in the Journal Entry — not just that one was created.
class TestFranchiseRoyalty(FrappeTestCase): def test_royalty_journal_entry_created(self): """Royalty calculation should create correct journal entries.""" outlet = create_test_outlet() agreement = create_and_submit_agreement(outlet.name, royalty_percentage=5)
# Create sales invoices totaling 100,000 create_test_sales_invoices(outlet.name, total=100000)
# Run royalty calculation from scoopjoy.royalty import calculate_monthly_royalty je_name = calculate_monthly_royalty( outlet=outlet.name, month="2025-06" )
# Verify journal entry je = frappe.get_doc("Journal Entry", je_name) self.assertEqual(je.docstatus, 1) # submitted
# Check amounts: 5% of 100,000 = 5,000 debit_entry = next( a for a in je.accounts if a.debit_in_account_currency > 0 ) self.assertEqual(debit_entry.debit_in_account_currency, 5000)Testing scheduled job logic
Section titled “Testing scheduled job logic”Scheduled jobs are just functions — call them directly. Don’t wait for the scheduler; set up the data, invoke the task, and assert what it produced.
class TestDailyDigest(FrappeTestCase): def test_daily_digest_content(self): """Daily digest includes sales summary for each outlet.""" outlet = create_test_outlet() create_test_sales_invoices(outlet.name, count=10, total=75000)
from scoopjoy.tasks import generate_daily_digest digest = generate_daily_digest(date="2025-06-15")
self.assertIn(outlet.outlet_name, digest["content"]) self.assertIn("75,000", digest["content"]) self.assertEqual(len(digest["outlets"]), 1)
def test_stock_reorder_triggered(self): """Stock reorder should create Material Request when below threshold.""" outlet = create_test_outlet() set_stock_level(outlet.name, "VANILLA-100ML", qty=5) set_reorder_level("VANILLA-100ML", warehouse=outlet.warehouse, reorder_level=10, reorder_qty=50)
from scoopjoy.tasks import check_stock_reorder check_stock_reorder()
mr = frappe.get_last_doc("Material Request", filters={"company": outlet.company}) self.assertEqual(mr.items[0].item_code, "VANILLA-100ML") self.assertEqual(mr.items[0].qty, 50)Test fixtures: helpers for consistent test data
Section titled “Test fixtures: helpers for consistent test data”Centralize your factory functions in a shared module so every test file builds outlets, agreements, and invoices the same way. Importing from one place keeps your tests DRY and makes a schema change a one-line fix.
import frappefrom frappe.utils import today, add_days
def create_test_outlet(**kwargs): defaults = { "doctype": "SJ Outlet", "outlet_name": f"Test Outlet {frappe.generate_hash(length=6)}", "company": "_Test Company", "city": "Mumbai", "state": "Maharashtra", "opening_date": today(), "status": "Active", } defaults.update(kwargs) doc = frappe.get_doc(defaults) doc.insert(ignore_permissions=True) return doc
def create_and_submit_agreement(outlet_name, **kwargs): defaults = { "doctype": "SJ Agreement", "franchise_outlet": outlet_name, "start_date": today(), "end_date": add_days(today(), 365 * 3), "royalty_percentage": 5, "agreement_value": 500000, } defaults.update(kwargs) doc = frappe.get_doc(defaults) doc.insert(ignore_permissions=True) doc.submit() return doc
def create_test_sales_invoices(outlet_name, count=1, total=10000): per_invoice = total / count outlet = frappe.get_doc("SJ Outlet", outlet_name)
for _ in range(count): si = frappe.get_doc({ "doctype": "Sales Invoice", "company": outlet.company, "customer": "Walk-In Customer", "posting_date": today(), "items": [{ "item_code": "VANILLA-100ML", "qty": 1, "rate": per_invoice }] }) si.insert(ignore_permissions=True) si.submit()