Skip to content

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 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 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
scoopjoy/scoopjoy/doctype/sj_outlet/test_sj_outlet.py
import frappe
from 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 resetfrappe.local.flags is 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.

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:

scoopjoy/scoopjoy/doctype/sj_outlet/test_records.json
[
{
"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:

scoopjoy/scoopjoy/tests/test_utils.py
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.

Terminal window
# Run all tests for an app
bench --site test_site run-tests --app scoopjoy
# Run tests for a specific DocType
bench --site test_site run-tests --doctype "SJ Outlet"
# Run tests from a specific module
bench --site test_site run-tests \
--module scoopjoy.scoopjoy.doctype.sj_outlet.test_sj_outlet
# Run a single test method
bench --site test_site run-tests \
--module scoopjoy.tests.test_royalty_calculator \
--test test_monthly_royalty_computation
# Run with verbose output
bench --site test_site run-tests --app scoopjoy --verbose
# Stop at first failure
bench --site test_site run-tests --app scoopjoy --failfast
# Skip building test records for faster iteration
bench --site test_site run-tests --doctype "SJ Outlet" \
--skip-test-records --skip-before-tests
# Generate JUnit XML output for CI systems
bench --site test_site run-tests --app scoopjoy \
--junit-xml-output /tmp/test-results.xml
# Profile tests to find slow ones
bench --site test_site run-tests --doctype "SJ Outlet" --profile

The --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.

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.

scoopjoy/scoopjoy/doctype/sj_outlet/test_sj_outlet.py
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)

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.

scoopjoy/scoopjoy/tests/test_api.py
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")

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.

scoopjoy/scoopjoy/doctype/sj_outlet/test_sj_outlet.py
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")

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.

scoopjoy/scoopjoy/tests/test_integrations.py
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.

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.

scoopjoy/scoopjoy/doctype/sj_agreement/test_sj_agreement.py
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)

Accounting is unforgiving, so assert the actual debit/credit amounts that land in the Journal Entry — not just that one was created.

scoopjoy/scoopjoy/doctype/sj_agreement/test_royalty.py
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)

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.

scoopjoy/scoopjoy/tests/test_tasks.py
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.

scoopjoy/scoopjoy/tests/test_utils.py
import frappe
from 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()