frappe-test
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseFrappe Testing Suite
Frappe测试套件
Create comprehensive test coverage for Frappe v15 applications using pytest-compatible test classes, fixtures, and factory patterns.
使用兼容pytest的测试类、测试夹具和工厂模式,为Frappe v15应用创建全面的测试覆盖。
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
- 为现有代码添加测试覆盖
- 为新的DocType/API/服务创建测试
- 设置测试夹具和工厂
- 编写涉及数据库访问的集成测试
- 创建无需依赖数据库的单元测试
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/frappe-test <target> [--type <unit|integration|e2e>] [--coverage]示例:
/frappe-test SalesOrder
/frappe-test inventory_service --type unit
/frappe-test api.orders --type integration --coverageProcedure
操作流程
Step 1: Analyze Test Target
步骤1:分析测试目标
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
确定需要测试的内容:
- DocType — 控制器生命周期钩子、验证、业务规则
- 服务 — 业务逻辑、编排、错误处理
- 数据仓库 — 数据访问模式、查询
- API — 端点、认证、输入验证
- 工具类 — 辅助函数、格式化器
Step 2: Determine Test Strategy
步骤2:确定测试策略
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 | ✓ | — | — |
根据目标类型,选择合适的测试类型:
| 组件 | 单元测试 | 集成测试 | 端到端测试 |
|---|---|---|---|
| DocType控制器 | ✓(钩子) | ✓(完整生命周期) | — |
| 服务层 | ✓(逻辑) | ✓(带数据库) | — |
| 数据仓库 | — | ✓(查询) | — |
| API端点 | ✓(验证) | ✓(完整请求) | ✓ |
| 工具函数 | ✓ | — | — |
Step 3: Generate Test Structure
步骤3:生成测试结构
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创建测试目录结构:
<app>/tests/
├── __init__.py
├── conftest.py # Pytest测试夹具
├── factories/ # 测试数据工厂
│ ├── __init__.py
│ └── <doctype>_factory.py
├── unit/ # 单元测试(无数据库)
│ ├── __init__.py
│ └── test_<module>.py
├── integration/ # 集成测试(带数据库)
│ ├── __init__.py
│ └── test_<module>.py
└── e2e/ # 端到端测试
└── test_workflows.pyStep 4: Generate conftest.py (Pytest Configuration)
步骤4:生成conftest.py(Pytest配置)
python
"""
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, Anypython
"""
<app>的Pytest配置和测试夹具。
使用方法:
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"
@pytest.fixture(scope="session")
def app_context():
"""初始化测试用的Frappe应用上下文。"""
# Frappe会自动处理,但可在此添加显式设置
yield
# 所有测试完成后的清理操作
@pytest.fixture(scope="session")
def test_admin_user() -> str:
"""获取或创建测试用的管理员用户。"""
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}"
}@pytest.fixture(scope="module")
def test_user() -> Generator[str, None, None]:
"""
为模块创建测试用户。
返回:
用户邮箱/名称
"""
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
# 清理:可选在模块测试后删除用户
# frappe.delete_doc("User", email, force=True)@pytest.fixture(scope="module")
def api_credentials(test_user: str) -> Generator[dict, None, None]:
"""
为测试用户生成API凭证。
返回:
包含api_key和api_secret的字典
"""
user = frappe.get_doc("User", test_user)
# 若不存在则生成API密钥
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()@pytest.fixture
def as_user(test_user: str) -> Generator[str, None, None]:
"""
以指定用户身份运行测试。
使用方法:
def test_something(as_user):
# 测试将以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]:
"""以访客身份运行测试。"""
original_user = frappe.session.user
frappe.set_user("Guest")
yield "Guest"
frappe.set_user(original_user)
@pytest.fixture
def rollback_db():
"""
测试后回滚数据库。
适用于修改数据但不应保留更改的测试。
"""
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@pytest.fixture
def sample_<doctype>(rollback_db) -> Generator[Any, None, None]:
"""
创建测试用的<DocType>示例。
返回:
<DocType>文档实例
"""
from <app>.tests.factories.<doctype>_factory import <DocType>Factory
doc = <DocType>Factory.create()
yield doc
# 清理操作由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 _assertundefined@pytest.fixture
def assert_doc_exists():
"""用于断言文档是否存在的辅助函数。"""
def _assert(doctype: str, name: str, should_exist: bool = True):
exists = frappe.db.exists(doctype, name)
if should_exist:
assert exists, f"{doctype} {name}应存在但未找到"
else:
assert not exists, f"{doctype} {name}不应存在但已找到"
return _assert
@pytest.fixture
def assert_permission():
"""用于断言权限检查的辅助函数。"""
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}应拥有{doctype}的{ptype}权限"
else:
assert not has_perm, f"{user}不应拥有{doctype}的{ptype}权限"
return _assertundefinedStep 5: Generate Factory Pattern
步骤5:生成工厂模式
Create :
<app>/tests/factories/<doctype>_factory.pypython
"""
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创建:
<app>/tests/factories/<doctype>_factory.pypython
"""
用于创建<DocType>测试数据的工厂类。
使用方法:
from <app>.tests.factories.<doctype>_factory import <DocType>Factory
# 使用默认值创建
doc = <DocType>Factory.create()
# 使用自定义值创建
doc = <DocType>Factory.create(title="Custom Title", status="Completed")
# 创建但不保存(用于单元测试)
doc = <DocType>Factory.build()
# 创建多个实例
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:
"""<DocType>测试文档的工厂类。"""
# 默认值
title: str = field(default_factory=lambda: f"Test {random_string(8)}")
date: str = field(default_factory=today)
status: str = "Draft"
description: Optional[str] = None
# 关联数据(链接)
# customer: Optional[str] = None
@classmethod
def build(cls, **kwargs) -> Any:
"""
创建文档实例但不保存。
返回:
未保存的文档实例
"""
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:
"""
创建并保存文档。
返回:
已保存的文档实例
"""
doc = cls.build(**kwargs)
doc.insert(ignore_permissions=True)
return doc
@classmethod
def create_batch(cls, count: int, **kwargs) -> list[Any]:
"""
创建多个文档。
参数:
count: 要创建的文档数量
**kwargs: 所有文档的公共属性
返回:
创建的文档列表
"""
return [cls.create(**kwargs) for _ in range(count)]
@classmethod
def create_submitted(cls, **kwargs) -> Any:
"""
创建并提交文档(适用于可提交的DocType)。
返回:
已提交的文档实例
"""
doc = cls.create(**kwargs)
doc.submit()
return doc
@classmethod
def create_with_items(
cls,
item_count: int = 3,
**kwargs
) -> Any:
"""
创建包含子表项的文档。
参数:
item_count: 要添加的子项数量
**kwargs: 文档属性
返回:
包含子项的文档
"""
doc = cls.build(**kwargs)
# 添加子表项
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()undefinedclass Sequence:
"""为测试生成唯一的连续值。"""
_counters: dict[str, int] = {}
@classmethod
def next(cls, name: str = "default") -> int:
"""获取序列的下一个值。"""
cls._counters[name] = cls._counters.get(name, 0) + 1
return cls._counters[name]
@classmethod
def reset(cls, name: Optional[str] = None) -> None:
"""重置序列计数器。"""
if name:
cls._counters[name] = 0
else:
cls._counters.clear()undefinedStep 6: Generate Integration Tests
步骤6:生成集成测试
Create :
<app>/tests/integration/test_<target>.pypython
"""
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)创建:
<app>/tests/integration/test_<target>.pypython
"""
<Target>的集成测试。
这些测试需要数据库访问,用于测试完整工作流。
运行命令:
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):
"""<Target>的集成测试。"""
@classmethod
def setUpClass(cls):
"""为类中所有测试一次性设置测试夹具。"""
super().setUpClass()
cls.service = <Target>Service()
def setUp(self):
"""每个测试前的设置。"""
frappe.set_user("Administrator")
def tearDown(self):
"""每个测试后的清理。"""
frappe.db.rollback()
# ──────────────────────────────────────────────────────────────────────────
# CRUD操作
# ──────────────────────────────────────────────────────────────────────────
def test_create_document(self):
"""测试通过服务创建新文档。"""
data = {
"title": "集成测试文档",
"date": frappe.utils.today(),
"description": "通过集成测试创建"
}
result = self.service.create(data)
self.assertIsNotNone(result.get("name"))
self.assertEqual(result.get("title"), data["title"])
# 验证数据库中是否存在
self.assertTrue(
frappe.db.exists("<DocType>", result["name"])
)
def test_create_validates_mandatory_fields(self):
"""测试必填字段验证是否生效。"""
with self.assertRaises(frappe.ValidationError) as context:
self.service.create({})
self.assertIn("required", str(context.exception).lower())
def test_update_document(self):
"""测试更新现有文档。"""
doc = <DocType>Factory.create()
result = self.service.update(doc.name, {"title": "更新后的标题"})
self.assertEqual(result["title"], "更新后的标题")
# 验证数据库中的值
db_value = frappe.db.get_value("<DocType>", doc.name, "title")
self.assertEqual(db_value, "更新后的标题")
def test_update_nonexistent_raises_error(self):
"""测试更新不存在的文档是否抛出错误。"""
with self.assertRaises(Exception):
self.service.update("NONEXISTENT-001", {"title": "Test"})
def test_delete_document(self):
"""测试删除文档。"""
doc = <DocType>Factory.create()
name = doc.name
self.service.repo.delete(name)
self.assertFalse(frappe.db.exists("<DocType>", name))
# ──────────────────────────────────────────────────────────────────────────
# 业务逻辑
# ──────────────────────────────────────────────────────────────────────────
def test_submit_workflow(self):
"""测试文档提交工作流。"""
doc = <DocType>Factory.create()
result = self.service.submit(doc.name)
self.assertEqual(result["status"], "Completed")
# 验证docstatus
docstatus = frappe.db.get_value("<DocType>", doc.name, "docstatus")
self.assertEqual(docstatus, 1)
def test_cancel_reverses_submission(self):
"""测试取消操作是否回滚提交的影响。"""
doc = <DocType>Factory.create_submitted()
result = self.service.cancel(doc.name, reason="测试取消")
self.assertEqual(result["status"], "Cancelled")
def test_cannot_modify_completed_documents(self):
"""测试已完成的文档无法修改。"""
doc = <DocType>Factory.create(status="Completed")
with self.assertRaises(frappe.ValidationError):
self.service.update(doc.name, {"title": "应失败"})
# ──────────────────────────────────────────────────────────────────────────
# 权限测试
# ──────────────────────────────────────────────────────────────────────────
def test_unauthorized_user_cannot_create(self):
"""测试未授权用户无法创建文档。"""
# 创建无创建权限的用户
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": "应失败"})
def test_owner_can_read_own_document(self):
"""测试文档所有者可以读取自己的文档。"""
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()
# 不应抛出错误
result = self.service.repo.get(doc.name)
self.assertIsNotNone(result)
# ──────────────────────────────────────────────────────────────────────────
# 查询与列表操作
# ──────────────────────────────────────────────────────────────────────────
def test_get_list_returns_paginated_results(self):
"""测试列表检索的分页功能。"""
# 创建测试数据
<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):
"""测试按状态过滤列表。"""
<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):
"""测试搜索功能。"""
<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):
"""测试仪表板统计数据。"""
<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
步骤7:生成单元测试
Create :
<app>/tests/unit/test_<target>.pypython
"""
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)创建:
<app>/tests/unit/test_<target>.pypython
"""
<Target>的单元测试。
这些测试不需要数据库访问,仅测试纯逻辑和验证函数。
运行命令:
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):
"""<Target>的单元测试(无数据库)。"""
def test_validate_mandatory_fields(self):
"""测试必填字段验证逻辑。"""
from <app>.<module>.services.base import BaseService
service = BaseService()
# 缺少字段应抛出错误
with self.assertRaises(Exception):
service.validate_mandatory({}, ["title", "date"])
# 所有字段存在应通过
service.validate_mandatory(
{"title": "Test", "date": "2024-01-01"},
["title", "date"]
)
def test_document_summary_format(self):
"""测试文档摘要返回的格式是否正确。"""
# 模拟文档
mock_doc = Mock()
mock_doc.name = "TEST-001"
mock_doc.title = "测试文档"
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):
"""测试数据仓库的存在性检查。"""
from <app>.<module>.repositories.base import BaseRepository
class TestRepo(BaseRepository):
doctype = "Test DocType"
repo = TestRepo()
# 测试存在的情况
mock_exists.return_value = True
self.assertTrue(repo.exists("TEST-001"))
# 测试不存在的情况
mock_exists.return_value = False
self.assertFalse(repo.exists("TEST-002"))
def test_status_validation(self):
"""测试状态值是否合法。"""
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):
"""测试日期格式化工具。"""
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):
"""验证逻辑的单元测试。"""
def test_title_cannot_be_empty(self):
"""测试空标题是否被拒绝。"""
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):
"""测试合法标题是否被接受。"""
valid_titles = ["Test", "测试标题", "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
步骤8:展示测试计划并确认
undefinedundefinedTest Suite Preview
测试套件预览
Target: <Target>
Coverage Goal: >80%
目标: <Target>
覆盖目标: >80%
Test Structure:
测试结构:
📁 <app>/tests/
├── 📄 conftest.py (fixtures)
├── 📁 factories/
│ └── 📄 <doctype>factory.py
├── 📁 unit/
│ └── 📄 test<target>.py (12 tests)
└── 📁 integration/
└── 📄 test_<target>.py (15 tests)
📁 <app>/tests/
├── 📄 conftest.py(测试夹具)
├── 📁 factories/
│ └── 📄 <doctype>factory.py
├── 📁 unit/
│ └── 📄 test<target>.py(12个测试用例)
└── 📁 integration/
└── 📄 test_<target>.py(15个测试用例)
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 |
| 分类 | 测试用例数 | 描述 |
|---|---|---|
| CRUD | 5 | 创建、读取、更新、删除 |
| 业务逻辑 | 4 | 提交、取消、工作流 |
| 权限 | 3 | 基于角色的访问控制 |
| 查询 | 3 | 列表、过滤、搜索 |
| 验证 | 5 | 输入验证、边缘情况 |
| 单元测试 | 7 | 纯逻辑、无数据库 |
Commands:
命令:
bash
undefinedbash
undefinedRun all tests
运行所有测试
bench --site test_site run-tests --app <app>
bench --site test_site run-tests --app <app>
Run specific module
运行指定模块
bench --site test_site run-tests --module <app>.tests.integration.test_<target>
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?bench --site test_site run-tests --app <app> --coverage
---
是否创建此测试套件?Step 9: Execute and Verify
步骤9:执行并验证
After approval, create files and run tests:
bash
bench --site test_site run-tests --app <app> -v获得批准后,创建文件并运行测试:
bash
bench --site test_site run-tests --app <app> -vOutput Format
输出格式
undefinedundefinedTest Suite Created
测试套件已创建
Target: <Target>
Files: 4
目标: <Target>
文件数: 4
Files Created:
创建的文件:
- ✅ conftest.py (pytest fixtures)
- ✅ factories/<doctype>_factory.py
- ✅ unit/test_<target>.py (7 tests)
- ✅ integration/test_<target>.py (15 tests)
- ✅ conftest.py(pytest测试夹具)
- ✅ factories/<doctype>_factory.py
- ✅ unit/test_<target>.py(7个测试用例)
- ✅ integration/test_<target>.py(15个测试用例)
Run Tests:
运行测试:
bash
undefinedbash
undefinedAll tests
所有测试
bench --site test_site run-tests --app <app>
bench --site test_site run-tests --app <app>
With verbose output
详细输出
bench --site test_site run-tests --app <app> -v
bench --site test_site run-tests --app <app> -v
Specific test
指定测试用例
bench --site test_site run-tests --app <app> -k "test_create"
undefinedbench --site test_site run-tests --app <app> -k "test_create"
undefinedCoverage Report:
覆盖率报告:
Run for coverage report.
bench --site test_site run-tests --app <app> --coverageundefined运行生成覆盖率报告。
bench --site test_site run-tests --app <app> --coverageundefinedRules
规则
- Test Isolation — Each test should be independent, use fixture
rollback_db - Factory Pattern — Use factories for test data, never hardcode values
- Meaningful Names — Test names should describe what is being tested
- AAA Pattern — Arrange, Act, Assert structure for each test
- Unit vs Integration — Unit tests = no DB, Integration tests = with DB
- Permission Tests — Always test both authorized and unauthorized access
- Edge Cases — Test empty values, nulls, large inputs, special characters
- ALWAYS Confirm — Never create files without explicit user approval
- 测试隔离 — 每个测试应独立,使用夹具
rollback_db - 工厂模式 — 使用工厂生成测试数据,绝不硬编码值
- 有意义的命名 — 测试名称应描述测试内容
- AAA模式 — 每个测试遵循Arrange(准备)、Act(执行)、Assert(断言)结构
- 单元与集成测试区分 — 单元测试=无数据库,集成测试=带数据库
- 权限测试 — 始终测试授权和未授权两种访问情况
- 边缘情况 — 测试空值、null、大输入、特殊字符
- 必须确认 — 未经用户明确批准,绝不创建文件
Mocking Best Practices
Mock最佳实践
Mock — If code under test calls , mock it to prevent partial commits:
frappe.db.commitfrappe.db.commitpython
@patch("myapp.mymodule.frappe.db.commit", new=MagicMock)
def test_something(self):
# commits are mocked, won't persist to DB
passUse — Check if running in test context:
frappe.flags.in_testpython
if frappe.flags.in_test: # or frappe.in_test in newer versions
# Skip external API calls, notifications, etc.
passTest Site Naming — Run tests on sites starting with to avoid accidental data loss:
test_bash
bench --site test_mysite run-tests --app myappMock — 如果被测代码调用,应模拟它以避免部分提交:
frappe.db.commitfrappe.db.commitpython
@patch("myapp.mymodule.frappe.db.commit", new=MagicMock)
def test_something(self):
# 提交操作已被模拟,不会持久化到数据库
pass使用 — 检查是否在测试环境中运行:
frappe.flags.in_testpython
if frappe.flags.in_test: # 新版本中可使用frappe.in_test
# 跳过外部API调用、通知等
pass测试站点命名 — 在以开头的站点上运行测试,避免意外数据丢失:
test_bash
bench --site test_mysite run-tests --app myapp