우리가 테스트를 안 쓰는 진짜 이유

TDD가 좋은 건 압니다. 정말로. 코드 품질도 올라가고, 리팩토링도 안전하고, 버그도 줄어들고, 회귀 버그도 방지되고.

이렇게 좋으면 모든 개발팀의 테스트 커버리지는 99%겠죠?

아니라고요? 왜일까요?

대부분의 개발팀에서 테스트 커버리지를 높게 유지하기는 어렵습니다. 심지어 "우리는 TDD를 한다"고 말하는 팀조차도요. 특히 스타트업처럼 빠르게 움직여야 하는 환경에서는 더욱 그렇습니다.

솔직히 말하면, 우리 팀에서도 테스트를 제대로 작성하는 경우는 많지 않습니다.

특히 SELECT 쿼리는 거의 테스트하지 않습니다. 왜일까요?

// 이런 복잡한 필터링 로직
async function getFilteredUsers(filters: UserFilters) {
  const query = db.users.query();
  
  if (filters.age) {
    query.where('age', '>=', filters.age.min);
    query.where('age', '<=', filters.age.max);
  }
  
  if (filters.status) {
    query.whereIn('status', filters.status);
  }
  
  if (filters.tags) {
    query.where('tags', 'overlaps', filters.tags);
  }
  
  if (filters.createdAfter) {
    query.where('created_at', '>=', filters.createdAfter);
  }
  
  return await query.execute();
}

이 함수의 구현 시간은 10분 정도입니다. 하지만 이걸 제대로 테스트하려면?

// 테스트를 위해 필요한 것들
test('complex user filtering', async () => {
  // 1. 나이 20-30세인 유저 3명
  await db.users.insert([
    { age: 25, status: 'active', tags: ['premium'], created_at: '2024-01-01' },
    { age: 28, status: 'active', tags: ['premium'], created_at: '2024-02-01' },
    { age: 22, status: 'inactive', tags: ['basic'], created_at: '2024-03-01' },
  ]);
  
  // 2. 나이 범위 밖인 유저들
  await db.users.insert([
    { age: 18, status: 'active', tags: ['premium'], created_at: '2024-01-01' },
    { age: 35, status: 'active', tags: ['premium'], created_at: '2024-01-01' },
  ]);
  
  // 3. status가 다른 유저들
  await db.users.insert([
    { age: 25, status: 'banned', tags: ['premium'], created_at: '2024-01-01' },
    { age: 25, status: 'pending', tags: ['premium'], created_at: '2024-01-01' },
  ]);
  
  // 4. tags가 다른 유저들
  await db.users.insert([
    { age: 25, status: 'active', tags: ['basic'], created_at: '2024-01-01' },
    { age: 25, status: 'active', tags: ['trial'], created_at: '2024-01-01' },
  ]);
  
  // 5. 날짜가 다른 유저들
  await db.users.insert([
    { age: 25, status: 'active', tags: ['premium'], created_at: '2023-12-01' },
  ]);
  
  // 이제 테스트 시작...
  const result = await getFilteredUsers({
    age: { min: 20, max: 30 },
    status: ['active'],
    tags: ['premium'],
    createdAfter: '2024-01-01'
  });
  
  expect(result).toHaveLength(2); // 정확히 2명이어야 함
});

픽스쳐 준비에만 30분, 엣지 케이스까지 고려하면 1시간이 훌쩍 넘어갑니다. 구현 10분짜리 함수를 위해서요.

이게 현실입니다. ROI가 맞지 않으니 테스트를 건너뛰게 됩니다. 그리고 나중에 버그가 나면... "아, 그때 테스트를 작성했어야 했는데"라고 후회하죠.

문제의 본질은 명확합니다: 우리는 함수의 리턴값만 검증할 수 있습니다. 함수 내부 깊숙한 곳에서 무슨 일이 일어나는지, 어떤 SQL 쿼리가 생성되는지, 중간 계산값이 뭔지 - 이런 건 볼 수 없습니다.

전통적 해결책: 함수를 쪼개라

TDD 교과서는 이렇게 말합니다: "함수를 작게 쪼개서 각각 테스트하라."

// "올바른" TDD 방식
class UserQueryBuilder {
  buildBaseQuery(): QueryBuilder {
    return db.users.query();
  }
  
  applyAgeFilter(query: QueryBuilder, age: AgeFilter): void {
    if (age.min) query.where('age', '>=', age.min);
    if (age.max) query.where('age', '<=', age.max);
  }
  
  applyStatusFilter(query: QueryBuilder, status: string[]): void {
    query.whereIn('status', status);
  }
  
  applyTagsFilter(query: QueryBuilder, tags: string[]): void {
    query.where('tags', 'overlaps', tags);
  }
  
  applyDateFilter(query: QueryBuilder, date: string): void {
    query.where('created_at', '>=', date);
  }
}

// 이제 각 메서드를 테스트
describe('UserQueryBuilder', () => {
  test('applyAgeFilter', () => { /* ... */ });
  test('applyStatusFilter', () => { /* ... */ });
  test('applyTagsFilter', () => { /* ... */ });
  test('applyDateFilter', () => { /* ... */ });
  test('integration', () => { /* ... */ });
});

이제 테스트는 작성할 수 있습니다. 하지만 새로운 문제가 생겼습니다:

  1. 함수가 5개로 늘었습니다 - 원래는 1개였는데
  2. 테스트가 5개 필요합니다 - 각 메서드 + 통합 테스트
  3. 픽스쳐도 5세트 - 각 테스트마다