프로젝트 테스트 패턴 레퍼런스. Use when: 테스트 작성, 테스트 코드 짜기, 테스트 구조 잡기, 테스트 어떻게 써, conftest 설정, 픽스처 만들기, fixture 구성, db_session 픽스처, 도메인 유닛 테스트, 서비스 테스트, mock repository, AsyncMock, 통합 테스트, API 테스트, httpx AsyncClient, TestClient, testcontainers, 실제 PostgreSQL 테스트, 테스트 DB, 커버리지, coverage 설정, pytest 설정, asyncio_mode, 테스트 격리, 트랜잭션 롤백, 테스트별 독립. NOT for: pytest 기본 문법, assert 사용법.
Install
npx skillscat add nomik94/claude-code-preset/testing Install via the SkillsCat registry.
SKILL.md
Testing Skill
Test Pyramid
Unit (domain, no DB) --> Fast, pure Python 3.13+
├── test_order_entity.py Domain rules, state transitions
├── test_user_entity.py Value object validation
└── test_application_service.py Mock repository, use case flows
Integration (API) --> Real DB (SQLite or testcontainers)
├── test_users_controller.py Full HTTP cycle
└── test_orders_controller.py
E2E --> External services includedTest Naming Convention
MUST follow Conventional Commits style prefix in test docstrings/comments when relevant:
| Prefix | Usage |
|---|---|
test: |
새 테스트 추가 커밋 |
fix: |
깨진 테스트 수정 커밋 |
refactor: |
테스트 구조 개선 커밋 |
Test class/method naming:
class TestOrder{Action}: # e.g., TestOrderAddItem
def test_{scenario}(self): ... # e.g., test_adds_item_increases_count
def test_{condition}_raises(self): ...Directory Structure
tests/
├── conftest.py # Shared fixtures (engine, session, client)
├── unit/
│ ├── domain/ # Pure domain tests (no DB, no mock)
│ └── application/ # Service tests (mock repo)
├── integration/
│ └── controllers/ # API tests (real DB)
└── e2e/conftest.py
TEST_DB_URL = "sqlite+aiosqlite:///./test.db"
@pytest.fixture(scope="session")
async def test_engine():
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
@pytest.fixture
async def db_session(test_engine):
"""Per-test transaction rollback isolation."""
factory = async_sessionmaker(test_engine, class_=AsyncSession)
async with factory() as session:
async with session.begin():
yield session
await session.rollback()
@pytest.fixture
async def client(db_session):
app = create_app()
app.dependency_overrides[get_db] = lambda: db_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()Domain Unit Test (No DB)
class TestOrderAddItem:
def test_adds_item_increases_count(self):
order = make_order()
order.add_item(1, "Product A", 2, Money.won(10000), 100)
assert order.item_count == 1 and order.subtotal == Money.won(20000)
def test_insufficient_stock_raises(self):
order = make_order()
with pytest.raises(InsufficientStockException):
order.add_item(1, "Product A", 10, Money.won(10000), stock=5)
def test_records_domain_event(self):
order = make_order()
order.add_item(1, "Product A", 2, Money.won(10000), 100)
assert isinstance(order.pull_domain_events()[0], OrderItemAddedEvent)Application Service Test (Mock Repo)
@pytest.fixture
def service(mock_order_repo, mock_product_repo, mock_user_repo):
return OrderApplicationService(
db=AsyncMock(), order_repo=mock_order_repo,
product_repo=mock_product_repo, user_repo=mock_user_repo,
pricing_service=OrderPricingService(),
validation_service=OrderValidationService(),
)
class TestOrderApplicationService:
async def test_create_order_success(self, service, mock_order_repo):
order = await service.create_order(user_id=1, request=CreateOrderRequest(...))
assert order.status == OrderStatus.PENDING
mock_order_repo.save.assert_called_once()
async def test_other_user_access_denied(self, service, mock_order_repo):
existing = Order.create(user_id=99, shipping_address=MagicMock())
existing.id = 1
mock_order_repo.find_by_id.return_value = existing
with pytest.raises(OrderOwnershipException):
await service.get_order(user_id=1, order_id=1)Integration Test (API via controllers/)
class TestUsersController:
async def test_create_user_201(self, client):
resp = await client.post("/api/v1/users", json={
"name": "Test", "email": "test@example.com",
"password": "Test1234!", "passwordConfirm": "Test1234!",
})
assert resp.status_code == 201
assert "password" not in resp.json()
async def test_unauthenticated_401(self, client):
resp = await client.get("/api/v1/users/me")
assert resp.status_code == 401testcontainers (Real PostgreSQL)
@pytest.fixture(scope="session")
async def test_engine():
with PostgresContainer("postgres:17") as pg:
url = pg.get_connection_url().replace("psycopg2", "asyncpg")
engine = create_async_engine(url)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield enginepytest Configuration
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = "-v --tb=short --strict-markers"
markers = ["slow: marks tests as slow", "integration: marks integration tests"]Key Rules
- MUST use
asyncio_mode = "auto"-- no manual@pytest.mark.asyncioneeded - MUST isolate each test with transaction rollback
- MUST NOT import framework modules in domain unit tests
- MUST use
AsyncMockfor async repository mocks - MUST place integration tests under
tests/integration/controllers/ - MUST name test files matching controller files:
test_{name}_controller.py - MUST use Python 3.13+ syntax (builtin generics,
X | None, etc.)
Verification Checklist
Before declaring tests complete:
-
poetry run pytestpasses with exit code 0 -
poetry run pytest --co -qshows expected test count -
poetry run ruff check tests/has no errors -
poetry run mypy tests/passes (if strict mode enabled for tests) - No hardcoded secrets or credentials in test code
- Each test is independent -- no ordering dependency
- Domain tests have zero DB/framework imports
- Mock objects use
spec=parameter for type safety