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



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

<p>이 글은 <a href=https://cartanova.notion.site/Naite-2b4f753e4fb981a590dee0eeb18df0d6>테스트를 위해 코드를 쪼개지 마세요: Naite와 함께하는 관찰 기반 테스팅</a>을 읽고 쓰는 일종의 반론이다. 나는 저 글에서 유용한 포인트들이 몇 가지 제시되었다고 생각하지만, 한편으로는 오해를 불러일으킬 수 있는 내용들이 있어서 짚어보고자 한다. 마침 준비 중인 강의 내용과도 깊은 관련이 있어서 조금 풀어서 써 본다.</p>
<p>우선, 처음 예시로 나온 코드는 다음과 같다.</p>
<pre><code>async function getFilteredUsers(filters: UserFilters) {
  const query = db.users.query();
  
  if (filters.age) {
    query.where('age', '&gt;=', filters.age.min);
    query.where('age', '&lt;=', 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', '&gt;=', filters.createdAfter);
  }
  
  return await query.execute();
}</code><!-- notionvc: d0b6e422-6c1b-4074-a3bb-646135babe25 --><!-- notionvc: d0b6e422-6c1b-4074-a3bb-646135babe25 --></pre>
<p>그리고, 전통적인 해결책은 함수를 쪼개는 것이라면서 함수를 다음과 같이 5개로 쪼갠다.</p>
<pre>// "올바른" TDD 방식
class UserQueryBuilder {
  buildBaseQuery(): QueryBuilder {
    return db.users.query();
  }
  
  applyAgeFilter(query: QueryBuilder, age: AgeFilter): void {
    if (age.min) query.where('age', '&gt;=', age.min);
    if (age.max) query.where('age', '&lt;=', 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', '&gt;=', date);
  }
}

// 이제 각 메서드를 테스트
describe('UserQueryBuilder', () =&gt; {
  test('applyAgeFilter', () =&gt; { /* ... */ });
  test('applyStatusFilter', () =&gt; { /* ... */ });
  test('applyTagsFilter', () =&gt; { /* ... */ });
  test('applyDateFilter', () =&gt; { /* ... */ });
  test('integration', () =&gt; { /* ... */ });
});</pre>
<p>내가 짚고 싶은 부분이 이 부분이다. 이것은 TDD에서 올바른 방법이라고 주장하고 있는 방법이 아니며, 전통적인 방법도 아니다. TDD의 창시자 Kent Beck이라면 저렇게 함수를 쪼개고 테스트를 작성하지 않을 것이고, 다른 XP의 창시자 론 제프리즈, 워드 커닝엄도 다른 관점을 취할 것이다.</p>
<p>우선, <code>applyAgeFilter</code> 같은 메서드들은 일종의 private method 성격을 띤다. 이 각각은 다른 곳에서 쓰일 가능성이 적다. 그런데 Xper들은 <a href=https://wiki.c2.com/?MethodsShouldBePublic>MethodsShouldBePublic</a>의 관점을 선호한다. 메서드를 뽑아낸다면 다른 곳에서도 사용할 만한 방식으로 설계하고 싶어하는 것이다. 마침 이 메서드들 사이에는 강력한 공통점이 보인다. 파라미터로 들어온 필터 조건으로 where 조건절을 생성하는 것이다. 아마도 이와 매우 비슷한 코드가 여기저기서 필드명만 바뀐 채로 나타나고 있을 것이다. 이것은 중복 코드다. 이 두 가지 관점으로 보면 좀더 범용적인 쿼리 빌더 테스트를 먼저 작성할 수 있다. </p>
<p>date 필터링하는 코드를 보자. 여기서 <code>created_at</code>의 존재는 이 필터링 코드가 한 필드에 대해서만 사용될 수 있게 제한하고 있다. 그런데 만약 이 상수를 인자로 만든다면 어떨까? 그리고 &gt;= 대신 &gt;, &lt;= 등도 받을 수 있게 하면 어떨까? 다음과 같은 코드가 될 것이다.</p>
<pre style="line-height: 1.42857;">  applyFilter(query: QueryBuilder, field: string, lookup: string, date: string): void {
    query.where(field, lookup, date);
  }</pre>
<p>그러면 이제 이 함수는 <code>UserQueryBuilder</code>에 있을 필요도 없어진다. date 타입에 대해 동작하는 범용적인 필터가 되기 때문에 그냥 <code>QueryBuilder</code>에 들어갈 수 있다. method overloading이 가능한 언어라면 타입만 달라지고 이름은 다 <code>applyFilter</code>로 통일하여 다형성을 얻을 수 있다. 다른 타입들과 where 조건들도 다 비슷하게 만들 수 있을 것이다. 그러면 첫번째 코드는 다음과 같이 고칠 수 있을 것이다.</p>
<pre style="line-height: 1.42857;">async function getFilteredUsers(filters: UserFilters) {
  const query = db.users.query();<br>  for (filter of filters.filters) {<br>    queryBuilder.applyFilter(query, filter.field, filter.lookup, filter.value)<br>  }<br>  return await query.execute()</pre>
<p>이런 방향을 좀더 극단적으로 하면 Django Rest Framework에서 django-filters를 사용하는 케이스가 예시로 좋다.</p>
<pre>class UserViewSet(ModelViewSet):<br>    queryset = User.objects.all()<br>    serializer_class = UserSerializer<br>    filterset_fields = {'age': ['gt', 'lt'], 'status': ['in'], 'tags': ['in'], 'created': ['gte']}</pre>
<p>이러고 이 <code>UserViewSet</code>을 <code>/user/</code> 같은 주소에 매핑시켜두면 <code>/user/age__gt=30&amp;status__in=activated</code> 같은 주소로 요청이 들어왔을 때 쿼리스트링을 파싱하고 그에 따라 DB에서 쿼리한 다음 응답으로 내보내주는 것까지가 끝난다. 함수도 <code>ModelViewSet</code>에서 상속 받은 것을 그대로 쓰기 때문에 특별히 동작을 오버라이드할 게 없으면 함수조차 선언할 필요가 없다. 그러면 위의 동작은 단지 선언만으로 끝나고 그 선언에 맞게 동작하는지는 프레임워크가 책임진다. </p>
<p>이런 프레임워크가 없는 상태에서 위와 같은 설계를 생각하면서 TDD의 관점으로 이 요구사항을 만나면 어떤 테스트부터 작성하게 될까? 나라면 아마도 다음과 같은 테스트 케이스로 시작할 것이다.</p>
<pre>describe('Filter', () =&gt; {
  test('applyFilter', () =&gt; {<br>    newUser = mockUser('2025-02-02')<br>    oldUser = mockUser('2024-01-01')<br>    // UserFilters 만들기 전이라 빨간 불이 뜨는 상태. Filters를 상속할 것이라 가정하고 Filters가 doFilter 함수를 가짐.<br>    query = UserFilters({'created_at': lookup: '&gt;=', value: '2025-01-01').doFilter(query)<br>    expect(await query.execute()).toEqual([newUser]) <br>  })
});<!-- notionvc: d88ecba6-2b1e-46dd-aae5-2503c3f7edd6 --></pre>
<p>그리고, Filters에 몇 가지 타입과 lookup 조건을 더 추가할 것이고, 그러고 나면 getFilteredUsers의 테스트는 여러 가지 케이스를 다 테스트하는 게 아니라 한두 가지 필터가 동작하는지만 테스트해서 약간의 안심 효과만 얻고 넘어갈 것이고, Filters 쪽에 테스트가 점점 더 보강될 것이다. 그리고 Filters가 충분한 신뢰도를 갖기 시작하면 아마도 그 이후부터 getFilteredProducts, getFilteredOrders 같은 함수들은 테스트를 만들 필요성을 못 느끼게 될 것이다.</p>
<p>나는 이것이 TDD가 주는 설계 효과라고 생각한다. 처음 생각했던 구현에서 더 좋은 테스트 케이스를 만들려고 하다보면 일부러 범용화를 하지 않고 눈에 보이는 중복만 제거하더라도 더 좋은 설계가 나오고, 그 설계로 인한 이득을 빨리 얻을 수 있기 때문. 저 글에서는 함수를 어떻게 쪼갤지를 먼저 결정해야 TDD를 시작할 수 있다고 하는데, 이것은 사실이다. 그러나, 이것이 바로 TDD가 유익한 이유다. 함수를 잘 쪼개야 TDD하기 쉽고, 또 TDD를 할 때의 고통(?)이 적기 때문이다. 저 글에서 함수를 쪼개야 한다는 발상까지는 괜찮은데, 쪼개는 방식에서 그냥 조건 단위로 하드코딩하는 5개의 함수로 쪼깨면 저 글의 지적처럼 매우 번거롭고 배보다 배꼽이 더 커진다. TDD하기 싫어지는 구조가 되는 셈이다. 그런데, 여기서 TDD하기 좋은 구조로 함수를 설계해보자고 방향을 틀면, 좀더 작으면서도 범용적인 함수를 설계하게 될 수 있는 것이다.</p>
<p>테스트하기 좋은 함수란 결국 입력을 파라미터로 받아서 리턴값으로 응답하는 함수인데, 이러면 자연스럽게 함수 내에 상태도 줄어든다. 또 한 번에 하나의 일만 해야 테스트할 때 케이스가 줄어들어서 테스트하기 쉽다. 테스트하기 좋은 함수라는 게 그냥 일반적으로 좋은 함수의 조건과 매우 흡사한 것이다.</p>
<p>그런데, Naite는 이런 TDD의 제약을 제거한다. 좋은 함수가 아니라도 테스트하기 쉽게 만들어 주는 것이다. 게다가 중간에 관찰한다는 것은 함수 내에 많은 상태를 가져야 한다는 것이다. 코드 자체가 길어져서 함수 하나가 한 눈에 다 들어올 가능성이 떨어진다는 점도 아쉬운 점이다. 그래서 나는 Naite의 접근법에 반대하고 전통적(?)인 TDD의 관점을 여전히 지지한다.
Wiki at WikiNamu