🧪 많이들 하는 착각

많이들 착각하는데, “단위 테스트를 만든다” 와 “TDD를 한다” 를 다르게 생각해야한다.

포비의 말을 듣고 뜨끔했다. 고작 단위 테스트를 몇 개 만들고 실행시킨 것으로 나 자신이 TDD를 지키고 있다고 착각한건 아니었나 되돌아보게 된다.

단위 테스트는 말 그대로 작은 단위의 테스트 함수를 의미하고, TDD는 하나의 프로그래밍 습관, 패턴을 이야기 한다. TDD가 익숙해질 때까지 반복 연습을 해야한다. TDD를 지켜나가다보면 리팩토링이 필수적으로 이루어져야 하고, 그 과정에서 자연스럽게 객체지향 설계와 클린코드를 고민하게 될 것이다.


🧪 우리가 TDD에 집착하는 이유

  1. 나는 사람이다. 요구사항 추가, 변경 때문에 소스코드를 수정하고 불안감에 살고 싶지 않다.
  2. 나는 평범하다. 어려운 알고리즘이나 구현 난이도가 높은 문제를 마주할 경우, 테스트 코드 없이 프로그래밍을 시도하면 천재가 아닌 이상 꽤나 오랜 시간이 소요된다. TDD를 통해서 테스트케이스를 하나씩 추가하면서 프로그래밍을 진행하면 큰 문제없이 개발이 이루어진다.
  3. 나는 프로그래밍을 좋아한다. 자동화된 테스트를 통해 피드백을 받는 주기가 처음엔 느리지만, 반복 횟수가 많아질수록 기하급수적으로 빨라진다. 피드백이 빨라질수록, 버그를 찾는 시점이 빨라지고, 더 많은 삽질을 할 시간을 확보할 수 있다.

TDD 원칙들을 지키다보면 처음부터 완벽한 설계를 하는 것이 아니라 점진적으로 설계를 개선해나갈 수 있다. 그리고, 점진적으로 설계를 개선해나가면 변화에 빠르게 대응하는 방법을 익힐 수 있고, 오버 엔지니어링을 막을 수 있게 된다.


🧪 그래서 어떻게 하면 TDD인건데?

TDD 원칙

  1. 실패하는 단위 테스트를 작성할 때까지 프로덕션 코드를 작성하지 않는다.
  2. 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
  3. 현재 실패하는 테스트를 통과할 정도로만 프로덕션 코드를 작성한다.
  4. 리팩토링 한다.

“TDD에는 리팩토링이 있기 때문에, 설계고 자시고 고려하지말고 일단 구현부터 해라. 그리고 3단계 리팩토링 단계에서 설계를 고려해서 진행해라. 그게 TDD의 장점이다. (우선 쓰레기 코드를 만들어라.) 쓰레기 코드라도 만든다면 사람이 심리적인 안정감을 얻을 수 있다. 구현과 설계를 동시에 진행하면 압박감 때문에 진행이 잘 안 될 것이다.”

TDD를 하려면 요구사항 분석을 잘 해야 핸다. 사실 최소한의 부분을 탄탄하게 설계해두어야하기도 하다. 요구사항 분석과 최소한의 설계가 준비되지 않는다면 TDD를 진행하기 어렵다. 만약 TDD를 시작하기 앞서 막막함이 몰려온다? 요구사항 분석을 제대로 못했거나, 최소한의 설계조차 하지 않은 것이다. TDD도 결국 기초준비가 되어야 한다.

TDD를 진행하기 앞서 최선의 조건은 탄탄한 요구사항 분석과 자신의 역량을 최대한 뽑아낸 탄탄한 설계를 갖춘 것이다.


🧪 TDD로 구현할 기능 찾기 - 기능 구현 목록 정리

프리코스 때부터 괜히 보기 좋으라고 기능 구현 목록을 정리하라 한게 아니었다. 기능 구현 목록 정리를 통해서 TDD를 진행할 기능 목록들에 대해 정리하고, 단위 테스트를 구상하고, 이를 순차적으로 진행해나가는 능력을 길러야 한다. 이번에 진행한 로또게임 미션에 대해 기능 구현 목록을 정리해보면 어떨까?


  • 로또 구매 금액을 전달하면 구매할 수 있는 로또의 장수를 반환한다.
  • 구매할 로또의 장 수만큼 자동 구매할 경우 자동 로또를 생성해 반환한다.
  • 구매한 한 장의 로또 번호와 당첨 번호를 넣으면 당첨 결과를 반환한다.
  • 구매한 전체 로또의 당첨 결과를 입력하면 당첨금 총액을 반환한다.
  • 당첨 금액과 구매 금액을 넣으면 수익률을 반환한다.

어떤 단위 테스트를 작성할지 조금씩 눈에 보인다!

단, 주의할 점은 기능 구현 목록을 너무 자세히, 열심히 쓰려고 해선 안된다. 설계에만 오랜 시간을 투자하는 것은 TDD의 주 목적이 아니다. 우선 돌아가는 쓰레기 코드를 만들고, TDD를 통해서 점진적으로 개선시켜 나가는 것을 유념하자.


🧪 TDD를 잘 하는 법 - 중간부분 자르기

로직 자체는 복잡하지 않지만, 중간에 거쳐오는 단계가 많아서 테스트가 어려운 메서드들이 많아지곤 한다. 우리는 구현 중간중간 부분을 자르는 연습을 해야한다. 즉, 프로그램이 실행되는 특정 시점의 상태값을 직접 삽입할 수 있어야 한다. 그게 가능해야 단위 테스트를 쉽게 작성할 수 있고, 그래야 TDD를 잘 할 수 있다.

예를 들면 아래와 같다.

public class WinnersTest {

    @Test
    void findWinners() {
        List<Car> cars = Arrays.asList(
            new Car("pobi", 4),
            new Car("crong", 3),
            new Car("honux", 4),
            new Car("jk", 2),
        );
        List<Car> winners = Winners.findWinners(cars);
        assertThat(winners).containsExcactly(
            new Car("pobi", 4),
            new Car("honux", 4)
        );
    }
}

자동차 경주 게임에서 우승자 구하기를 구현할 때 자동차 경주 게임을 완료한 시점의 자동차 상태값을 테스트코드에서 변경(또는 결정) 할 수 있다. 만일 이처럼 중간 부분을 잘라두지 않았다면 초기 자동차 경주에 참여하는 플레이어들의 이름을 입력하는 것부터 테스트를 진행해야 할 것이다.

그렇다면 중간부분을 자르기 쉬운 메서드는 어떤 형태일까? 이전에 했던 말을 다시 떠올려보자. 중간 부분을 자르는 연습을 해야 프로그램이 실행되는 특정 시점의 상태값을 직접 삽입할 수 있다고 했다. input이 없는채로 ouput만 내뱉는 메서드보다, input을 주입 받고 해당되는 ouput을 내뱉는 메서드가 단위 테스트 작성이 훨씬 용이한 것이다.

기억하자. 자르는게 실력이다.


🧪 테스트 할 수 없는 걸 테스트하고 싶다면

대부분의 경우 억지로 private 메서드를 테스트할 필요는 없다. 그러나 꼭 테스트 해야할 것 같다고 생각이 드는 private 메서드가 존재한다면, “이 메서드는 다른 객체에 속하는게 더 낫지 않은가?” 라는 생각이 이어져야 한다. 이어지는 생각대로 메서드를 다른 객체 쪽으로 이동시킨다면, 자연스럽게 public으로 전환시켜 테스트도 가능해 질 것이다.

단순히 접근제한자(접근제어자) 문제가 아닌, “이건 정말 테스트가 불가능 하지 않나?” 싶은 것들이 있을 수 있다. 그럴 땐 의존관계를 상위 레벨로 올리는 것을 고려해보아야 한다.

슬라이드1

자동차의 이동에 대한 테스트를 진행해보고 싶지만, Car.move()Random.nextInt()의 반환 값에 따라 확률적으로 전진하므로 테스트가 어려운 상황이다. 이럴 땐 Random의 의존 관계를 상위 레벨로 끌어 올려야 한다.

슬라이드2

이렇게 Random의 의존관계가 RacingGame으로 옮겨가면, RacingGame.race() 메서드 내부에서 Car.move() 메서드의 인자(파라미터)로 정수 값을 넘겨줌으로서 테스트가 가능해진다.

슬라이드3

한 번 더 올리면 위와 같은 형태를 띄게 된다. 그런데 과연 이렇게까지 올릴 필요가 있다고 생각되는가? 무작정 최상위로 의존관계를 올린다고 좋은 것은 아니다. 적당한 선에서 의존관계의 위치를 결정하고 단위 테스트를 구현할 수 있어야 한다.

(사실 이게 자유자재로 가능한 레벨이면 이미 TDD의 고수가 아닐까 😂)


🧪 리팩토링 단계에서 TDD 지키기

리팩토링 단계에서 TDD를 수행하면서 가장 많이 하는 실수가, 새로운 테스트 코드 작성 후 테스트 코드의 컴파일 에러를 맞추기 위해 기존 프로덕션 코드의 메서드를 곧장 수정하는 것이다.

복잡도가 낮은 프로젝트에서는 유효한 방법일 수 있으나, 프로젝트의 규모가 클수록 작은 메서드 수정에도 수천 수백개의 많은 컴파일 에러가 발생하고, 그 과정에서 집중력을 잃을 시 다시 모든 과정을 되돌아가야 할 수도 있다. 또한 이미 상용화중인 프로젝트에서는 얼마든지 수정중인 코드가 서비스코드에 포함되어 나갈 수 있음을 염두에 두어야 한다. (내가 포비의 이야기를 제대로 이해한게 맞다면, 프로젝트 리팩토링 중에도 즉시 리팩토링을 중단하고 코드를 반영해야하는 경우가 존재한다고 한다. 😨)

다른 문제점을 다 제쳐두고, 우선 컴파일 에러가 안나야 기부니가 좋지 않은가?

리팩토링 중 TDD를 지키기 위해서는 과도기적인 단계가 항상 필요하다.

// 프로덕션 코드
public LottoResult getResult(final WinningNumbers winningNumbers) { 
    return lottoTickets.getMatchResult(winningNumbers);
}
// 테스트 코드
@Test
@Display("로또 당첨 결과가 좋은지 테스트ㅋㅋ")
void testLottoMatchResult() {
    LottoGame lottoGame = new LottoGame();
    assertThat(lottoGame.getResult()).isGood();    
}

위와 같은 getLottoResult 메서드를 리팩토링하고 싶을 때, 곧장 메서드를 수정하지 말고 동일한 시그니처를 가진 메서드를 하나 더 생성한다.

// 프로덕션 코드
public LottoResult getResult(final WinningNumbers winningNumbers) { 
    return lottoTickets.getMatchResult(winningNumbers);
}

public LottoResult getResult(final WinningNumbers winningNumbers) {  // 컴파일 에러!
    return lottoTickets.getMatchResult(winningNumbers);
}

동일한 메서드가 2개 존재하므로 컴파일 에러가 발생할 것이다. 컴파일 에러를 회피하기 위해 복제된 메서드의 이름을 살짝 바꾼 후 새로운 변경사항을 대입한다.

// 프로덕션 코드
public LottoResult getResult(final WinningNumbers winningNumbers) {
    return lottoTickets.getMatchResult(winningNumbers);
}

public LottoResult getResult2(final WinningNumbers winningNumbers) {
    return lottoTickets.getNewMatchResult(winningNumbers + 5);
}

이후 테스트 코드를 복제한 메서드로 살짝 바꿔준 후, 테스트를 수행해본다.

// 테스트 코드
@Test
@Display("로또 당첨 결과가 좋은지 테스트ㅋㅋ")
void testLottoMatchResult() {
    LottoGame lottoGame = new LottoGame();
    assertThat(lottoGame.getResult2()).isGood();    
}

이런식으로 과도기적인 단계를 거쳐 리팩토링을 진행해나가면 급작스러운 상황에서도 컴파일 에러 없이 정상적으로 수행되는 코드 상태를 유지시킬 수 있다. 즉, 기부니가 계속 좋을 수 있다. 다소 귀찮을 수 있어도 좋은 기부니를 유지하기 위해서 과도기적인 리팩토링 단계를 습관화 할 수 있도록 노력하자.


끗!

댓글남기기