返回

CLI-Anything 教程 6:第五阶段 Write Tests(单元测试与 E2E 测试编写)

CLI-Anything 第五阶段 Write Tests 详解:如何编写 pytest 单元测试和 E2E 测试,包含测试代码示例、mock 策略和测试最佳实践。

教程概述

系列目录:CLI-Anything 教程系列索引

Write Tests 是 CLI-Anything 7 阶段流程的第五阶段。在这一阶段,我们将 Plan Tests 阶段的测试计划转化为可运行的测试代码

单元测试实现

测试目录结构

tests/
├── __init__.py
├── conftest.py              # pytest 配置和 fixtures
├── unit/
│   ├── __init__.py
│   ├── test_state.py        # 状态管理测试
│   ├── test_validators.py   # 验证器测试
│   └── test_commands/       # 命令测试
│       ├── test_project.py
│       ├── test_image.py
│       └── test_layer.py
└── e2e/
    ├── __init__.py
    ├── test_workflows.py    # 完整工作流测试
    └── test_batch.py        # 批处理测试

conftest.py 配置

# tests/conftest.py

import pytest
import tempfile
from pathlib import Path
from click.testing import CliRunner

from cli_anything.state import ProjectState, ImageState


@pytest.fixture
def runner():
    """Provide Click test runner."""
    return CliRunner()


@pytest.fixture
def temp_dir():
    """Provide temporary directory."""
    with tempfile.TemporaryDirectory() as tmpdir:
        yield Path(tmpdir)


@pytest.fixture
def sample_project(temp_dir):
    """Create a sample project for testing."""
    state = ProjectState(name="TestProject")
    state_path = temp_dir / "test_project.json"
    state.save(state_path)
    return state, state_path


@pytest.fixture
def sample_image():
    """Create a sample image for testing."""
    return ImageState(
        image_id="img_001",
        name="TestImage",
        width=1920,
        height=1080
    )

状态管理测试

# tests/unit/test_state.py

import pytest
from datetime import datetime

from cli_anything.state import ProjectState, ImageState, LayerState


class TestProjectState:
    """Test suite for ProjectState."""

    def test_project_creation(self):
        """Test basic project creation."""
        project = ProjectState(name="MyProject")

        assert project.name == "MyProject"
        assert project.project_id.startswith("proj_")
        assert project.modified is False
        assert len(project.images) == 0

    def test_project_save_load(self, temp_dir):
        """Test project state persistence."""
        # Create and save
        project = ProjectState(name="TestProject")
        state_path = temp_dir / "project.json"
        project.save(state_path)

        # Load
        loaded = ProjectState.load(state_path)

        assert loaded.name == project.name
        assert loaded.project_id == project.project_id

    def test_project_add_command(self):
        """Test command history tracking."""
        project = ProjectState(name="Test")

        project.add_command(
            command="image new",
            args={"width": 100, "height": 100},
            undo_data={"images": {}}
        )

        assert len(project.history) == 1
        assert project.history_position == 0
        assert project.modified is True

    def test_project_undo(self):
        """Test undo functionality."""
        project = ProjectState(name="Test")
        project.add_command("cmd1", {}, {"old": "value"})

        result = project.undo()

        assert result is True
        assert project.history_position == -1

    def test_project_undo_nothing(self):
        """Test undo with empty history."""
        project = ProjectState(name="Test")

        result = project.undo()

        assert result is False

    def test_project_redo(self):
        """Test redo functionality."""
        project = ProjectState(name="Test")
        project.add_command("cmd1", {}, {})
        project.undo()

        result = project.redo()

        assert result is True
        assert project.history_position == 0


class TestImageState:
    """Test suite for ImageState."""

    def test_image_creation(self):
        """Test image state creation."""
        image = ImageState(
            image_id="img_001",
            name="Test",
            width=1920,
            height=1080
        )

        assert image.name == "Test"
        assert image.width == 1920
        assert image.height == 1080

    def test_image_add_layer(self):
        """Test adding layer to image."""
        image = ImageState(image_id="img_001", name="Test", width=100, height=100)
        layer = LayerState(layer_id="layer_001", name="Background")

        image.layers.append(layer)

        assert len(image.layers) == 1
        assert image.layers[0].name == "Background"

命令测试

# tests/unit/test_commands/test_project.py

import json
import pytest
from click.testing import CliRunner

from cli_anything.cli import cli


class TestProjectCommands:
    """Test suite for project commands."""

    def test_project_new(self, runner, temp_dir):
        """Test project new command."""
        result = runner.invoke(cli, [
            '--json',
            'project', 'new',
            '--name', 'TestProject',
            '--output', str(temp_dir / 'project.json')
        ])

        assert result.exit_code == 0

        output = json.loads(result.output)
        assert output['success'] is True
        assert output['data']['project_name'] == 'TestProject'

    def test_project_new_duplicate_name(self, runner, temp_dir):
        """Test project new with existing file."""
        # Create first project
        runner.invoke(cli, [
            'project', 'new',
            '--name', 'TestProject',
            '--output', str(temp_dir / 'project.json')
        ])

        # Try to create again
        result = runner.invoke(cli, [
            '--json',
            'project', 'new',
            '--name', 'TestProject2',
            '--output', str(temp_dir / 'project.json')
        ])

        assert result.exit_code != 0

    def test_project_info_no_project(self, runner):
        """Test project info without loading project."""
        result = runner.invoke(cli, ['--json', 'project', 'info'])

        assert result.exit_code != 0
        output = json.loads(result.output)
        assert output['success'] is False

    def test_project_info_with_project(self, runner, temp_dir):
        """Test project info with loaded project."""
        project_path = temp_dir / 'project.json'

        # Create project
        runner.invoke(cli, [
            'project', 'new',
            '--name', 'TestProject',
            '--output', str(project_path)
        ])

        # Get info
        result = runner.invoke(cli, [
            '--json', '--project', str(project_path),
            'project', 'info'
        ])

        assert result.exit_code == 0
        output = json.loads(result.output)
        assert output['data']['name'] == 'TestProject'

E2E 测试实现

完整工作流测试

# tests/e2e/test_workflows.py

import pytest
import json
from pathlib import Path

from cli_anything.cli import cli


class TestWorkflows:
    """End-to-end workflow tests."""

    def test_create_edit_export_workflow(self, runner, temp_dir):
        """Test complete workflow: create → edit → export."""
        project_path = temp_dir / 'project.json'

        # Step 1: Create project
        result = runner.invoke(cli, [
            'project', 'new',
            '--name', 'WorkflowTest',
            '--output', str(project_path)
        ])
        assert result.exit_code == 0

        # Step 2: Create image
        result = runner.invoke(cli, [
            '--project', str(project_path),
            'image', 'new',
            '--width', '800',
            '--height', '600',
            '--name', 'TestImage'
        ])
        assert result.exit_code == 0

        # Step 3: Add layer
        result = runner.invoke(cli, [
            '--project', str(project_path),
            'layer', 'new',
            '--name', 'Layer1'
        ])
        assert result.exit_code == 0

        # Step 4: Apply filter (mocked)
        # In real test, this would interact with actual GIMP

        # Step 5: Export
        output_path = temp_dir / 'output.png'
        result = runner.invoke(cli, [
            '--project', str(project_path),
            'export', 'render',
            str(output_path),
            '--format', 'png'
        ])
        assert result.exit_code == 0

    def test_undo_redo_workflow(self, runner, temp_dir):
        """Test undo/redo functionality."""
        project_path = temp_dir / 'project.json'

        # Create project and add layer
        runner.invoke(cli, [
            'project', 'new',
            '--name', 'UndoTest',
            '--output', str(project_path)
        ])

        # Get initial state
        result = runner.invoke(cli, [
            '--json', '--project', str(project_path),
            'project', 'info'
        ])
        initial_state = json.loads(result.output)

        # Add layer
        runner.invoke(cli, [
            '--project', str(project_path),
            'layer', 'new',
            '--name', 'TestLayer'
        ])

        # Undo (via REPL)
        # In real implementation, test would verify layer count changed

    def test_batch_processing(self, runner, temp_dir):
        """Test batch processing multiple files."""
        # Create test files
        for i in range(3):
            (temp_dir / f'input_{i}.png').touch()

        project_path = temp_dir / 'batch_project.json'

        # Create project
        runner.invoke(cli, [
            'project', 'new',
            '--name', 'BatchTest',
            '--output', str(project_path)
        ])

        # Run batch command
        result = runner.invoke(cli, [
            '--project', str(project_path),
            'export', 'batch',
            '--input', str(temp_dir / 'input_*.png'),
            '--output-dir', str(temp_dir / 'output'),
            '--format', 'jpg'
        ])

        assert result.exit_code == 0

测试运行

运行命令

# 运行所有测试
pytest

# 运行并显示详细输出
pytest -v

# 仅运行单元测试
pytest tests/unit/ -v

# 仅运行 E2E 测试
pytest tests/e2e/ -v

# 运行特定测试
pytest tests/unit/test_state.py::TestProjectState::test_project_creation -v

# 生成覆盖率报告
pytest --cov=cli_anything --cov-report=html --cov-report=term

# 并行运行测试(需要 pytest-xdist)
pytest -n auto

小结

Write Tests 阶段产出可靠的测试套件:

  1. 单元测试 - 覆盖核心功能模块
  2. E2E 测试 - 验证完整工作流
  3. pytest - 标准 Python 测试框架
  4. 覆盖率 - 确保代码质量

系列导航