실전에서 TDD하기

실전에서 TDD하기

요약: 이 블로그 글은 cdragon이 개발자로서 애플리케이션의 품질 개선에 대한 경험과 생각을 공유한 내용입니다. cdragon은 개발 초기 단계에서의 품질 보장에 대한 고민을 갖고 있으며, 테스트 코드와 테스트 주도 개발(Test Driven Development, TDD)에 대한 관심을 표현합니다. TDD 개념을 설명하고, 켄트 벡(Kent Beck)의 1999년 익스트림 프로그래밍에서 제안된 TDD 방법론에 대해 언급합니다. 또한, 실제 TDD 접근 방식과 자바 환경에서 테스트 코드 작성 예시를 설명합니다. 이 과정에서 TDD의 이점과 함께 실제 개발 환경에서 TDD를 적용하는 데 있어서의 도전과 고민을 공유하며, TDD의 실질적인 적용을 장려합니다.

시작하며

안녕하세요. 카카오페이 머니서비스파티 cdragon입니다. 개발자로서 더 나은 품질의 애플리케이션을 만드는 것에 관심이 많습니다. 최종적으로 더 나은 품질을 만들기 위해 회사 내에서는 QA 직군도 있고, PM 직군도 있지만 그전에 개발자 수준에서 어떻게 품질을 보장할 수 있을지에 대한 고민이 많았습니다.

또한 이미 릴리즈된 애플리케이션은 그걸로 끝나지 않습니다. 지속적으로 새로운 기능을 추가하게 되고, 시간이 흐를수록 처음의 모습과는 다른 모습이 되어갑니다. 이럴 때도 기존 코드를 추가/삭제/수정할 때 우리는 어떻게 기존에 있던 동작이 변하지 않았음을 증명할 수 있을까요? 애플리케이션을 구동시켜 직접 확인해 보는 것도 좋은 방법이고, 필요한 방법이지만 매번 모든 기능을 다 일일이 확인하기란 쉽지 않습니다.

그렇게 전 테스트 코드에 관심을 갖게 되었습니다. 그리고 더 나아가 동작하는 코드보다 테스트 코드를 먼저 작성하는 TDD에 매력을 느끼게 됐습니다. 이 글을 보는 분들은 대부분 TDD가 무엇인지 알고 있을 거라고 생각합니다. 하지만 무엇인지 알고 있는 것과는 별개로 TDD를 실제로 하는 분들은 많지 않을 것 같은데요. 얼마 전 사내 테크톡을 통해 TDD로 라이브 코딩을 진행하고, 공유했던 경험을 기술 볼로그를 통해 소개해보려 합니다.

TDD에 대한 간략한 소개

TDD는 Test Driven Development의 약자로 켄트백이 1999년 익스트림 프로그래밍의 일부로 제안하며 널리 알려졌습니다. 지금은 많은 분들이 익스트림 프로그래밍은 뭔지 몰라도 TDD는 알만큼 훨씬 유명해진 것 같습니다. TDD는 동작하는 코드를 작성하기 이전에 테스트를 먼저 작성하고, 그 테스트를 통과하는 코드를 작성함으로써 테스트된 동작하는 코드를 얻는 개발 방법입니다. 켄트백은 이 개발방식을 초기에는 Test First Development (테스트 우선 개발)이라고 불렀으나 First의 경우 Last라는 명백한 반의어가 있고, 많은 프로그래머가 코드를 작성한 후 테스트를 작성하기에 반의어가 모호한 TDD라고 부르기로 했습니다. 테스트로 개발을 주도하지 않을 거라면 추측 주도 개발을 할 거냐는 농담과 함께 말이죠. 때문에, 간혹 테스트를 작성하는 행위와 TDD를 동의어로 사용하는 경우도 있는데 켄트백이 TDD로 명명한 이유는 그 자체로 테스트를 나중에 작성하는 것과는 차별을 두려고 한 것이므로 적절하지 않습니다.

TDD는 세 가지 단계를 한 사이클로 돌게 됩니다.

  • 테스트를 작성한다(빨간 막대)
  • 실행 가능하게 만든다(초록 막대)
  • 올바르게 만든다

흔히 red-green-refactoring cycle이라고 하는데요. 컴파일조차 되지 않는 코드를 대상으로 먼저 테스트를 작성하고, 그다음 테스트코드가 컴파일되고 실행까지 되도록 코드를 작성한 후 테스트가 통과되면 테스트의 보호아래 코드를 다듬는 것입니다. TDD 자체를 설명하고 있는 자료는 이미 너무나도 많습니다. 따라서, TDD 소개는 이 정도로 마치겠습니다. 다음으로 TDD로 xUnit 만드는 방법을 살펴보겠습니다.

TDD로 xUnit 만들기

켄트백의 저서인 테스트 주도 개발의 정확한 명칭은 Test Driven Development By Example입니다. 책의 절반이 예제를 TDD로 개발해가는 과정을 담고 있는데요. 첫 번째 예제는 카카오페이와도 알맞게 Money와 연관 클래스를 개발해 가는 과정이고, 두 번째는 xUnit 프레임워크를 개발하는 예제입니다. 이 책을 보고 저도 xUnit을 TDD로 개발해 보면 재미있겠다는 생각을 했습니다. 이미 책에서도 모든 코드가 나와있었지만, 책과는 별개로 저만의 설계를 통해 다른 모양의 결과물을 만들어냈습니다. 그리고 그 과정을 사내 테크톡 시간에 라이브 코딩으로 진행했었는데요. 그 코드의 일부와 함께 간략하게 TDD를 어떻게 하는지 설명해보고자 합니다.

자바 환경에서 테스트 코드를 작성한다고 하면 보통 junit을 이용합니다. junit은 테스트 메서드를 어떻게 실행할까요? 테스트 클래스와 메서드명을 전달받아 실행합니다. 그리고 실행결과 예외가 발생하지 않으면 테스트가 성공했다고 간주합니다. 간단하게 클래스와 메서드명을 전달받아 해당 클래스에서 메서드를 실행시키는 클래스를 개발해 보겠습니다.

public class EachTestRunnerTest {
  @Test
  void 전달받은_클래스의_메서드를_실행한다() {
    // arrange
    var runner = new EachTestRunner(Dummy.class, "run");

    // act
    runner.run();

    // assert
    assertThat(runner.wasRun()).isTrue();
   }

  static class Dummy {
    public void run() {}
  }
}

아무것도 존재하지 않는 코드베이스에서 테스트 케이스를 먼저 작성했습니다. 테스트 케이스의 사전조건/실행/결과확인을 arrange/act/assert라는 주석으로 나눠놨는데요. 이는 테스트 메서드를 작성할 때 나타나는 패턴들을 좀 더 명확하게 표기해 놓은 것으로 3A pattern이라고 부릅니다. 비슷한 내용으로 given/when/then으로 나눈 GWT pattern이 요즘엔 더 유명한 것 같습니다.

다시 코드를 보겠습니다. EachTestRunner 클래스는 생성자로 클래스와 메서드명을 주입받고, run 메서드를 통해 해당 메서드를 실행합니다. 그리고 wasRun 메서드를 통해 실행여부를 확인할 수 있습니다. 현재는 EachTestRunner라는 클래스조차 없으므로 컴파일이 되지 않을 겁니다. 이미 IDE가 빨간 줄을 내뿜고 있겠지만 그래도 한번 실행해 보죠.

EachTestRunnerTest.java:12: error: cannot find symbol
    var runner = new EachTestRunner(Dummy.class, "run");
             ^
 symbol: class EachTestRunner
 location: class EachTestRunnerTest

🔴 테스트가 실패합니다. red를 확인했으니 이제 EachTestRunner 클래스를 만들어 보겠습니다.

public class EachTestRunner {
  public EachTestRunner(Class<?> dummyClass, String run) {

  }

  public void run() {

  }

  public boolean wasRun() {
    return false;
  }
}

빨간 줄이 뜨지 않도록 생성자도 만들어주고, 메서드들도 선언합니다. wasRun 메서드는 boolean의 기본 값인 false를 리턴하도록 합니다. 이제 IDE에서 빨간 줄은 사라졌을 겁니다. 그럼 다시 테스트 메서드를 실행해 보죠.

Expecting value to be true but was false
Expected :true
Actual :false

테스트는 여전히 실패하지만 에러 메시지의 내용이 바뀌었습니다. true를 기대했는데 false를 리턴했다는 내용이네요. 우리는 최대한 빨리 red를 green으로 바꿔야 합니다. 최단 경로로 green으로 갈 수 있는 방법은 무엇일까요?

public boolean wasRun() {
  return true; // false를 true로 변경
}

wasRun에서 false를 리턴하고 있었으므로 해당 구현을 true로 바꿔줍니다. 그리고 다시 테스트 메서드를 실행해 보면 테스트가 성공하는 걸 확인할 수 있습니다. 🟢 이제 red에서 green으로 오게 된 것입니다. TDD는 red-green-refactoring cycle이라는 것을 알고 있으므로 이제 리팩터링 차례입니다. 지금은 코드가 그리 많지 않아 어떤 걸 리팩터링 해야 할지 감이 잘 잡히지 않습니다. 켄트백의 테스트 주도 개발에서는 테스트에 있는 데이터와 코드에 있는 데이터의 중복도 제거하라고 얘기합니다.

// test 코드에 있는 true
assertThat(runner.wasRun()).isTrue();

// 코드에 있는 true
public boolean wasRun() {
  return true;
}

true가 중복으로 등장하고 있는 것입니다. 위 두 코드가 왜 중복인지 잘 이해가 안 된다면 단언문을 이렇게 바꾸면 조금 더 와닿으실 것 같습니다.

assertThat(runner.wasRun()).isEqualTo(true);

true가 두 군데 존재하므로 이를 리팩터링 해보겠습니다. 이미 테스트는 통과했으니 지속적으로 테스트의 우산 아래에서 코드 구조를 바꿀 수 있습니다.

class EachTestRunner {
  private boolean wasRun; // 인스턴스 필드 추가

  public EachTestRunner(Class<?> clazz, String methodName) {

  }

  public void run() {
    this.wasRun = true; // 인스턴스 필드에 값 할당
  }

  public boolean wasRun() {
    return this.wasRun; // 하드코딩된 값에서 인스턴스 필드를 반환하도록 변경
  }
}

wasRun 메서드가 하드코딩된 값이 아니라 객체 내부의 상태를 반환하도록 코드를 변경했습니다. 그리고 생성자의 파라미터 이름을 좀 더 알맞게 변경하는 리팩터링을 진행했습니다. 테스트를 돌려보면 여전히 테스트는 성공합니다.

하지만 이제 끝!이라고 하기엔 부족한 상태입니다. 테스트 메서드를 하나 더 추가해 보겠습니다.

@Test
void 전달받은_클래스의_메서드가_존재하지_않으면_실행하지_못한다() {
  // arrange
  var runner = new EachTestRunner(Dummy.class, “no_run");

  // act
  runner.run();

  // assert
  assertThat(runner.wasRun()).isFalse(); // assertThat(runner.wasRun()).isEqualTo(false);
}

현재의 코드에서는 테스트가 실패하고 있습니다. true를 할당하는 코드를 변경하면 새로 추가한 테스트는 통과할 수 있지만 그렇게 하면 이전에 작성한 테스트가 실패하게 됩니다. 하나씩 축적되고 있는 테스트가 코드 변경에 대한 자신감을 더해주게 되는 것입니다.

class EachTestRunner {
  private final Class<?> clazz;
  private final String methodName;
  private boolean wasRun;

  public EachTestRunner1(Class<?> clazz, String methodName) {
    this.clazz = clazz;
    this.methodName = methodName;
  }

  public void run() {
    try {
      Object object = clazz.getDeclaredConstructor().newInstance();
      Method method = clazz.getDeclaredMethod(methodName);
      method.invoke(object);
      this.wasRun = true;
    } catch(Exception e) {
      this.wasRun = false;
    }
  }

  public boolean wasRun() {
    return this.wasRun;
   }
}

EachTestRunner 클래스의 내부를 대폭 변경했습니다. 생성자를 통해 넘어오면 파라미터들을 내부 상태로 할당했고, 리플렉션을 통해 실제 메서드를 호출하게 했습니다. 메서드 실행 여부에 따라 wasRun을 할당하게 됩니다.

어떠신가요? 고작 하나의 클래스를 정의해 가는 과정이지만 컴파일이 안될 거라는 걸 알면서도 실행버튼을 눌렀고, 컴파일만 될 뿐 테스트는 당연히 깨질 거라는 걸 알면서도 실행버튼을 눌렀습니다. 어떻게 보면 미련하고 지루한 과정일 수 있고 이 정도 단계는 그냥 건너뛰어도 될 것 같다는 생각이 들 겁니다. 그렇게 어렵지 않은 클래스라 TDD를 행하지 않았어도 현재와 비슷한 모습의 클래스를 정의했을 것 같기도 합니다.

하지만 그 지루함 반대에는 내가 생각한 대로 코드가 동작한다는 확신을 얻었습니다. 존재하지 않는 메서드를 전달했을 때는 wasRun이 false가 된다는 확신을 가질 수 있게 되었습니다. 쉬운 코드이지만 생각보다 그 쉬운 코드에서 버그가 존재했던 경험들은 프로그래머라면 누구나 한 번쯤은 있을 거라고 생각합니다. 켄트벡은 TDD를 이용한 소프트웨어 개발이란 코드변경에 대한 두려움에서 지루함으로 바뀌는 과정이라고 이야기합니다. 그러므로 지루함을 느끼고 있다면 의외로 정상입니다. 그리고 매번 이런 작은 스텝으로 진행할 필요는 없지만, 필요한 경우에 이런 작은 스텝으로 진행할 줄 알아야 한다고 합니다. 필요할 때 작은 스텝으로 진행하기 위해선 필요하지 않을 때부터 작은 스텝에 적응이 되어야 합니다. 그래서 저 또한 항상 의식하지 못하고 큰 스텝을 밟을 땐 의식적으로 작은 스텝으로 진행하기 위한 연습을 하고 있습니다.

현실에서 TDD를 해보자

이 글을 보는 분들 중 상당수는 TDD가 뭔지 이미 알고 있을 거라고 추측합니다. 하지만 알고 있는 것과는 별개로 TDD를 실제로 행하는 분은 많지 않을 텐데요. 우리는 왜 TDD가 무엇인지 알면서도 막상 실행은 잘하지 못할까요? 어쩌면 TDD를 하고 싶어도 방법을 잘 몰라서는 아닐까라는 생각이 들었습니다. 지금부터는 테스트를 어떻게 하면 더 잘 작성할 수 있을지, 그리고 테스트를 작성하게 되면 발생하는 고민들과 TDD로 개발했을 때 이 고민들이 어떻게 해결되었는지 제 생각을 정리해보려 합니다.

로직을 어디에, 어떻게 작성해야 할까?

최근 송금 계좌 리스트를 조회해 오는 API를 개발한다고 해보겠습니다. 다만 카카오페이에서는 즐겨찾기에 등록된 계좌는 최근 송금 계좌 리스트에서 노출하지 않기 때문에 즐겨찾기 계좌를 제외해야 합니다. 송금 기록은 BankAccountRemittanceHistory, 즐겨찾기는 BankAccountBookmark 엔터티로 표현하겠습니다.

@Service
class RecentBankAccountRemittanceService {
  private BankAccountRemittanceHistoryRepository bankAccountRemittanceHistoryRepository;
  private BankAccountBookmarkRepository bankAccountBookmarkRepository;

  // 생성자 생략

  public List<BankAccountRemittanceHistory> recentBankAccounts(UserId userId) {
    List<BankAccountRemittanceHistory> histories = bankAccountRemittanceHistoryRepository.findBy(userId);
    List<BankAccountBookmark> bookmarks = bankAccountBookmarkRepository.findBy(userId);
    List<String> bankAccountNumbers = bookmarks.stream().map(BankAccountBookmark::getBankAccountNumber).toList();

    List<BankAccountRemittanceHistory> result = histories.stream()
        .distinct()
        .filter(history -> !bankAccountNumbers.contains(history.getBankAccountNumber()))
        .collect(Collectors.toList());

    return result;
  }
}

BankAccountRemittanceHistory의 컬렉션을 조회하고, 조회 결과에서 중복을 제거하고 즐겨찾기에 저장이 안 된 엔터티들만 필터링해서 리턴하는 로직입니다. 이 코드의 테스트 코드는 어떻게 작성해야 할까요?

@ExtendWith(MockitoExtension.class)
class RecentBankAccountRemittanceServiceTest {
  @InjectMocks
  private RecentBankAccountRemittanceService recentBankAccountRemittanceService;
  @Mock
  private BankAccountRemittanceHistoryRepository bankAccountRemittanceHistoryRepository;
  @Mock
  private BankAccountBookmarkRepository bankAccountBookmarkRepository;

  @Test
  void 송금기록을_조회한다() {
    // arrange
    var histories = List.of(
        new BankAccountRemittanceHistory("11111111"),
        new BankAccountRemittanceHistory("11112222"),
        new BankAccountRemittanceHistory("11113333")
    );
    given(bankAccountRemittanceHistoryRepository.findBy(any())).willReturn(histories);
    given(bankAccountBookmarkRepository.findBy(any())).willReturn(List.of());

    // act
    var result = recentBankAccountRemittanceService.recentBankAccounts(new UserId());

    // assert
    assertThat(result).hasSize(3);
  }

  @Test
  void 즐겨찾기_등록된_계좌는_포함하지_않는다() {
    // arrange
    var histories = List.of(
        new BankAccountRemittanceHistory("11111111"),
        new BankAccountRemittanceHistory("11112222"),
        new BankAccountRemittanceHistory("11113333")
    );

    var bookmarks = List.of(
        new BankAccountBookmark("11111111"),
        new BankAccountBookmark("11114444")
    );
    given(bankAccountRemittanceHistoryRepository.findBy(any())).willReturn(histories);
    given(bankAccountBookmarkRepository.findBy(any())).willReturn(bookmarks);

    // act
    var result = recentBankAccountRemittanceService.recentBankAccounts(new UserId());

    // assert
    assertThat(result).hasSize(2);
  }
}

이런 식으로 작성할 수 있을 것 같습니다. DB에 접근하는 부분은 mockito 같은 mock 라이브러리를 이용하고, service에 있는 로직을 테스트합니다. 두 개의 테스트 메서드는 모두 잘 성공하고, 의도도 비교적 잘 나타내는 것 같습니다. 이런 모습의 테스트는 실제로 우리의 코드에서 익숙하게 볼 수 있는 모습의 테스트 코드입니다.

잘 테스트하고 있다고 생각하는 이 코드는 전형적인 TLD (Test Last Development)입니다. 테스트를 작성하기에 앞서 동작하는 코드를 먼저 작성했고, 그 이후에 이미 구현을 다 숙지하고 있는 상태로 테스트를 작성했습니다. 코드가 어떻게 돌아가는지, 어떤 클래스를 이용하고 어떤 메서드를 호출하는지 다 알고 있기 때문에 필요한 지점마다 목킹을 할 수 있었습니다. 이 테스트 코드를 TDD로 작성할 수 있을까요?

저는 거의 불가능에 가깝다고 생각합니다. 이 테스트는 로직에 대한 관심보다 기반 데이터 설정에 대한 관심이 훨씬 많고, 그에 대한 목킹이 대부분입니다. 이미 구현된 메서드에 대해 테스트를 작성했기에 이 모든 걸 알고 있는 상태에서 작성할 수 있었으나 아직 코드가 없는 상태에서 이를 먼저 떠올릴 수 있을지 모르겠습니다. 스펙에 대한 테스트 케이스가 굳건히 존재하는 상태에서 구현 코드가 완성되어 갈 때 코드 변경에 대한 자신감을 얻게 되는데 이런 방식의 테스트 코드는 테스트를 먼저 작성하더라도 구현 코드가 바뀌면 지속적으로 영향을 받을 수밖에 없을 겁니다.

그럼 어떤 식으로 작성해야 TDD를 할 수 있을까요?

class BankAccountRemittanceHistoryCollectionTest {
  @Test
  void 즐겨찾기에_등록된_계좌는_제외한다() {
    // arrange
    var bookmarks = List.of(
        new BankAccountBookmark("11111111"),
        new BankAccountBookmark("11114444")
    );
    var histories = new BankAccountRemittanceHistoryCollection(
      List.of(
            new BankAccountRemittanceHistory("11111111"),
            new BankAccountRemittanceHistory("11112222"),
            new BankAccountRemittanceHistory("11113333")
      )
    );

    // act
    List<BankAccountRemittanceHistory> result = histories.excludeBookmark(bookmarks);

    // assert
    assertThat(result).hasSize(2);
  }
}

class BankAccountRemittanceHistoryCollection {
  private List<BankAccountRemittanceHistory> histories;

  public BankAccountRemittanceHistoryCollection(List<BankAccountRemittanceHistory> histories) {
    this.histories = histories;
  }

  public List<BankAccountRemittanceHistory> excludeBookmark(List<BankAccountBookmark> bookmarks) {
    List<String> bankAccountNumbers = bookmarks.stream().map(BankAccountBookmark::getBankAccountNumber).toList();
    return this.histories.stream().filter(history -> !bankAccountNumbers.contains(history.getBankAccountNumber())).toList();
  }
}

@Service
class RecentBankAccountRemittanceService {
  private BankAccountRemittanceHistoryRepository bankAccountRemittanceHistoryRepository;
  private BankAccountBookmarkRepository bankAccountBookmarkRepository;

  // 생성자 생략

  public List<BankAccountRemittanceHistory> recentBankAccounts(UserId userId) {
    List<BankAccountRemittanceHistory> histories = bankAccountRemittanceHistoryRepository.findBy(userId);
    List<BankAccountBookmark> bookmarks = bankAccountBookmarkRepository.findBy(userId);

    BankAccountRemittanceHistoryCollection collection = new BankAccountRemittanceHistoryCollection(histories);
    return collection.excludeBookmark(bookmarks);
  }
}

BankAccountRemittanceHistory의 컬렉션을 의미하는 BankAccountRemittanceHistoryCollection이라는 클래스를 정의하고 해당 클래스의 객체가 로직을 처리하도록 했습니다. 목킹 라이브러리의 힘을 빌리지 않고, 컬렉션에 대한 로직을 응집하는 클래스가 정의되어 테스트하기가 훨씬 쉬워졌습니다. BankAccountRemittanceHistoryCollection과 같은 클래스를 일급컬렉션(First Class Collection)이라고도 부릅니다. service에 로직을 작성하지 않고, 실제 로직을 수행하는 객체들을 사용하도록 코드를 변경한다면 객체는 더 작은 객체로써 활용될 수 있고, 테스트하기도 훨씬 용이해집니다.

mock을 적극적으로 활용하는 게 좋을까?

테스트에 이용하는 데이터는 동일하기에 비슷한 코드로 보일 수 있지만 첫 번째 테스트는 목킹에 의한 가정을 이용하고 있기 때문에 내부 구현에 의존하게 됩니다. 실제로 service 내부 로직이 변경되어 목킹한 repository를 이용하지 않거나 다른 메서드를 호출하게 되면 의미 없는 목킹에 의한 예외를 일으키기도 합니다. 스펙에 대한 변경 없이 내부 구현이 변경된 리팩터링에도 테스트가 쉽게 깨지게 되는 것이죠.

public List<BankAccountRemittanceHistory> recentBankAccounts(UserId userId) {
  // 최근 1달 조건을 넣기 위한 파라미터 추가
  var today = LocalDate.now();
  var period = BetweenPeriod.of(today.minusMonths(1), today);

  // findBy(userId)에서 findBy(userId, BetweenPeriod)로 내부 리파지토리 메서드가 변경됐을 뿐이지만 목킹 테스트코드는 깨짐
  List<BankAccountRemittanceHistory> histories = bankAccountRemittanceHistoryRepository.findBy(userId, BetweenPeriod);
  List<BankAccountBookmark> bookmarks = bankAccountBookmarkRepository.findBy(userId);

  // …
}

위 코드는 스펙은 아무것도 변한 게 없는 상태에서 내부 구현만 변경됐습니다. 전체 데이터를 조회해서 처리하던걸 1달치만 조회해 오도록 성능개선을 했습니다. 내부에서 호출하는 메서드만 바뀌었을 뿐인데 mock 때문에 테스트가 실패하는 게 바람직할까요?

단위테스트에서는 좋은 단위테스트의 특성 중 하나로 리팩터링 내성을 이야기합니다. 즉 스펙이 변한 게 아니라 리팩터링으로 내부구조만 변했다면 테스트는 여전히 통과해야 한다는 것입니다. 그리고 개발자는 통과하는 테스트를 기반으로 마음 놓고 코드구조를 변경할 수 있습니다. 변경도중 자신도 모르게 스펙에 영향을 끼쳤다면 그때는 테스트가 실패할 테니까요.

자꾸만 가정들을 정의하게 되고, 이에 대한 코드가 길어져서 실제 테스트코드보다 목킹 코드가 더 많아지는 것 또한 테스트 코드를 이해하는데 어렵게 만드는 요소입니다. 단위테스트에서는 목킹을 최대한 자제하는 쪽을 고전파, 목킹을 적극적으로 이용하는 쪽을 런던파라고 부르고 있는데 책의 저자와 저 모두 런던파보다는 고전파 쪽입니다. 저는 뛰어난 목킹 라이브러리가 오히려 테스트에 용이한 디자인을 고민하는 시간을 줄인다고 생각합니다. 또한 TDD 방식으로 코드를 작성하게 된다면 구현 코드가 없는 상태에서 테스트 코드를 먼저 작성하기 때문에 어떤 가정을 해야 하는지조차 알 수 없어 자연스레 목킹에서 멀어지게 됩니다.

전 개인적으로 목킹 라이브러리는 개발자가 컨트롤할 수 없는 외부 라이브러리의 코드, 혹은 인프라에 관련된 코드에만 사용하려 노력하는 편이고 최대한 자제하려 합니다.

private method는 어떻게 테스트해야 할까?

테스트 코드를 작성하게 되면 누구나 한 번쯤은 마주하게 되는 고민이 아닐까 합니다. 보통 이 고민을 시작하면 3가지 방안 중에 하나를 선택하게 되는 것 같습니다.

  • 접근제어자를 default로 변경
  • 리플렉션 사용
  • 해당 private method를 사용하는 public method를 테스트

접근제어자를 바꾸거나 리플렉션을 사용하는 건 뭔가 꺼림칙하고 퍼블릭 메서드를 테스트하려다 보면 뭔가 어려움을 마주하게 됩니다. 보통 위 테스트처럼 service에 테스트를 작성하게 되는데 내가 테스트하고자 하는 프라이빗 메서드를 테스트하기 위해 목킹해야 할 클래스도 많고, 퍼블릭 메서드 내에서 테스트하고자 하는 프라이빗 메서드 호출이 아랫부분에 있다면 그 부분까지 진행하기 어려운 경우도 많습니다.

그럼 어떻게 해야 할까요? 왜 프라이빗 메서드를 테스트하고 싶을까요? 프라이빗 메서드를 테스트하려는 행위 자체가 TLD로 코드를 작성했기 때문입니다. 기존 코드에 프라이빗으로 구현코드를 추가하고 그 부분을 테스트하고 싶은 욕구가 생기기 때문이죠. TDD 방식으로 코드를 작성하면 프라이빗 메서드의 테스트에 대한 고민이 쉽게 사라집니다. 리팩터링 과정에서 추출되는 프라이빗 메서드들은 그 자체로 이미 테스트되고 있는 퍼블릭 메서드의 파생 메서드들이기 때문에 별도의 테스트가 필요하지 않기 때문입니다. TDD를 만능인 것처럼 표현했지만 프라이빗 메서드의 테스트는 해당 메서드가 다른 클래스의 퍼블릭 메서드가 되어야 하는 건 아닌지를 고민해야 하는 지점이라고 생각합니다. 하지만 TLD 방식에서 클래스를 분리하는 건 의식적으로 행하지 않는 이상 잘 떠오르지 않기 때문에 처음부터 테스트를 작성하면 비교적 쉽게 되는 것 같습니다.

종종 아티클들을 보다 보면 TDD가 설계기법인지에 대해 논의하는 게 보이는데요. 저는 TDD만으로는 좋은 설계를 얻기는 힘들다고 생각합니다. 다만 나쁜 설계를 피할 수 있게 해 준다고 생각합니다. 마침 비교적 최근에 켄트벡이 작성한 아티클을 공유합니다.

마치며

테스트 코드와 TDD에 대해 사내 발표한 내용과 제 생각을 정리해 보았습니다. TDD가 만능인 것처럼 표현한 부분도 있는 것 같지만 주제가 TDD였던 만큼 마주하는 문제들을 TDD로 개발했을 땐 어떻게 해결할 수 있을지 집중해보고자 했습니다. TDD는 그 방식 자체가 어렵진 않습니다. 하지만 우리의 습관을 바꿔야 하고 무척 지루한 길입니다. 저도 잠시만 방심하면 바로 프로덕션 코드부터 작성하고 마지막에 코드를 짜는 경우가 많은데, 이 글을 씀으로써 좀 더 의식적으로 TDD를 해야겠다는 다짐을 하게 됩니다.

cdragon.cd
cdragon.cd

카카오페이 서버개발자 cdragon입니다. 견고한 애플리케이션을 만드는데 관심이 많습니다. 한적한 카페에서 책 보는 걸 즐깁니다.

태그