Create comprehensive test suites for Frappe Framework v15 applications. Triggers: "create tests", "add tests", "frappe test", "write tests", "test coverage", "/frappe-test". Generates unit tests, integration tests, fixtures, and factory patterns following testing best practices.
/plugin marketplace add sergio-bershadsky/ai/plugin install frappe-dev@bershadsky-claude-toolsThis skill inherits all available tools. When active, it can use any tool Claude has access to.
Create comprehensive test coverage for Frappe v15 applications using pytest-compatible test classes, fixtures, and factory patterns.
/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
Identify what needs to be tested:
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 | ✓ | — | — |
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
"""
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
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()
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)
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)
## 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:
```bash
# 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
## 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"
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
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.