教程概述
系列目录: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 阶段产出可靠的测试套件:
- 单元测试 - 覆盖核心功能模块
- E2E 测试 - 验证完整工作流
- pytest - 标准 Python 测试框架
- 覆盖率 - 确保代码质量
系列导航:
- ← 上一篇:教程 5:第四阶段 - 测试计划
- → 下一篇:教程 7:第六阶段 - 文档与 SKILL.md