“I know I should write tests, but I don't know where or how to start.”
Many developers face this question. Testing is more than simply “catching bugs.” It is a cost-effective risk-management mechanism that makes changes fast and safe.
This post goes beyond concepts to cover practical patterns you can apply immediately in FastAPI and Next.js.
1. The Three Layers of Testing
From a system-architecture perspective, tests fall into three layers.
| Layer | Target | Characteristics |
|---|---|---|
| Unit tests | Functions and classes | Fast; easy to locate problems |
| Integration tests | Interactions between modules | API + DB, component + API |
| E2E tests | The whole system | Verify user scenarios; slow |
In addition, static analysis with TypeScript, ESLint, or mypy catches problems without running the code. It prevents many bugs before tests are written, offering the best return for the cost.
Trade-offs Between Layers
| Characteristic | Unit | Integration | E2E |
|---|---|---|---|
| Execution speed | Fast | Medium | Slow |
| Maintenance cost | Low | Medium | High |
| Diagnosing failures | Easy | Medium | Difficult |
| Fidelity to real behavior | Low | Medium | High |
When an E2E test fails, you know something is wrong but may struggle to pinpoint it. Unit tests identify the failure point clearly, making fixes faster.
2. Pyramid vs. Trophy: Which Strategy Should You Choose?
There are two philosophies for deciding how many tests to write and where to put them.
The Testing Pyramid
/\
/ \ E2E (적게)
/────\
/ \ Integration (중간)
/────────\
/ \ Unit (많이)
──────────────
The idea: if the small units are verified thoroughly, the whole system will work.
The Testing Trophy
/\ E2E
/ \
/────\
/ \
/ Integration \ ← 가장 두꺼움
/───────────────\
│ Unit │
│───────────────│
│ Static │
─────────────────
The idea: tests are trustworthy when they behave like users.
Kent C. Dodds proposed this in the frontend context. Testing components in isolation can make tests less representative of reality.
Practical Selection Criteria
| Category | Pyramid | Trophy |
|---|---|---|
| Core value | Fast feedback | High confidence |
| Largest share | Unit | Integration |
| Best suited to | Complex business logic (backend) | UI and state management (frontend) |
Conclusion: use the pyramid for the backend and the trophy for the frontend.
3. TDD: Red-Green-Refactor
TDD is not “write lots of tests.” It is a development method that first fixes the requirements as an executable specification.
Red (실패) → Green (통과) → Refactor (개선) → 반복
- Red: write a failing test first.
- Green: write the minimum code needed to pass it.
- Refactor: improve the structure while keeping tests passing.
The Real Value of TDD
- Requirements are fixed as an executable specification
- Refactoring becomes safer
- It naturally enforces separation of boundaries through dependency injection and interface separation
The key is **“minimum.”** During Green, do not try to make the code beautiful. Just make the test pass.
4. Practical Backend TDD Patterns
Features with many external integrations and DB operations need clear layers for TDD to work.
Dependency Flow
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Router │ ───▶ │ Protocol │ ◀─── │ Fake │
│ (ai.py) │ │ (인터페이스) │ │ (테스트용) │
└──────────────┘ └──────────────┘ └──────────────┘
│
▼
┌──────────────┐
│ Service │
│ (구현체) │
└──────────────┘
│
▼
┌──────────────┐
│ External │
│ (DB/API) │
└──────────────┘
| Layer | Role | Test strategy |
|---|---|---|
| Routers | HTTP requests and responses | Integration (Fake DI) |
| Dependencies | Protocol + DI | Unit (inject a Fake) |
| Services | Business logic | Unit (Protocol mock) |
| External | DB/Firebase integration | E2E (real services) |
| Fakes | Implementations for Testing | - |
Test Double: Stub, Fake, Mock
A substitute object used in place of a real one during testing is called a Test Double.
Stub—returns predefined values only
class StubProductRepo:
def get_by_id(self, id):
return Product(id=1, price=10000) # 항상 같은 값Fake—a simple implementation that behaves like the real thing
class FakeUserRepo:
def __init__(self):
self.users = {}
self.next_id = 1
async def save(self, user):
user.id = self.next_id
self.users[self.next_id] = user
self.next_id += 1
return user
async def get_by_id(self, id):
return self.users.get(id)Mock—verifies whether calls occurred
def test_주문시_이메일_발송():
mock_email = Mock()
service = OrderService(email_service=mock_email)
service.create_order(user_email="test@test.com")
mock_email.send.assert_called_once()How to Choose
| Situation | Choice |
|---|---|
| If you only need a value | Stub |
| If you need save and retrieve behavior | Fake |
| If you need to verify a call | Mock |
Important: a Fake does not exist to replicate the DB. It only needs to satisfy the repository contract—the meaning of its methods. Verify DB constraints and transactions against a real DB in integration tests.
Defining Interfaces with Protocol
Python's typing.Protocol lets you define interfaces without explicit inheritance.
# app/dependencies/protocol.py
from typing import Protocol, Optional
class UserRepository(Protocol):
async def get_by_id(self, user_id: int) -> Optional[User]:
...
async def save(self, user: User) -> User:
...# app/external/repositories.py (실제 구현)
class SqlAlchemyUserRepository:
def __init__(self, session: AsyncSession):
self.session = session
async def get_by_id(self, user_id: int) -> Optional[User]:
result = await self.session.execute(
select(User).where(User.id == user_id)
)
return result.scalars().first()# tests/fakes/fake_user_repo.py (테스트용)
class FakeUserRepository:
def __init__(self):
self._storage = {}
self._current_id = 0
async def get_by_id(self, user_id: int) -> Optional[User]:
return self._storage.get(user_id)Testing with dependency_overrides
# 프로덕션 코드
def get_user_repository():
return SqlAlchemyUserRepository(session)
@app.post("/users")
async def create_user(
data: UserCreate,
repo: UserRepository = Depends(get_user_repository)
):
# ...# 테스트 코드
@pytest.mark.asyncio
async def test_create_user():
fake_repo = FakeUserRepository()
app.dependency_overrides[get_user_repository] = lambda: fake_repo
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.post("/users", json={"email": "test@test.com"})
assert response.status_code == 201
# Fake 내부 상태 직접 검증 가능
saved_user = await fake_repo.get_by_id(1)
assert saved_user.email == "test@test.com"
app.dependency_overrides.clear()Test Every Rule, Not Every State
If an order has 10 states, must you test all 100 combinations (10×10)? No.
# 상태 전이 규칙을 한 곳에 정의
VALID_TRANSITIONS = {
"pending": ["approved", "rejected"],
"approved": ["shipped", "cancelled"],
"shipped": ["delivered"],
}
# 허용된 전이만 테스트
@pytest.mark.parametrize("from_status,to_status", [
("pending", "approved"),
("approved", "shipped"),
])
def test_유효한_상태_전이(from_status, to_status):
order = Order(status=from_status)
order.transition_to(to_status)
assert order.status == to_status
# 대표적인 불가능한 전이만 테스트
def test_배송완료_후_취소_불가():
order = Order(status="delivered")
with pytest.raises(InvalidTransitionError):
order.transition_to("cancelled")Even as states increase, you only need to update the rule table.
5. Frontend TDD: The Honest Reality and Where It Works
The Honest Reality
TDD is not always effective on the frontend.
- UI development is exploratory and changes continually with design feedback.
- “How it looks” is difficult to test.
- The browser provides faster feedback.
Where TDD Is Effective
TDD 적용 ✓ Test-After ✓ 테스트 안 함 ✓
────────────── ────────────── ──────────────
• 유틸리티 함수 • UI 컴포넌트 • 단순 프레젠테이션
• 커스텀 훅 • 탐색적 개발 • 스타일만 있는 컴포넌트
• 버그 수정 • 자주 바뀌는 부분
Mocking APIs with MSW
// mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.post('/api/login', async ({ request }) => {
const body = await request.json()
if (body.email === 'test@test.com') {
return HttpResponse.json({ token: 'fake-token' })
}
return HttpResponse.json({ error: 'Invalid' }, { status: 401 })
})
]// LoginForm.test.tsx
test('로그인 성공 시 대시보드로 이동', async () => {
render(<LoginForm />)
await userEvent.type(screen.getByLabelText('이메일'), 'test@test.com')
await userEvent.type(screen.getByLabelText('비밀번호'), 'password')
await userEvent.click(screen.getByRole('button', { name: '로그인' }))
await waitFor(() => {
expect(mockRouter.push).toHaveBeenCalledWith('/dashboard')
})
})The Core Principle of Testing Library
Test like a user.
// ❌ 구현 세부사항 테스트
expect(component.state.isLoading).toBe(true)
// ✅ 사용자가 보는 것 테스트
expect(screen.getByText('로딩 중...')).toBeInTheDocument()// ❌ 내부 함수 호출 테스트
expect(handleClick).toHaveBeenCalled()
// ✅ 결과 테스트
expect(screen.getByText('1')).toBeInTheDocument() // 카운트 증가 확인Configuring MSW in Next.js 15
Server (instrumentation.ts):
export async function register() {
if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { server } = await import('./mocks/server')
server.listen({ onUnhandledRequest: 'bypass' })
}
}
}Client (MSWProvider):
'use client'
import { useEffect, useState } from 'react'
export function MSWProvider({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false)
useEffect(() => {
const init = async () => {
if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') {
const { worker } = await import('@/mocks/browser')
await worker.start({ onUnhandledRequest: 'bypass' })
}
setReady(true)
}
init()
}, [])
if (!ready) return null
return <>{children}</>
}6. Data and Environment Strategy
In full-stack development, tests usually fall apart because of data and environments, rather than code.
Separate Base Data into Two Types
| Type | Description | How to manage it |
|---|---|---|
| Reference Seed | Lookup data such as status and permission codes | Load the same versioned seeds locally and in CI |
| Business Data | Business data such as users and products | Generate with factories or fixtures for each test |
Strategy by Environment
| Environment | Strategy |
|---|---|
| Local | Fast tests (external stubs, local DB) + Static analysis |
| CI (PR) | Unit tests + Short integration tests + Essential smoke tests only |
| Nightly/QA | Heavy integration tests + Core E2E tests |
| Production | Observability, gradual rollout, and rollback strategies rather than tests |
7. Three Antipatterns to Avoid
1. Too Many E2E Tests (The Ice Cream Cone)
- Slow and flaky
- A nightmare to diagnose
- Declining trust in CI
2. Mock-Heavy Tests
- Testing the implementation means every refactor also requires test changes
- Tests become a burden rather than an asset
3. Local Development Shares a dev/qa Database
- Development mistakes corrupt data
- Test reproducibility breaks down
- Permission and security problems increase operational risk
8. Example Project Structure
Backend (FastAPI)
app/
├── config/ # 설정
├── dependencies/ # 의존성 주입
│ ├── protocol.py # 인터페이스 정의
│ ├── auth.py # 인증 의존성
│ └── database.py # DB 세션
├── services/ # 비즈니스 로직
├── external/ # 외부 연동 (DB, Firebase)
├── models/ # SQLAlchemy 모델
├── schemas/ # Pydantic 스키마
└── routers/ # API 엔드포인트
tests/
├── fakes/ # Fake 구현체
├── unit/ # 단위 테스트
├── integration/ # 통합 테스트 (DI 사용)
└── e2e/ # E2E 테스트 (실제 서비스)
Frontend (Next.js)
src/
├── components/
│ └── features/
│ └── LoginForm/
│ ├── LoginForm.tsx
│ └── LoginForm.test.tsx
├── hooks/
│ ├── useAuth.ts
│ └── useAuth.test.ts
└── lib/
└── utils/
├── format.ts
└── format.test.ts
mocks/
├── handlers.ts # MSW 핸들러
├── browser.ts # 클라이언트용
└── server.ts # 서버용
tests/
└── e2e/ # Playwright
9. Practical Checklist
Backend
- External integrations (DB, APIs, email) are separated behind interfaces (Protocol)
- Dependency injection allows replacement with Fakes or Mocks in tests
- Business logic is separated into the Service layer
- State-transition rules are defined in one place
- Fakes implement only the contract rather than replicating the DB
Frontend
- Complex logic is separated into custom hooks or utility functions
- APIs are mocked with MSW
- Tests cover user behavior rather than implementation details
- Bug fixes begin with a reproduction test
Environment
- Fixed reference seeds and dynamic business data are separated
- CI keeps only smoke E2E coverage rather than adding it excessively
- Local development uses an isolated environment instead of a shared DB
Closing Thoughts
There is no single correct test strategy, but there is a direction.
- Backend: a pyramid structure, business-logic unit tests, and interfaces for external integrations
- Frontend: a trophy structure, emphasis on integration tests, and testing like a user
The most important question is:
“If this test passes, would I feel confident deploying?”
Write tests that give you that confidence.
Tools to Consider
| Environment | Test runner | Mocking | E2E |
|---|---|---|---|
| Python/FastAPI | pytest | unittest.mock, FakeRepository | - |
| Next.js/React | Vitest | MSW | Chrome Extension |
| TypeScript | Vitest | MSW | Chrome Extension. |





Comments
Korean and English pages share this conversation.
Loading comments…