Frappe Testing Suite
Create comprehensive test coverage for Frappe v15 applications using pytest-compatible test classes, fixtures, and factory patterns.
When to Use
-
Adding test coverage to existing code
-
Creating tests for new DocTypes/APIs/Services
-
Setting up test fixtures and factories
-
Writing integration tests with database access
-
Creating unit tests without database dependency
Arguments
/frappe-test <target> [--type <unit|integration|e2e>] [--coverage]
Examples:
/frappe-test SalesOrder /frappe-test inventory_service --type unit /frappe-test api.orders --type integration --coverage
Procedure
Step 1: Analyze Test Target
Identify what needs to be tested:
-
DocType — Controller lifecycle hooks, validation, business rules
-
Service — Business logic, orchestration, error handling
-
Repository — Data access patterns, queries
-
API — Endpoints, authentication, input validation
-
Utility — Helper functions, formatters
Step 2: Determine Test Strategy
Based on target, determine appropriate test types:
Component Unit Test Integration Test E2E Test
DocType Controller ✓ (hooks) ✓ (full lifecycle) —
Service Layer ✓ (logic) ✓ (with DB) —
Repository — ✓ (queries) —
API Endpoint ✓ (validation) ✓ (full request) ✓
Utility Functions ✓ — —
Step 3: Generate Test Structure
Create test directory structure:
<app>/tests/ ├── init.py ├── conftest.py # Pytest fixtures ├── factories/ # Test data factories │ ├── init.py │ └── <doctype>factory.py ├── unit/ # Unit tests (no DB) │ ├── init.py │ └── test<module>.py ├── integration/ # Integration tests (with DB) │ ├── init.py │ └── test_<module>.py └── e2e/ # End-to-end tests └── test_workflows.py
Step 4: Generate conftest.py (Pytest Configuration)
""" Pytest configuration and fixtures for <app>.
Usage: bench --site test_site run-tests --app <app> bench --site test_site run-tests --app <app> -k "test_name" """
import pytest import frappe from frappe.tests import IntegrationTestCase from typing import Generator, Any
──────────────────────────────────────────────────────────────────────────────
Session-scoped Fixtures (run once per test session)
──────────────────────────────────────────────────────────────────────────────
@pytest.fixture(scope="session") def app_context(): """Initialize Frappe app context for testing.""" # Frappe handles this automatically, but explicit setup can be added here yield # Cleanup after all tests
@pytest.fixture(scope="session") def test_admin_user() -> str: """Get or create admin user for tests.""" return "Administrator"
──────────────────────────────────────────────────────────────────────────────
Module-scoped Fixtures (run once per test module)
──────────────────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module") def test_user() -> Generator[str, None, None]: """ Create a test user for the module.
Yields:
User email/name
"""
email = "test_user@example.com"
if not frappe.db.exists("User", email):
user = frappe.get_doc({
"doctype": "User",
"email": email,
"first_name": "Test",
"last_name": "User",
"send_welcome_email": 0
})
user.insert(ignore_permissions=True)
user.add_roles("System Manager")
yield email
# Cleanup: optionally delete user after module tests
# frappe.delete_doc("User", email, force=True)
@pytest.fixture(scope="module") def api_credentials(test_user: str) -> Generator[dict, None, None]: """ Generate API credentials for test user.
Yields:
Dict with api_key and api_secret
"""
user = frappe.get_doc("User", test_user)
# Generate API keys if not exists
api_key = user.api_key or frappe.generate_hash(length=15)
api_secret = frappe.generate_hash(length=15)
if not user.api_key:
user.api_key = api_key
user.api_secret = api_secret
user.save(ignore_permissions=True)
yield {
"api_key": api_key,
"api_secret": api_secret,
"authorization": f"token {api_key}:{api_secret}"
}
──────────────────────────────────────────────────────────────────────────────
Function-scoped Fixtures (run for each test)
──────────────────────────────────────────────────────────────────────────────
@pytest.fixture def as_user(test_user: str) -> Generator[str, None, None]: """ Run test as specific user.
Usage:
def test_something(as_user):
# Test runs as test_user
pass
"""
original_user = frappe.session.user
frappe.set_user(test_user)
yield test_user
frappe.set_user(original_user)
@pytest.fixture def as_guest() -> Generator[str, None, None]: """Run test as Guest user.""" original_user = frappe.session.user frappe.set_user("Guest") yield "Guest" frappe.set_user(original_user)
@pytest.fixture def rollback_db(): """ Rollback database after test.
Useful for tests that modify data but shouldn't persist changes.
"""
frappe.db.begin()
yield
frappe.db.rollback()
──────────────────────────────────────────────────────────────────────────────
Test Data Fixtures
──────────────────────────────────────────────────────────────────────────────
@pytest.fixture def sample_<doctype>(rollback_db) -> Generator[Any, None, None]: """ Create sample <DocType> for testing.
Yields:
<DocType> document instance
"""
from <app>.tests.factories.<doctype>_factory import <DocType>Factory
doc = <DocType>Factory.create()
yield doc
# Cleanup handled by rollback_db
──────────────────────────────────────────────────────────────────────────────
Assertion Helpers
──────────────────────────────────────────────────────────────────────────────
@pytest.fixture def assert_doc_exists(): """Helper to assert document existence.""" def _assert(doctype: str, name: str, should_exist: bool = True): exists = frappe.db.exists(doctype, name) if should_exist: assert exists, f"{doctype} {name} should exist but doesn't" else: assert not exists, f"{doctype} {name} should not exist but does" return _assert
@pytest.fixture def assert_permission(): """Helper to assert permission checks.""" def _assert( doctype: str, ptype: str, user: str, should_have: bool = True, doc: Any = None ): frappe.set_user(user) has_perm = frappe.has_permission(doctype, ptype, doc=doc) frappe.set_user("Administrator")
if should_have:
assert has_perm, f"{user} should have {ptype} permission on {doctype}"
else:
assert not has_perm, f"{user} should not have {ptype} permission on {doctype}"
return _assert
Step 5: Generate Factory Pattern
Create <app>/tests/factories/<doctype>_factory.py :
""" Factory for creating <DocType> test data.
Usage: from <app>.tests.factories.<doctype>_factory import <DocType>Factory
# Create with defaults
doc = <DocType>Factory.create()
# Create with custom values
doc = <DocType>Factory.create(title="Custom Title", status="Completed")
# Create without saving (for unit tests)
doc = <DocType>Factory.build()
# Create multiple
docs = <DocType>Factory.create_batch(5)
"""
import frappe from frappe.utils import today, random_string from typing import Optional, Any from dataclasses import dataclass, field
@dataclass class <DocType>Factory: """Factory for <DocType> test documents."""
# Default values
title: str = field(default_factory=lambda: f"Test {random_string(8)}")
date: str = field(default_factory=today)
status: str = "Draft"
description: Optional[str] = None
# Related data (Links)
# customer: Optional[str] = None
@classmethod
def build(cls, **kwargs) -> Any:
"""
Build document instance without saving.
Returns:
Unsaved Document instance
"""
factory = cls(**kwargs)
return frappe.get_doc({
"doctype": "<DocType>",
"title": factory.title,
"date": factory.date,
"status": factory.status,
"description": factory.description,
})
@classmethod
def create(cls, **kwargs) -> Any:
"""
Create and save document.
Returns:
Saved Document instance
"""
doc = cls.build(**kwargs)
doc.insert(ignore_permissions=True)
return doc
@classmethod
def create_batch(cls, count: int, **kwargs) -> list[Any]:
"""
Create multiple documents.
Args:
count: Number of documents to create
**kwargs: Common attributes for all documents
Returns:
List of created documents
"""
return [cls.create(**kwargs) for _ in range(count)]
@classmethod
def create_submitted(cls, **kwargs) -> Any:
"""
Create and submit document (for submittable DocTypes).
Returns:
Submitted Document instance
"""
doc = cls.create(**kwargs)
doc.submit()
return doc
@classmethod
def create_with_items(
cls,
item_count: int = 3,
**kwargs
) -> Any:
"""
Create document with child table items.
Args:
item_count: Number of items to add
**kwargs: Document attributes
Returns:
Document with child items
"""
doc = cls.build(**kwargs)
# Add child items
for i in range(item_count):
doc.append("items", {
"item_code": f"ITEM-{i:03d}",
"qty": i + 1,
"rate": 100.0 * (i + 1)
})
doc.insert(ignore_permissions=True)
return doc
──────────────────────────────────────────────────────────────────────────────
Sequence Generator for Unique Values
──────────────────────────────────────────────────────────────────────────────
class Sequence: """Generate unique sequential values for tests."""
_counters: dict[str, int] = {}
@classmethod
def next(cls, name: str = "default") -> int:
"""Get next value in sequence."""
cls._counters[name] = cls._counters.get(name, 0) + 1
return cls._counters[name]
@classmethod
def reset(cls, name: Optional[str] = None) -> None:
"""Reset sequence counter(s)."""
if name:
cls._counters[name] = 0
else:
cls._counters.clear()
Step 6: Generate Integration Tests
Create <app>/tests/integration/test_<target>.py :
""" Integration tests for <Target>.
These tests require database access and test full workflows.
Run with: bench --site test_site run-tests --app <app> --module <app>.tests.integration.test_<target> """
import pytest import frappe from frappe.tests import IntegrationTestCase from <app>.<module>.services.<target>_service import <Target>Service from <app>.tests.factories.<doctype>_factory import <DocType>Factory
class Test<Target>Integration(IntegrationTestCase): """Integration tests for <Target>."""
@classmethod
def setUpClass(cls):
"""Set up test fixtures once for all tests in class."""
super().setUpClass()
cls.service = <Target>Service()
def setUp(self):
"""Set up before each test."""
frappe.set_user("Administrator")
def tearDown(self):
"""Clean up after each test."""
frappe.db.rollback()
# ──────────────────────────────────────────────────────────────────────────
# CRUD Operations
# ──────────────────────────────────────────────────────────────────────────
def test_create_document(self):
"""Test creating a new document through service."""
data = {
"title": "Integration Test Document",
"date": frappe.utils.today(),
"description": "Created via integration test"
}
result = self.service.create(data)
self.assertIsNotNone(result.get("name"))
self.assertEqual(result.get("title"), data["title"])
# Verify in database
self.assertTrue(
frappe.db.exists("<DocType>", result["name"])
)
def test_create_validates_mandatory_fields(self):
"""Test that mandatory field validation works."""
with self.assertRaises(frappe.ValidationError) as context:
self.service.create({})
self.assertIn("required", str(context.exception).lower())
def test_update_document(self):
"""Test updating existing document."""
doc = <DocType>Factory.create()
result = self.service.update(doc.name, {"title": "Updated Title"})
self.assertEqual(result["title"], "Updated Title")
# Verify in database
db_value = frappe.db.get_value("<DocType>", doc.name, "title")
self.assertEqual(db_value, "Updated Title")
def test_update_nonexistent_raises_error(self):
"""Test updating non-existent document raises error."""
with self.assertRaises(Exception):
self.service.update("NONEXISTENT-001", {"title": "Test"})
def test_delete_document(self):
"""Test deleting document."""
doc = <DocType>Factory.create()
name = doc.name
self.service.repo.delete(name)
self.assertFalse(frappe.db.exists("<DocType>", name))
# ──────────────────────────────────────────────────────────────────────────
# Business Logic
# ──────────────────────────────────────────────────────────────────────────
def test_submit_workflow(self):
"""Test document submission workflow."""
doc = <DocType>Factory.create()
result = self.service.submit(doc.name)
self.assertEqual(result["status"], "Completed")
# Verify docstatus
docstatus = frappe.db.get_value("<DocType>", doc.name, "docstatus")
self.assertEqual(docstatus, 1)
def test_cancel_reverses_submission(self):
"""Test cancellation reverses submission effects."""
doc = <DocType>Factory.create_submitted()
result = self.service.cancel(doc.name, reason="Test cancellation")
self.assertEqual(result["status"], "Cancelled")
def test_cannot_modify_completed_documents(self):
"""Test that completed documents cannot be modified."""
doc = <DocType>Factory.create(status="Completed")
with self.assertRaises(frappe.ValidationError):
self.service.update(doc.name, {"title": "Should Fail"})
# ──────────────────────────────────────────────────────────────────────────
# Permissions
# ──────────────────────────────────────────────────────────────────────────
def test_unauthorized_user_cannot_create(self):
"""Test that unauthorized users cannot create documents."""
# Create user without create permission
test_email = "no_create@example.com"
if not frappe.db.exists("User", test_email):
frappe.get_doc({
"doctype": "User",
"email": test_email,
"first_name": "No Create",
"send_welcome_email": 0
}).insert(ignore_permissions=True)
frappe.set_user(test_email)
with self.assertRaises(frappe.PermissionError):
self.service.create({"title": "Should Fail"})
def test_owner_can_read_own_document(self):
"""Test that document owner can read their own document."""
test_email = "owner_test@example.com"
if not frappe.db.exists("User", test_email):
user = frappe.get_doc({
"doctype": "User",
"email": test_email,
"first_name": "Owner",
"send_welcome_email": 0
}).insert(ignore_permissions=True)
user.add_roles("System Manager")
frappe.set_user(test_email)
doc = <DocType>Factory.create()
# Should not raise
result = self.service.repo.get(doc.name)
self.assertIsNotNone(result)
# ──────────────────────────────────────────────────────────────────────────
# Query & List Operations
# ──────────────────────────────────────────────────────────────────────────
def test_get_list_returns_paginated_results(self):
"""Test list retrieval with pagination."""
# Create test data
<DocType>Factory.create_batch(15)
results = self.service.repo.get_list(limit=10, offset=0)
self.assertLessEqual(len(results), 10)
def test_get_list_filters_by_status(self):
"""Test filtering list by status."""
<DocType>Factory.create(status="Draft")
<DocType>Factory.create(status="Completed")
<DocType>Factory.create(status="Completed")
results = self.service.repo.get_by_status("Completed")
for result in results:
self.assertEqual(result.get("status"), "Completed")
def test_search_finds_matching_documents(self):
"""Test search functionality."""
<DocType>Factory.create(title="Unique Search Term XYZ")
<DocType>Factory.create(title="Another Document")
results = self.service.repo.search("Unique Search")
self.assertTrue(len(results) >= 1)
self.assertTrue(
any("Unique" in r.get("title", "") for r in results)
)
def test_get_dashboard_stats(self):
"""Test dashboard statistics."""
<DocType>Factory.create(status="Draft")
<DocType>Factory.create(status="Completed")
stats = self.service.get_dashboard_stats()
self.assertIn("total", stats)
self.assertIn("draft", stats)
self.assertIn("completed", stats)
self.assertGreaterEqual(stats["total"], 2)
Step 7: Generate Unit Tests
Create <app>/tests/unit/test_<target>.py :
""" Unit tests for <Target>.
These tests do NOT require database access. They test pure logic and validation functions.
Run with: bench --site test_site run-tests --app <app> --module <app>.tests.unit.test_<target> """
import pytest from unittest.mock import Mock, patch, MagicMock from frappe.tests import UnitTestCase
class Test<Target>Unit(UnitTestCase): """Unit tests for <Target> (no database)."""
def test_validate_mandatory_fields(self):
"""Test mandatory field validation logic."""
from <app>.<module>.services.base import BaseService
service = BaseService()
# Should raise for missing fields
with self.assertRaises(Exception):
service.validate_mandatory({}, ["title", "date"])
# Should pass with all fields
service.validate_mandatory(
{"title": "Test", "date": "2024-01-01"},
["title", "date"]
)
def test_document_summary_format(self):
"""Test document summary returns correct format."""
# Mock the document
mock_doc = Mock()
mock_doc.name = "TEST-001"
mock_doc.title = "Test Document"
mock_doc.status = "Draft"
mock_doc.date = "2024-01-01"
mock_doc.get_summary = lambda: {
"name": mock_doc.name,
"title": mock_doc.title,
"status": mock_doc.status,
"date": str(mock_doc.date)
}
summary = mock_doc.get_summary()
self.assertEqual(summary["name"], "TEST-001")
self.assertIn("title", summary)
self.assertIn("status", summary)
@patch("frappe.db.exists")
def test_repository_exists_check(self, mock_exists):
"""Test repository existence check."""
from <app>.<module>.repositories.base import BaseRepository
class TestRepo(BaseRepository):
doctype = "Test DocType"
repo = TestRepo()
# Test when exists
mock_exists.return_value = True
self.assertTrue(repo.exists("TEST-001"))
# Test when not exists
mock_exists.return_value = False
self.assertFalse(repo.exists("TEST-002"))
def test_status_validation(self):
"""Test status values are valid."""
valid_statuses = ["Draft", "Pending", "Completed", "Cancelled"]
invalid_status = "InvalidStatus"
self.assertIn("Draft", valid_statuses)
self.assertNotIn(invalid_status, valid_statuses)
def test_date_formatting(self):
"""Test date formatting utilities."""
from frappe.utils import getdate, formatdate
date_str = "2024-01-15"
date_obj = getdate(date_str)
self.assertEqual(date_obj.year, 2024)
self.assertEqual(date_obj.month, 1)
self.assertEqual(date_obj.day, 15)
class Test<Target>Validation(UnitTestCase): """Unit tests for validation logic."""
def test_title_cannot_be_empty(self):
"""Test that empty titles are rejected."""
invalid_titles = ["", " ", None]
for title in invalid_titles:
with self.subTest(title=title):
is_valid = bool(title and str(title).strip())
self.assertFalse(is_valid)
def test_valid_title_accepted(self):
"""Test that valid titles are accepted."""
valid_titles = ["Test", "Test Title", "A", "123"]
for title in valid_titles:
with self.subTest(title=title):
is_valid = bool(title and str(title).strip())
self.assertTrue(is_valid)
Step 8: Show Test Plan and Confirm
Test Suite Preview
Target: <Target> Coverage Goal: >80%
Test Structure:
📁 <app>/tests/ ├── 📄 conftest.py (fixtures) ├── 📁 factories/ │ └── 📄 <doctype>factory.py ├── 📁 unit/ │ └── 📄 test<target>.py (12 tests) └── 📁 integration/ └── 📄 test_<target>.py (15 tests)
Test Coverage:
| Category | Tests | Description |
|---|---|---|
| CRUD | 5 | Create, Read, Update, Delete |
| Business Logic | 4 | Submit, Cancel, Workflows |
| Permissions | 3 | Role-based access control |
| Queries | 3 | List, Filter, Search |
| Validation | 5 | Input validation, edge cases |
| Unit | 7 | Pure logic, no database |
Commands:
# Run all tests
bench --site test_site run-tests --app <app>
# Run specific module
bench --site test_site run-tests --module <app>.tests.integration.test_<target>
# Run with coverage
bench --site test_site run-tests --app <app> --coverage
Create this test suite?
### Step 9: Execute and Verify
After approval, create files and run tests:
```bash
bench --site test_site run-tests --app <app> -v
Output Format
## Test Suite Created
**Target:** <Target>
**Files:** 4
### Files Created:
- ✅ conftest.py (pytest fixtures)
- ✅ factories/<doctype>_factory.py
- ✅ unit/test_<target>.py (7 tests)
- ✅ integration/test_<target>.py (15 tests)
### Run Tests:
```bash
# All tests
bench --site test_site run-tests --app <app>
# With verbose output
bench --site test_site run-tests --app <app> -v
# Specific test
bench --site test_site run-tests --app <app> -k "test_create"
Coverage Report:
Run bench --site test_site run-tests --app <app> --coverage
for coverage report.
## Rules
1. **Test Isolation** — Each test should be independent, use `rollback_db` fixture
2. **Factory Pattern** — Use factories for test data, never hardcode values
3. **Meaningful Names** — Test names should describe what is being tested
4. **AAA Pattern** — Arrange, Act, Assert structure for each test
5. **Unit vs Integration** — Unit tests = no DB, Integration tests = with DB
6. **Permission Tests** — Always test both authorized and unauthorized access
7. **Edge Cases** — Test empty values, nulls, large inputs, special characters
8. **ALWAYS Confirm** — Never create files without explicit user approval
## Mocking Best Practices
**Mock `frappe.db.commit`** — If code under test calls `frappe.db.commit`, mock it to prevent partial commits:
```python
@patch("myapp.mymodule.frappe.db.commit", new=MagicMock)
def test_something(self):
# commits are mocked, won't persist to DB
pass
Use frappe.flags.in_test
— Check if running in test context:
if frappe.flags.in_test: # or frappe.in_test in newer versions
# Skip external API calls, notifications, etc.
pass
Test Site Naming — Run tests on sites starting with test_
to avoid accidental data loss:
bench --site test_mysite run-tests --app myapp