frappe-test

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Frappe 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 --coverage

Procedure

操作流程

Step 1: Analyze Test Target

步骤1:分析测试目标

Identify what needs to be tested:
  1. DocType — Controller lifecycle hooks, validation, business rules
  2. Service — Business logic, orchestration, error handling
  3. Repository — Data access patterns, queries
  4. API — Endpoints, authentication, input validation
  5. Utility — Helper functions, formatters
确定需要测试的内容:
  1. DocType — 控制器生命周期钩子、验证、业务规则
  2. 服务 — 业务逻辑、编排、错误处理
  3. 数据仓库 — 数据访问模式、查询
  4. API — 端点、认证、输入验证
  5. 工具类 — 辅助函数、格式化器

Step 2: Determine Test Strategy

步骤2:确定测试策略

Based on target, determine appropriate test types:
ComponentUnit TestIntegration TestE2E 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.py

Step 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, Any
python
"""
<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 _assert
undefined
@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 _assert
undefined

Step 5: Generate Factory Pattern

步骤5:生成工厂模式

Create
<app>/tests/factories/<doctype>_factory.py
:
python
"""
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.py
python
"""
用于创建<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()
undefined
class 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()
undefined

Step 6: Generate Integration Tests

步骤6:生成集成测试

Create
<app>/tests/integration/test_<target>.py
:
python
"""
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>.py
python
"""
<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>.py
:
python
"""
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>.py
python
"""
<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:展示测试计划并确认

undefined
undefined

Test 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:

测试覆盖:

CategoryTestsDescription
CRUD5Create, Read, Update, Delete
Business Logic4Submit, Cancel, Workflows
Permissions3Role-based access control
Queries3List, Filter, Search
Validation5Input validation, edge cases
Unit7Pure logic, no database
分类测试用例数描述
CRUD5创建、读取、更新、删除
业务逻辑4提交、取消、工作流
权限3基于角色的访问控制
查询3列表、过滤、搜索
验证5输入验证、边缘情况
单元测试7纯逻辑、无数据库

Commands:

命令:

bash
undefined
bash
undefined

Run 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> -v

Output Format

输出格式

undefined
undefined

Test 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
undefined
bash
undefined

All 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"
undefined
bench --site test_site run-tests --app <app> -k "test_create"
undefined

Coverage Report:

覆盖率报告:

Run
bench --site test_site run-tests --app <app> --coverage
for coverage report.
undefined
运行
bench --site test_site run-tests --app <app> --coverage
生成覆盖率报告。
undefined

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
  1. 测试隔离 — 每个测试应独立,使用
    rollback_db
    夹具
  2. 工厂模式 — 使用工厂生成测试数据,绝不硬编码值
  3. 有意义的命名 — 测试名称应描述测试内容
  4. AAA模式 — 每个测试遵循Arrange(准备)、Act(执行)、Assert(断言)结构
  5. 单元与集成测试区分 — 单元测试=无数据库,集成测试=带数据库
  6. 权限测试 — 始终测试授权和未授权两种访问情况
  7. 边缘情况 — 测试空值、null、大输入、特殊字符
  8. 必须确认 — 未经用户明确批准,绝不创建文件

Mocking Best Practices

Mock最佳实践

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:
python
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:
bash
bench --site test_mysite run-tests --app myapp
Mock
frappe.db.commit
— 如果被测代码调用
frappe.db.commit
,应模拟它以避免部分提交:
python
@patch("myapp.mymodule.frappe.db.commit", new=MagicMock)
def test_something(self):
    # 提交操作已被模拟,不会持久化到数据库
    pass
使用
frappe.flags.in_test
— 检查是否在测试环境中运行:
python
if frappe.flags.in_test:  # 新版本中可使用frappe.in_test
    # 跳过外部API调用、通知等
    pass
测试站点命名 — 在以
test_
开头的站点上运行测试,避免意外数据丢失:
bash
bench --site test_mysite run-tests --app myapp