Clean Pytest
/install clean-pytest
\r \r
Clean Pytest\r
\r Clean, maintainable pytest test patterns using Fake-based testing, contract testing, and dependency injection. Focuses on test isolation, reusability, and clarity through explicit AAA pattern and well-structured fixtures.\r \r
When to Use\r
\r
- Setting up test suites for Python/MCP projects\r
- Creating Fake implementations for external dependencies\r
- Writing contract tests for MCP tools/controllers\r
- Implementing test patterns with dependency injection\r
- Testing layered architectures (Controllers → Services → Repositories)\r
- Writing parametrized tests for multiple scenarios\r \r
Core Principles\r
\r
1. Fakes over Mocks\r
\r
Use Fake classes instead of mocking with unittest.mock. Fakes are in-memory implementations that mimic real dependencies without external calls.\r
\r
Why Fakes?\r
- More readable and maintainable\r
- Easier to debug\r
- Better test isolation\r
- No monkey-patching magic\r
- Self-documenting behavior\r \r
2. Explicit AAA Pattern\r
\r Structure every test into three clear phases with comments:\r \r
# Arrange\r
# Set up test data and dependencies\r
\r
# Act\r
# Execute the code under test\r
\r
# Assert\r
# Verify the result\r
```\r
\r
### 3. Dependency Injection in Fixtures\r
\r
Inject dependencies between fixtures to maintain relationships and avoid duplication.\r
\r
### 4. Contract Testing\r
\r
Verify that components register tools/functions correctly and pass expected arguments.\r
\r
## Architecture Pattern\r
\r
```\r
Controller (MCP Tools)\r
↓\r
Service (Business Logic)\r
↓\r
Repository (Data Access)\r
↓\r
Fake (Test Implementation)\r
```\r
\r
## Creating Fakes\r
\r
### Basic Fake Structure\r
\r
Create a Fake class that implements the same interface as the real dependency:\r
\r
```python\r
# tests/fakes.py\r
from typing import Any, Dict, List, Optional\r
\r
class FakeAuth:\r
"""Fake implementation of AuthProvider for testing."""\r
def __init__(self) -> None:\r
self.created: List[Dict[str, Any]] = []\r
self.deleted: List[str] = []\r
self._seq = 0\r
self.fail_on_create: bool = False\r
\r
def create_user(self, email: str, password: str, display_name: str) -> str:\r
if self.fail_on_create:\r
raise RuntimeError("create_user failed (fake)")\r
self._seq += 1\r
uid = f"uid-{self._seq}"\r
rec = {"uid": uid, "email": email, "display_name": display_name}\r
self.created.append(rec)\r
return uid\r
\r
def delete_user(self, uid: str) -> None:\r
self.deleted.append(uid)\r
```\r
\r
### Repository Fake\r
\r
```python\r
class FakeUsersRepo:\r
"""Fake implementation of UsersRepository."""\r
def __init__(self) -> None:\r
self.users: Dict[str, Dict[str, Any]] = {}\r
self.fail_on_upsert: bool = False\r
\r
def upsert_user_doc(self, uid: str, data: Dict[str, Any]) -> None:\r
if self.fail_on_upsert:\r
raise RuntimeError("upsert_user_doc failed (fake)")\r
self.users[uid] = dict(data)\r
\r
def list_users(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:\r
items = list(self.users.values())\r
if limit and limit > 0:\r
items = items[:limit]\r
return [dict(it) for it in items]\r
```\r
\r
### Controlled Failure Fakes\r
\r
```python\r
class FakeAuth:\r
def __init__(self) -> None:\r
self.fail_on_create: bool = False # Control failure in tests\r
\r
def create_user(self, email: str, password: str, display_name: str) -> str:\r
if self.fail_on_create:\r
raise RuntimeError("create_user failed (fake)")\r
# ... rest of implementation\r
```\r
\r
### Nested Repository Fakes\r
\r
```python\r
class FakeSectorsRepo:\r
def __init__(self, institutions: FakeInstitutionsRepo | None = None) -> None:\r
self.institutions = institutions # Inject dependency\r
self.data: Dict[str, Dict[str, Dict[str, Any]]] = {}\r
\r
def institution_exists(self, institution_id: str) -> bool:\r
return bool(self.institutions and institution_id in self.institutions.data)\r
\r
def upsert_sector(self, institution_id: str, sector_id: str, data: Dict[str, Any]) -> None:\r
self.data.setdefault(institution_id, {})[sector_id] = dict(data)\r
```\r
\r
## Fixtures\r
\r
### Basic Fixture (conftest.py)\r
\r
```python\r
# tests/conftest.py\r
import pytest\r
from tests.fakes import FakeAuth, FakeUsersRepo\r
\r
@pytest.fixture()\r
def fake_auth():\r
"""Provide a fresh FakeAuth for each test."""\r
return FakeAuth()\r
\r
@pytest.fixture()\r
def fake_users_repo():\r
"""Provide a fresh FakeUsersRepo for each test."""\r
return FakeUsersRepo()\r
```\r
\r
### Fixture with Dependency Injection\r
\r
```python\r
@pytest.fixture()\r
def fake_sectors_repo(fake_institutions_repo):\r
"""FakeSectorsRepo depends on FakeInstitutionsRepo."""\r
return FakeSectorsRepo(institutions=fake_institutions_repo)\r
\r
@pytest.fixture()\r
def fake_rooms_repo(fake_sectors_repo):\r
"""FakeRoomsRepo depends on FakeSectorsRepo."""\r
return FakeRoomsRepo(sectors=fake_sectors_repo)\r
```\r
\r
### Environment Fixture\r
\r
```python\r
@pytest.fixture()\r
def user_env(fake_auth, fake_users_repo):\r
"""Provide service and all dependencies for user operations."""\r
from myapp.services.user_service import UserService\r
svc = UserService(fake_auth, fake_users_repo)\r
return svc, fake_auth, fake_users_repo\r
```\r
\r
### Seeded Environment Fixture\r
\r
```python\r
@pytest.fixture()\r
def user_env_seeded(user_env):\r
"""Environment with pre-seeded data."""\r
svc, auth, repo = user_env\r
svc.add_user(email="[email protected]", password="secret", name="Test User")\r
return svc\r
```\r
\r
### Fixture with Cleanup\r
\r
```python\r
@pytest.fixture()\r
def temp_file():\r
"""Provide a temporary file and clean up after test."""\r
import tempfile\r
import os\r
fd, path = tempfile.mkstemp()\r
os.close(fd)\r
yield path\r
os.unlink(path)\r
```\r
\r
## Service Layer Testing\r
\r
### Basic AAA Pattern Test\r
\r
```python\r
# tests/test_user_service.py\r
import pytest\r
from myapp.services.user_service import UserService\r
\r
def test_add_user_success(fake_auth, fake_users_repo):\r
# Arrange\r
svc = UserService(fake_auth, fake_users_repo)\r
email = "[email protected]"\r
password = "secret"\r
name = "Test User"\r
\r
# Act\r
result = svc.add_user(email=email, password=password, name=name)\r
\r
# Assert\r
assert result["status"] == "ok"\r
assert result["user"]["email"] == email\r
assert result["user"]["name"] == name\r
assert result["uid"] in fake_users_repo.users\r
```\r
\r
### Parametrized Tests\r
\r
```python\r
@pytest.mark.parametrize(\r
"email,password,name,role",\r
[\r
("[email protected]", "secret", "Alice", "admin"),\r
("[email protected]", "p@ss", "Bob", "user"),\r
],\r
)\r
def test_add_user_parametrized(user_env, email, password, name, role):\r
svc, _auth, _repo = user_env\r
\r
# Act\r
res = svc.add_user(email=email, password=password, name=name, global_role=role)\r
\r
# Assert\r
assert res["status"] == "ok"\r
assert res["user"]["email"] == email\r
assert res["user"]["name"] == name\r
assert res["user"]["globalRole"] == role\r
```\r
\r
### Testing Error Scenarios with Fakes\r
\r
```python\r
@pytest.mark.parametrize("email", ["[email protected]", "[email protected]"])\r
def test_add_user_rollback_on_firestore_failure(fake_auth, fake_users_repo, email):\r
# Arrange\r
fake_users_repo.fail_on_upsert = True\r
svc = UserService(fake_auth, fake_users_repo)\r
\r
# Act & Assert\r
with pytest.raises(RuntimeError):\r
svc.add_user(email=email, password="secret", name="Bob")\r
\r
# Assert rollback\r
assert fake_auth.deleted, "Expected auth user to be deleted on Firestore failure"\r
```\r
\r
### Testing Timestamp Normalization\r
\r
```python\r
def test_list_users_normalizes_timestamps_to_iso(user_env):\r
# Arrange\r
svc, _auth, repo = user_env\r
from datetime import datetime\r
repo.users["u1"] = {\r
"id": "u1",\r
"email": "[email protected]",\r
"name": "X",\r
"globalRole": "user",\r
"createdAt": datetime(2024, 1, 1),\r
"updatedAt": datetime(2024, 1, 2),\r
}\r
\r
# Act\r
res = svc.list_users(limit=10)\r
\r
# Assert\r
assert res["status"] == "ok"\r
assert res["count"] == 1\r
user = res["users"][0]\r
assert isinstance(user["createdAt"], str)\r
assert isinstance(user["updatedAt"], str)\r
```\r
\r
## Contract Testing\r
\r
### MCP Tool Registration Contract\r
\r
Test that controllers properly register tools with expected signatures:\r
\r
```python\r
# tests/test_controllers_contract.py\r
from typing import Any, Callable, Dict\r
\r
class FakeMCP:\r
"""Minimal FakeMCP for contract testing."""\r
def __init__(self) -> None:\r
self.tools: Dict[str, Callable[..., Any]] = {}\r
self.meta: Dict[str, Dict[str, Any]] = {}\r
\r
def tool(self, name: str, description: str, tags: Optional[set] = None, meta: Optional[dict] = None):\r
def decorator(fn: Callable[..., Any]):\r
self.tools[name] = fn\r
self.meta[name] = {\r
"description": description,\r
"tags": set(tags or set()),\r
"meta": dict(meta or {}),\r
}\r
return fn\r
return decorator\r
\r
\r
class FakeUserService:\r
"""Simple fake service that records calls."""\r
def __init__(self):\r
self.calls = []\r
\r
def add_user(self, **kwargs):\r
self.calls.append(("add_user", kwargs))\r
return {"status": "ok", "op": "add_user", "args": kwargs}\r
\r
\r
def test_users_controller_contract():\r
# Arrange\r
from myapp.controllers.users_controller import UsersController\r
fake = FakeMCP()\r
svc = FakeUserService()\r
UsersController(fake, svc)\r
\r
# Assert tool registration\r
assert "add_user" in fake.tools\r
assert "list_users" in fake.tools\r
\r
# Act & Assert tool behavior\r
res = fake.tools["add_user"](\r
email="[email protected]", password="s3cr3t", name="Alice", global_role="admin"\r
)\r
assert res["status"] == "ok"\r
assert res["op"] == "add_user"\r
assert res["args"]["email"] == "[email protected]"\r
```\r
\r
### Parametrized Contract Tests\r
\r
```python\r
@pytest.mark.parametrize(\r
"email,password,name,role",\r
[\r
("[email protected]", "s3cr3t", "Alice", "admin"),\r
("[email protected]", "p@ssw0rd", "Bob", "user"),\r
],\r
)\r
def test_users_add_user_parametrized(_users_env, email, password, name, role):\r
# Arrange\r
fake, _ = _users_env\r
\r
# Act\r
res = fake.tools["add_user"](\r
email=email, password=password, name=name, global_role=role\r
)\r
\r
# Assert\r
assert res["status"] == "ok"\r
assert res["op"] == "add_user"\r
assert res["args"]["email"] == email\r
```\r
\r
## Repository Layer Testing\r
\r
### Testing Repository Operations\r
\r
```python\r
@pytest.fixture()\r
def repo_env(fake_institutions_repo, fake_sectors_repo):\r
# Seed data\r
fake_institutions_repo.upsert("inst1", {"id": "inst1", "name": "Inst One"})\r
fake_sectors_repo.upsert_sector(\r
"inst1", "er", {"id": "er", "name": "ER", "slug": "er", "isActive": True}\r
)\r
return fake_sectors_repo\r
```\r
\r
### Testing Multiple Data Scenarios\r
\r
```python\r
@pytest.mark.parametrize("rooms", [\r
["101"],\r
["201", {"name": "102", "id": "room-102"}],\r
])\r
def test_add_and_list_rooms(room_env, rooms):\r
svc, _ = room_env\r
\r
# Act\r
res = svc.add_sector_rooms("inst1", "er", rooms)\r
\r
# Assert\r
assert res["status"] == "ok"\r
assert res["count"] == len(rooms)\r
\r
lst = svc.list_sector_rooms("inst1", "er", limit=10)\r
assert lst["status"] == "ok"\r
assert lst["count"] == len(rooms)\r
```\r
\r
### Testing Limit Behavior\r
\r
```python\r
@pytest.mark.parametrize("limit", [1, 3])\r
def test_list_rooms_limits(room_env_seeded, limit):\r
svc = room_env_seeded\r
\r
# Act\r
lst = svc.list_sector_rooms("inst1", "er", limit=limit)\r
\r
# Assert\r
assert lst["status"] == "ok"\r
assert lst["count"] == min(2, limit) # 2 items seeded\r
```\r
\r
### Testing Not Found Scenarios\r
\r
```python\r
@pytest.mark.parametrize("room_id,deleted", [\r
("room-102", True),\r
("room-999", False),\r
])\r
def test_remove_rooms_parametrized(room_env_seeded, room_id, deleted):\r
svc = room_env_seeded\r
\r
# Act\r
res = svc.remove_sector_room("inst1", "er", room_id)\r
\r
# Assert\r
assert res["deleted"] is deleted\r
if not deleted:\r
assert res.get("reason") == "room_not_found"\r
```\r
\r
## Integration Testing\r
\r
### Conditional Integration Tests\r
\r
Skip integration tests when external dependencies are not available:\r
\r
```python\r
# tests/test_integration_wiring.py\r
import os\r
import pytest\r
\r
# Gate this integration test on presence of credentials\r
_ENV_KEYS = (\r
"FIREBASE_SERVICE_ACCOUNT",\r
"GOOGLE_APPLICATION_CREDENTIALS",\r
)\r
_has_env_creds = any(os.getenv(k) for k in _ENV_KEYS)\r
\r
pytestmark = [\r
pytest.mark.integration,\r
pytest.mark.skipif(\r
not _has_env_creds,\r
reason=(\r
"Integration test requires Firebase Admin credentials via env "\r
"(FIREBASE_SERVICE_ACCOUNT or GOOGLE_APPLICATION_CREDENTIALS)"\r
),\r
),\r
]\r
\r
@pytest.mark.integration\r
def test_build_app_initializes_and_registers_tools():\r
# Arrange\r
from myapp.wiring import build_app\r
\r
# Act\r
app = build_app()\r
\r
# Assert\r
assert hasattr(app, "run")\r
```\r
\r
### Test Isolation\r
\r
Each test should be independent and not share state:\r
\r
```python\r
def test_user_created_in_one_test_not_visible_in_another(fake_auth, fake_users_repo):\r
# Arrange\r
svc1 = UserService(fake_auth, fake_users_repo)\r
\r
# Act\r
result1 = svc1.add_user(email="[email protected]", password="secret", name="User1")\r
\r
# Assert - second test with fresh fixtures should not see this user\r
svc2 = UserService(fake_auth, fake_users_repo)\r
users = svc2.list_users()\r
assert users["count"] == 1 # Only the user from this test\r
```\r
\r
## Testing Anti-Patterns to Avoid\r
\r
### Don't Mock What You Don't Own\r
\r
❌ Bad - Mocking external library:\r
\r
```python\r
@patch('firebase_admin.auth.create_user')\r
def test_add_user(mock_create_user):\r
mock_create_user.return_value = Mock(uid="uid-1")\r
# ... test code\r
```\r
\r
✅ Good - Use Fake for your interface:\r
\r
```python\r
def test_add_user(fake_auth, fake_users_repo):\r
svc = UserService(fake_auth, fake_users_repo)\r
# ... test code\r
```\r
\r
### Don't Test Implementation Details\r
\r
❌ Bad - Testing internal method calls:\r
\r
```python\r
def test_add_user(fake_auth, fake_users_repo):\r
svc = UserService(fake_auth, fake_users_repo)\r
svc.add_user(email="[email protected]", password="secret", name="User")\r
assert fake_auth.created == [{"uid": "uid-1", ...}] # Implementation detail\r
```\r
\r
✅ Good - Testing observable behavior:\r
\r
```python\r
def test_add_user(fake_auth, fake_users_repo):\r
svc = UserService(fake_auth, fake_users_repo)\r
result = svc.add_user(email="[email protected]", password="secret", name="User")\r
assert result["status"] == "ok"\r
assert result["user"]["email"] == "[email protected]"\r
```\r
\r
### Don't Skip Error Paths\r
\r
❌ Bad - Only happy path:\r
\r
```python\r
def test_add_user_success(fake_auth, fake_users_repo):\r
# Only tests success case\r
```\r
\r
✅ Good - Test all scenarios:\r
\r
```python\r
def test_add_user_success(fake_auth, fake_users_repo):\r
# Happy path\r
\r
def test_add_user_rollback_on_firestore_failure(fake_auth, fake_users_repo):\r
# Error path\r
\r
def test_add_user_handles_duplicate_email(fake_auth, fake_users_repo):\r
# Edge case\r
```\r
\r
## Running Tests\r
\r
```bash\r
# Run all tests\r
pytest\r
\r
# Run with coverage\r
pytest --cov=myapp --cov-report=term-missing\r
\r
# Run specific test file\r
pytest tests/test_user_service.py\r
\r
# Run specific test\r
pytest tests/test_user_service.py::test_add_user_success\r
\r
# Run parametrized tests with verbose output\r
pytest -v tests/test_user_service.py::test_add_user_parametrized\r
\r
# Skip integration tests\r
pytest -m "not integration"\r
\r
# Run only integration tests\r
pytest -m integration\r
\r
# Stop on first failure\r
pytest -x\r
\r
# Show local variables on failure\r
pytest -l\r
\r
# Run tests in parallel (with pytest-xdist)\r
pytest -n auto\r
```\r
\r
## Best Practices Checklist\r
\r
- [ ] Use Fake classes instead of `unittest.mock`\r
- [ ] Structure tests with explicit AAA comments\r
- [ ] Use fixtures for test setup\r
- [ ] Inject dependencies between fixtures\r
- [ ] Parametrize tests for multiple scenarios\r
- [ ] Test happy paths and error paths\r
- [ ] Test edge cases and boundaries\r
- [ ] Write contract tests for interfaces\r
- [ ] Ensure test isolation\r
- [ ] Use descriptive test names\r
- [ ] Keep tests focused on one behavior\r
- [ ] Avoid testing implementation details\r
- [ ] Test at appropriate level (unit vs integration)\r
- [ ] Mock external dependencies appropriately\r
- [ ] Maintain test coverage\r
- 确保已安装 OpenClaw(本地或 Docker 部署)
- 在对话框中输入安装命令:
/install clean-pytest - 安装完成后,直接呼叫该 Skill 的名称或使用
/clean-pytest触发 - 根据 Skill 的参数说明提供必要输入,即可获得结构化输出
Clean Pytest 是什么?
Write clean, maintainable pytest tests using Fake-based testing, contract testing, and dependency injection patterns. Use when setting up test suites for Pyt... 它是一个面向 Claude Code / OpenClaw 的 AI Agent Skill 插件,目前累计下载 826 次。
如何安装 Clean Pytest?
在 OpenClaw 或 Claude Code 对话框中运行命令「/install clean-pytest」即可一键安装,无需额外配置。
Clean Pytest 是免费的吗?
是的,Clean Pytest 完全免费(开源免费),可自由下载、安装和使用。
Clean Pytest 支持哪些平台?
Clean Pytest 跨平台运行,可在任意部署了 OpenClaw / Claude Code 的环境中使用(cross-platform)。
谁开发了 Clean Pytest?
由 Marco Borges(@marcoracer)开发并维护,当前版本 v0.1.0。