Skip to content
FunDev
FunDev
tdd

Development and Testing: A Practical TDD Guide for Full-Stack Developers

Development and Testing: A Practical TDD Guide for Full-Stack Developers
30 views
10 min read
#tdd

“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.

LayerTargetCharacteristics
Unit testsFunctions and classesFast; easy to locate problems
Integration testsInteractions between modulesAPI + DB, component + API
E2E testsThe whole systemVerify 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

CharacteristicUnitIntegrationE2E
Execution speedFastMediumSlow
Maintenance costLowMediumHigh
Diagnosing failuresEasyMediumDifficult
Fidelity to real behaviorLowMediumHigh

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

CategoryPyramidTrophy
Core valueFast feedbackHigh confidence
Largest shareUnitIntegration
Best suited toComplex 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 (개선) → 반복
  1. Red: write a failing test first.
  2. Green: write the minimum code needed to pass it.
  3. 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)    │
                        └──────────────┘
LayerRoleTest strategy
RoutersHTTP requests and responsesIntegration (Fake DI)
DependenciesProtocol + DIUnit (inject a Fake)
ServicesBusiness logicUnit (Protocol mock)
ExternalDB/Firebase integrationE2E (real services)
FakesImplementations 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

SituationChoice
If you only need a valueStub
If you need save and retrieve behaviorFake
If you need to verify a callMock

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

TypeDescriptionHow to manage it
Reference SeedLookup data such as status and permission codesLoad the same versioned seeds locally and in CI
Business DataBusiness data such as users and productsGenerate with factories or fixtures for each test

Strategy by Environment

EnvironmentStrategy
LocalFast tests (external stubs, local DB) + Static analysis
CI (PR)Unit tests + Short integration tests + Essential smoke tests only
Nightly/QAHeavy integration tests + Core E2E tests
ProductionObservability, 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

EnvironmentTest runnerMockingE2E
Python/FastAPIpytestunittest.mock, FakeRepository-
Next.js/ReactVitestMSWChrome Extension
TypeScriptVitestMSWChrome Extension.

Related posts

Comments

Korean and English pages share this conversation.

Write a comment

0 / 5,000
You will need this password to edit or delete this comment.

Loading comments…