Skip to content

Unit Testing Patterns

First, understand the test class hierarchy in Frappe v16:

ClassImportDB AccessTest RecordsUse Case
UnitTestCasefrom frappe.tests import UnitTestCaseNoNoPure logic, utils, formatters
IntegrationTestCasefrom frappe.tests import IntegrationTestCaseYesAuto-loadedDocType CRUD, workflows, permissions

To define test records, create a JSON file named test_records.json in the DocType directory. Frappe automatically loads these records before running integration tests.

apps/scoopjoy/scoopjoy/scoopjoy/doctype/franchise_outlet/test_records.json
[
{
"doctype": "Franchise Outlet",
"outlet_name": "ScoopJoy MG Road",
"outlet_code": "SJ-BLR-001",
"city": "Bangalore",
"state": "Karnataka",
"franchise_owner": "test-franchise@scoopjoy.com",
"status": "Active",
"seating_capacity": 40,
"monthly_rent": 85000,
"agreement_start_date": "2025-01-01",
"agreement_end_date": "2027-12-31"
},
{
"doctype": "Franchise Outlet",
"outlet_name": "ScoopJoy Koramangala",
"outlet_code": "SJ-BLR-002",
"city": "Bangalore",
"state": "Karnataka",
"franchise_owner": "test-franchise@scoopjoy.com",
"status": "Active",
"seating_capacity": 25,
"monthly_rent": 65000,
"agreement_start_date": "2025-06-01",
"agreement_end_date": "2028-05-31"
}
]

Rather than hardcoding document creation in individual tests, use factory functions to keep your test suite DRY. This helper file defines functions to generate outlets, agreements, and users with realistic test data for the ScoopJoy franchise.

apps/scoopjoy/scoopjoy/tests/factory.py
import frappe
from frappe.utils import today, add_months, getdate, nowdate
def create_test_outlet(
outlet_name="Test Outlet",
city="Mumbai",
state="Maharashtra",
status="Active",
seating_capacity=30,
monthly_rent=50000,
do_not_save=False,
do_not_submit=False,
**kwargs,
):
"""Factory function to create a Franchise Outlet for testing."""
outlet = frappe.get_doc(
{
"doctype": "Franchise Outlet",
"outlet_name": outlet_name,
"outlet_code": f"SJ-TST-{frappe.generate_hash(length=4).upper()}",
"city": city,
"state": state,
"franchise_owner": kwargs.get(
"franchise_owner", "test-franchise@scoopjoy.com"
),
"status": status,
"seating_capacity": seating_capacity,
"monthly_rent": monthly_rent,
"agreement_start_date": kwargs.get("agreement_start_date", today()),
"agreement_end_date": kwargs.get(
"agreement_end_date", add_months(today(), 36)
),
**{k: v for k, v in kwargs.items() if k not in (
"franchise_owner", "agreement_start_date", "agreement_end_date"
)},
}
)
if not do_not_save:
outlet.insert(ignore_permissions=kwargs.get("ignore_permissions", True))
if not do_not_submit and outlet.meta.is_submittable:
outlet.submit()
return outlet
def create_test_agreement(
outlet=None,
royalty_percentage=8.0,
security_deposit=500000,
do_not_save=False,
**kwargs,
):
"""Factory function to create a Franchise Agreement for testing."""
if not outlet:
outlet = create_test_outlet()
agreement = frappe.get_doc(
{
"doctype": "Franchise Agreement",
"franchise_outlet": outlet.name,
"franchise_owner": outlet.franchise_owner,
"royalty_percentage": royalty_percentage,
"security_deposit": security_deposit,
"start_date": kwargs.get("start_date", today()),
"end_date": kwargs.get("end_date", add_months(today(), 36)),
"status": kwargs.get("status", "Active"),
}
)
if not do_not_save:
agreement.insert(ignore_permissions=True)
return agreement
def create_test_user(email, roles=None, franchise_outlet=None):
"""Create a test user with specific roles and user permissions."""
if frappe.db.exists("User", email):
user = frappe.get_doc("User", email)
else:
user = frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": email.split("@")[0].replace("-", " ").title(),
"send_welcome_email": 0,
"language": "en",
"time_zone": "Asia/Kolkata",
}
)
user.insert(ignore_permissions=True)
# Clear existing roles and set new ones
user.roles = []
for role in roles or ["Franchise User"]:
user.append("roles", {"role": role})
user.save(ignore_permissions=True)
# Set user permission to restrict to specific outlet
if franchise_outlet:
if not frappe.db.exists(
"User Permission",
{"user": email, "allow": "Franchise Outlet", "for_value": franchise_outlet},
):
frappe.get_doc(
{
"doctype": "User Permission",
"user": email,
"allow": "Franchise Outlet",
"for_value": franchise_outlet,
}
).insert(ignore_permissions=True)
return user

Here is the complete test suite. It includes both pure unit tests (UnitTestCase) that run instantly without database access, and integration tests (IntegrationTestCase) that verify database operations, custom permission checks, validations, and mocked API responses.

apps/scoopjoy/scoopjoy/scoopjoy/doctype/franchise_outlet/test_franchise_outlet.py
import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.exceptions import ValidationError, DoesNotExistError
from unittest.mock import patch, MagicMock
from scoopjoy.tests.factory import (
create_test_outlet,
create_test_agreement,
create_test_user,
)
# Module-level dependencies: Frappe auto-loads test records for these DocTypes
EXTRA_TEST_RECORD_DEPENDENCIES = ["Customer", "Item"]
class TestFranchiseOutletUnit(UnitTestCase):
"""Pure logic tests -- no database access."""
def test_outlet_code_format_validation(self):
"""Outlet code must match pattern SJ-XXX-NNN."""
from scoopjoy.scoopjoy.doctype.franchise_outlet.franchise_outlet import (
validate_outlet_code,
)
# Valid codes
self.assertTrue(validate_outlet_code("SJ-BLR-001"))
self.assertTrue(validate_outlet_code("SJ-MUM-999"))
# Invalid codes
self.assertFalse(validate_outlet_code("BLR-001"))
self.assertFalse(validate_outlet_code("SJ-BLRX-001"))
self.assertFalse(validate_outlet_code(""))
def test_royalty_calculation_logic(self):
"""Royalty = monthly_revenue * royalty_percentage / 100."""
from scoopjoy.scoopjoy.utils import calculate_royalty
self.assertEqual(calculate_royalty(1000000, 8.0), 80000.0)
self.assertEqual(calculate_royalty(0, 8.0), 0.0)
self.assertEqual(calculate_royalty(500000, 10.0), 50000.0)
def test_performance_tier_assignment(self):
"""Outlets are tiered by monthly revenue."""
from scoopjoy.scoopjoy.utils import get_performance_tier
self.assertEqual(get_performance_tier(2500000), "Platinum")
self.assertEqual(get_performance_tier(1500000), "Gold")
self.assertEqual(get_performance_tier(800000), "Silver")
self.assertEqual(get_performance_tier(300000), "Bronze")
class TestFranchiseOutletIntegration(IntegrationTestCase):
"""Integration tests with full database access."""
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create shared test data once for the class
cls.test_outlet = create_test_outlet(
outlet_name="Integration Test Outlet",
city="Delhi",
state="Delhi",
)
def test_outlet_creation(self):
"""A new outlet gets saved with auto-generated fields."""
outlet = create_test_outlet(outlet_name="Creation Test")
self.assertTrue(outlet.name)
self.assertEqual(outlet.status, "Active")
self.assertIsNotNone(outlet.creation)
def test_duplicate_outlet_code_rejected(self):
"""Two outlets cannot share the same outlet_code."""
outlet1 = create_test_outlet(outlet_name="Dupe Test 1")
with self.assertRaises(frappe.DuplicateEntryError):
create_test_outlet(
outlet_name="Dupe Test 2",
outlet_code=outlet1.outlet_code, # force duplicate
)
def test_validation_agreement_dates(self):
"""agreement_end_date must be after agreement_start_date."""
with self.assertRaises(ValidationError):
create_test_outlet(
outlet_name="Bad Dates",
agreement_start_date="2026-12-31",
agreement_end_date="2025-01-01",
)
def test_validation_negative_rent(self):
"""monthly_rent cannot be negative."""
with self.assertRaises(ValidationError):
create_test_outlet(outlet_name="Neg Rent", monthly_rent=-5000)
def test_validation_seating_capacity_bounds(self):
"""seating_capacity must be between 1 and 500."""
with self.assertRaises(ValidationError):
create_test_outlet(outlet_name="Zero Seats", seating_capacity=0)
def test_computed_annual_rent(self):
"""annual_rent should be monthly_rent * 12."""
outlet = create_test_outlet(
outlet_name="Computed Test", monthly_rent=75000
)
self.assertEqual(outlet.annual_rent, 900000)
def test_computed_agreement_duration_months(self):
"""agreement_duration_months calculated from start and end dates."""
outlet = create_test_outlet(
outlet_name="Duration Test",
agreement_start_date="2025-01-01",
agreement_end_date="2028-01-01",
)
self.assertEqual(outlet.agreement_duration_months, 36)
def test_status_change_on_agreement_expiry(self):
"""Outlet status changes to 'Agreement Expired' when agreement ends."""
outlet = create_test_outlet(
outlet_name="Expiry Test",
agreement_start_date="2023-01-01",
agreement_end_date="2024-01-01",
)
outlet.reload()
self.assertEqual(outlet.status, "Agreement Expired")
# --- Permission tests ---
def test_franchise_manager_can_read_own_outlet(self):
"""Franchise Manager can read their assigned outlet."""
user = create_test_user(
"mgr-test@scoopjoy.com",
roles=["Franchise Manager"],
franchise_outlet=self.test_outlet.name,
)
frappe.set_user("mgr-test@scoopjoy.com")
try:
doc = frappe.get_doc("Franchise Outlet", self.test_outlet.name)
self.assertEqual(doc.outlet_name, "Integration Test Outlet")
finally:
frappe.set_user("Administrator")
def test_franchise_manager_cannot_read_other_outlet(self):
"""Franchise Manager cannot read outlets not assigned to them."""
other_outlet = create_test_outlet(outlet_name="Other Outlet")
user = create_test_user(
"mgr-restricted@scoopjoy.com",
roles=["Franchise Manager"],
franchise_outlet=self.test_outlet.name, # only this outlet
)
frappe.set_user("mgr-restricted@scoopjoy.com")
try:
with self.assertRaises(frappe.PermissionError):
frappe.get_doc("Franchise Outlet", other_outlet.name)
finally:
frappe.set_user("Administrator")
# --- Whitelisted method test ---
def test_whitelisted_deactivate_outlet(self):
"""Whitelisted method deactivate_outlet changes status."""
outlet = create_test_outlet(outlet_name="Deactivate Test")
self.assertEqual(outlet.status, "Active")
from scoopjoy.scoopjoy.doctype.franchise_outlet.franchise_outlet import (
deactivate_outlet,
)
frappe.set_user("Administrator")
deactivate_outlet(outlet.name)
outlet.reload()
self.assertEqual(outlet.status, "Inactive")
# --- Mocking external API ---
@patch("scoopjoy.scoopjoy.integrations.gst.verify_gstin")
def test_gstin_verification_on_save(self, mock_verify):
"""GST verification API is called on save; mock it."""
mock_verify.return_value = {
"valid": True,
"legal_name": "ScoopJoy Foods Pvt Ltd",
"status": "Active",
}
outlet = create_test_outlet(
outlet_name="GST Test",
gstin="29AADCS1234F1Z5",
)
mock_verify.assert_called_once_with("29AADCS1234F1Z5")
self.assertTrue(outlet.gstin_verified)
@patch("scoopjoy.scoopjoy.integrations.gst.verify_gstin")
def test_invalid_gstin_blocks_save(self, mock_verify):
"""Invalid GSTIN from API should block document save."""
mock_verify.return_value = {"valid": False, "error": "Invalid GSTIN"}
with self.assertRaises(ValidationError):
create_test_outlet(
outlet_name="Bad GST Test",
gstin="INVALID123",
)

Use the bench CLI to run tests at various scopes (by DocType, module, app, or specific test case matching a pattern).

Terminal window
# Run all tests for the Franchise Outlet DocType
bench --site test_site run-tests --doctype "Franchise Outlet"
# Run only unit tests (no DB) using -k pattern matching
bench --site test_site run-tests --app scoopjoy -k "TestFranchiseOutletUnit"
# Run a single test method
bench --site test_site run-tests --app scoopjoy -k "test_gstin_verification_on_save"
# Run all tests in a module
bench --site test_site run-tests --module scoopjoy.scoopjoy.doctype.franchise_outlet
# Run with verbose output
bench --site test_site run-tests --doctype "Franchise Outlet" -v
# Run all app tests
bench --site test_site run-tests --app scoopjoy