Page history of 테스트를 위해 코드를 쪼개는 방법



Title: 테스트를 위해 코드를 쪼개는 방법 | edited by Youngrok Pak at 2 weeks ago.

이 글은 테스트를 위해 코드를 쪼개지 마세요: Naite와 함께하는 관찰 기반 테스팅을 읽고 쓰는 일종의 반론이다. 나는 저 글에서 유용한 포인트들이 몇 가지 제시되었다고 생각하지만, 한편으로는 오해를 불러일으킬 수 있는 내용들이 있어서 짚어보고자 한다. 마침 준비 중인 강의 내용과도 깊은 관련이 있어서 조금 풀어서 써 본다.

우선, 처음 예시로 나온 코드는 다음과 같다.

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();
}

그리고, 전통적인 해결책은 함수를 쪼개는 것이라면서 함수를 다음과 같이 5개로 쪼갠다.

// "올바른" 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', () => { /* ... */ });
});

내가 짚고 싶은 부분이 이 부분이다. 이것은 TDD에서 올바른 방법이라고 주장하고 있는 방법이 아니며, 전통적인 방법도 아니다. TDD의 창시자 Kent Beck이라면 저렇게 함수를 쪼개고 테스트를 작성하지 않을 것이고, 다른 XP의 창시자 론 제프리즈, 워드 커닝엄도 다른 관점을 취할 것이다.

우선, applyAgeFilter 같은 메서드들은 일종의 private method 성격을 띤다. 이 각각은 다른 곳에서 쓰일 가능성이 적다. 그런데 Xper들은 MethodsShouldBePublic의 관점을 선호한다. 메서드를 뽑아낸다면 다른 곳에서도 사용할 만한 방식으로 설계하고 싶어하는 것이다. 마침 이 메서드들 사이에는 강력한 공통점이 보인다. 파라미터로 들어온 필터 조건으로 where 조건절을 생성하는 것이다. 아마도 이와 매우 비슷한 코드가 여기저기서 필드명만 바뀐 채로 나타나고 있을 것이다. 이것은 중복 코드다. 이 두 가지 관점으로 보면 좀더 범용적인 쿼리 빌더 테스트를 먼저 작성할 수 있다. 

date 필터링하는 코드를 보자. 여기서 created_at의 존재는 이 필터링 코드가 한 필드에 대해서만 사용될 수 있게 제한하고 있다. 그런데 만약 이 상수를 인자로 만든다면 어떨까? 그리고 >= 대신 >, <= 등도 받을 수 있게 하면 어떨까? 다음과 같은 코드가 될 것이다.

  applyFilter(query: QueryBuilder, field: string, lookup: string, date: string): void {
    query.where(field, lookup, date);
  }

그러면 이제 이 함수는 UserQueryBuilder에 있을 필요도 없어진다. date 타입에 대해 동작하는 범용적인 필터가 되기 때문에 그냥 QueryBuilder에 들어갈 수 있다. method overloading이 가능한 언어라면 타입만 달라지고 이름은 다 applyFilter로 통일하여 다형성을 얻을 수 있다. 다른 타입들과 where 조건들도 다 비슷하게 만들 수 있을 것이다. 그러면 첫번째 코드는 다음과 같이 고칠 수 있을 것이다.

async function getFilteredUsers(filters: UserFilters) {
  const query = db.users.query();
for (filter of filters.filters) {
queryBuilder.applyFilter(query, filter.field, filter.lookup, filter.value)
}
return await query.execute()

이런 방향을 좀더 극단적으로 하면 Django Rest Framework에서 django-filters를 사용하는 케이스가 예시로 좋다.

class UserViewSet(ModelViewSet):
queryset = User.objects.all()
serializer_class = UserSerializer
filterset_fields = {'age': ['gt', 'lt'], 'status': ['in'], 'tags': ['in'], 'created': ['gte']}

이러고 이 UserViewSet/user/ 같은 주소에 매핑시켜두면 /user/?age__gt=30&status__in=activated 같은 주소로 요청이 들어왔을 때 쿼리스트링을 파싱하고 그에 따라 DB에서 쿼리한 다음 응답으로 내보내주는 것까지가 끝난다. 함수도 ModelViewSet에서 상속 받은 것을 그대로 쓰기 때문에 특별히 동작을 오버라이드할 게 없으면 함수조차 선언할 필요가 없다. 그러면 위의 동작은 단지 선언만으로 끝나고 그 선언에 맞게 동작하는지는 프레임워크가 책임진다. 

이런 프레임워크가 없는 상태에서 위와 같은 설계를 생각하면서 TDD의 관점으로 이 요구사항을 만나면 어떤 테스트부터 작성하게 될까? 나라면 아마도 다음과 같은 테스트 케이스로 시작할 것이다.

describe('Filter', () => {
  test('applyFilter', () => {
newUser = mockUser('2025-02-02')
oldUser = mockUser('2024-01-01')
// UserFilters 만들기 전이라 빨간 불이 뜨는 상태. Filters를 상속할 것이라 가정하고 Filters가 doFilter 함수를 가짐.
query = UserFilters({'created_at': lookup: '>=', value: '2025-01-01').doFilter(query)
expect(await query.execute()).toEqual([newUser])
}) });

그리고, Filters에 몇 가지 타입과 lookup 조건을 더 추가할 것이고, 그러고 나면 getFilteredUsers의 테스트는 여러 가지 케이스를 다 테스트하는 게 아니라 한두 가지 필터가 동작하는지만 테스트해서 약간의 안심 효과만 얻고 넘어갈 것이고, Filters 쪽에 테스트가 점점 더 보강될 것이다. 그리고 Filters가 충분한 신뢰도를 갖기 시작하면 아마도 그 이후부터 getFilteredProducts, getFilteredOrders 같은 함수들은 테스트를 만들 필요성을 못 느끼게 될 것이다.

나는 이것이 TDD가 주는 설계 효과라고 생각한다. 처음 생각했던 구현에서 더 좋은 테스트 케이스를 만들려고 하다보면 일부러 범용화를 하지 않고 눈에 보이는 중복만 제거하더라도 더 좋은 설계가 나오고, 그 설계로 인한 이득을 빨리 얻을 수 있기 때문. 저 글에서는 함수를 어떻게 쪼갤지를 먼저 결정해야 TDD를 시작할 수 있다고 하는데, 이것은 사실이다. 그러나, 이것이 바로 TDD가 유익한 이유다. 함수를 잘 쪼개야 TDD하기 쉽고, 또 TDD를 할 때의 고통(?)이 적기 때문이다. 저 글에서 함수를 쪼개야 한다는 발상까지는 괜찮은데, 쪼개는 방식에서 그냥 조건 단위로 하드코딩하는 5개의 함수로 쪼깨면 저 글의 지적처럼 매우 번거롭고 배보다 배꼽이 더 커진다. TDD하기 싫어지는 구조가 되는 셈이다. 그런데, 여기서 TDD하기 좋은 구조로 함수를 설계해보자고 방향을 틀면, 좀더 작으면서도 범용적인 함수를 설계하게 될 수 있는 것이다.

테스트하기 좋은 함수란 결국 입력을 파라미터로 받아서 리턴값으로 응답하는 함수인데, 이러면 자연스럽게 함수 내에 상태도 줄어든다. 또 한 번에 하나의 일만 해야 테스트할 때 케이스가 줄어들어서 테스트하기 쉽다. 테스트하기 좋은 함수라는 게 그냥 일반적으로 좋은 함수의 조건과 매우 흡사한 것이다.

그런데, Naite는 이런 TDD의 제약을 제거한다. 좋은 함수가 아니라도 테스트하기 쉽게 만들어 주는 것이다. 게다가 중간에 관찰한다는 것은 함수 내에 많은 상태를 가져야 한다는 것이다. 코드 자체가 길어져서 함수 하나가 한 눈에 다 들어올 가능성이 떨어진다는 점도 아쉬운 점이다. 그래서 나는 Naite의 접근법에 반대하고 전통적(?)인 TDD의 관점을 여전히 지지한다.

Wiki at WikiNamu