본문 바로가기
우아한테크코스 4기/레벨1

[레벨 1 돌아보기] 테스트 코드 작성을 위한 규칙

by 나는후니 2022. 4. 17.

레벨 1 미션의 주요 학습 목표는 TDD입니다.

 

그 이후 모든 과제를 TDD로 수행하다보니, 좋은 테스트 코드를 작성하기 위한 규칙을 만들어야 했는데요. 레벨 1을 돌아보며 좋은 테스트 코드를 작성하기 위한 규칙을 재정립하고자 이 포스팅을 작성하게 됐습니다.

 

먼저 좋은 테스트 코드라는 정의를 내리기 전에 테스트 코드를 왜 작성해야 하는지가 중요합니다.

저도 지금 생각해보니 학습목표이기 때문에 관성적으로 작성했다 라는 답변이 가장 먼저 떠올랐습니다. 이건 이유가 되지 않는 것 같고 다른 이유를 좀 더 고민해보았습니다.

테스트는 왜 작성하는가?

저는 아래 3가지 이유로 테스트 코드를 작성해왔습니다.

1. 시간 단축

하나의 기능을 구현할 때마다 실제 애플리케이션을 실행한다면 얼마나 불편할까요? 특히 나중에 여러 라이브러리가 포함된 애플리케이션을 실행한다면 길게는 1분 이상 시간이 걸립니다. 그리고 정해진 시나리오가 없어 매번 시나리오대로 세팅을 해줘야 하기도 합니다.

 

테스트 코드를 작성하면 애플리케이션을 여는 것은 동일하지만 충분히 최적화할 수 있고 원하는 시나리오대로 테스트하고자하는 메서드를 실행할 수 있습니다.

2. 가시성

테스트 라이브러리를 이용하면 테스트의 성공, 실패 여부를 가시적으로 보여줍니다. 실제 반환값을 print하지 않고도 충분히 테스트하고자 하는 내용이 올바른지 확인할 수 있는 것이죠.

 

눈버그를 하지 않아도 된다는 장점이 있습니다.

3. 심적 안정감

테스트 코드를 작성하면 심적 안정감(?)이 생깁니다. 새 기능이 추가될 때 기존 테스트 코드를 함께 실행하며 버그가 발생하지 않는지 확인할 수 있습니다. 또 요구사항에 맞게 짜여진 단위 테스트가 있다면 유지보수시 놓치고 넘어가는 부분을 test fail로 확인할 수 있어 안정감이 생깁니다.

 

위 세가지 테스트 코드 작성 이유를 모두가 공감하지 않을 수는 있지만 저는 이런 생각을 갖고 테스트를 작성해 왔습니다. 그렇다면 이제부터 가시성, 시간 단축, 심적 안정감이라는 테스트 작성 이유를 모두 만족하기 위한 저만의 테스트 코드 작성 규칙을 소개하고자 합니다.

1. @DisplayName을 잘 활용하자

단위 테스트 작성에 대한 모범 사례 의 내용 중 일부에는 테스트 이름 지정에 대해 아래와 같이 설명합니다.

테스트의 이름은 다음 세 부분으로 구성되어야 합니다.

1. 테스트할 메서드의 이름입니다.
2. 테스트 중인 시나리오입니다.
3. 시나리오에서 호출될 때 예상되는 동작입니다.

저는 테스트를 작성하는 이유 중 하나로 가시성을 꼽았습니다.

의미있는 테스트 이름을 작성하는 것은 테스트가 실패했을 때 코드를 자세히 확인하지 않아도 어떤 시나리오에서 실패가 발생하는지 쉽게 확인할 수 있습니다.

성공/실패한 테스트 모음이 하나의 문서가 되는 것이죠.

@Test
@DisplayName("1+1의 합은 2이다.")
void sum() {
    Calculator cal = new Calculator();
    int res = cal.sum(1, 1);
    assertThat(res).isEqualTo(2);
}

물론 메서드 이름으로 한글을 지원하긴 하지만 한글을 사용했을 때 빌드 과정에서 발생하는 경고를 제거하기 위해 의미 있는 메서드명과 자세한 시나리오를 드러내는 @DisplayName을 사용하는 편입니다.

2. 상호 독립적인 테스트를 작성하자

하나의 기능을 테스트하는 데 있어 다른 테스트의 내용을 이해할 필요는 전혀 없습니다.

게다가 만약 테스트 간 연결성이 존재한다면 순서를 보장하지 않는 테스트의 성격상 여러가지 문제가 발생할 위험이 높습니다.

과연 아래 시나리오가 매번 통과할 것인가?

Wallet wallet = new Wallet();

@Test
void addMoney() {
    wallet.add(1000);
    assertThat(wallet.getMoney()).isEqualTo(1000);
}

@Test
void pay() {
    wallet.pay(500);
    assertThat(wallet.getMoney()).isEqualTo(500);
}

더불어 상호 연결성이 존재하는 테스트 코드는 테스트 하고자 하는 내용을 파악하는 것이 굉장히 어렵습니다.

 

@BeforeEach와 독립적인 단위 테스트 라는 주제로 글을 작성했는데, 요약하자면 테스트의 구성 요소를 @BeforeEach 테스트 메서드에서 작성하는 것은 테스트 목적 혼동, 원치 않는 종속성 발생등의 이유로 독립적 단위테스트 작성에 독이 된다고 말하고 있습니다.

3. 테스트 환경에 한해서 @BeforeEach를 작성하자

테스트의 구성요소는 하나의 메서드로 분리하여 의미있는 이름으로 작성하고 테스트 메서드에서 사용해야 합니다.

하지만 테스트 환경을 구성하는 부분은 충분히 @BeforeEach 키워드를 사용할 수 있습니다.

MemberRepository memberRepository;

@BeforeEach
void setUp() {
    memberRepository = new FakeMemberRepository();
}

테스트 메서드에서 변경될 여지가 없는 테스트 환경같은 경우에는 위 코드처럼 사용해도 독립된 테스트를 유지할 수 있습니다.

4. 구현이 아닌 설계를 테스트하자

처음 테스트 코드를 작성할 때는 private 메서드도 모두 검증해야 하는 것 아닐까? 라는 고민이 들었습니다.

하지만 결국 private 메서드는 하나의 기능으로 캡슐화되어 있다는 의미입니다.

즉, 굳이 추상화한 내용을 하나하나 뜯어 테스트를 할 필요가 없는 것이죠.

 

만약 기능 내 모든 메서드를 테스트하다 해당 기능이 리팩터링 된다면 여러 개의 테스트가 깨지는 현상이 발생하게 됩니다.

예를 들어 로또번호 6개를 생성한다 라는 요구사항이 생긴다면 상세 구현을 테스트 하는 것이 아니라

* 로또번호는 6개이다.
* 로또번호는 중복되지 않는다.
* 로또번호의 범위는 1~45이다.

위 리스트와 같이 기능(설계)을 중심으로 테스트 하는 것이 리팩터링 내성을 가지면서 안정적인 테스트 코드를 만듭니다.

5. 제어할 수 없다면 추상화하거나 상위레벨로

테스트 코드를 작성하다 보면 제어할 수 없는 상황에 대한 테스트를 작성하는 데 큰 시간을 소비합니다.

특히 로또미션에서 랜덤한 로또 번호를 생성받아 테스트 하는 것이 난제였는데요.

이 문제를 해결하는 방법은 두 가지가 존재했습니다.

  • 상위레벨로 올려 주입한다.
public class Lotto {
    private final List<Integer> numbers;    
    public Lotto(final List<Integer> numbers) {
        // 6개고 중복아님 검증
        this.numbers = numbers;
    }
}

로또 번호가 랜덤인지 아닌지는 중요하지 않고 6개의 중복되지 않는 수가 넘어온다면 로또가 잘 생성되도록 변경하면 테스트가 쉬워집니다.

  • 추상화

 

interface NumberGenerator {
  List<Integer> generate();
}

class LottoMachine {
private final NumberGenerator numberGenerator;

public LottoMachine(NumberGenerator numberGenerator) {
 this.numberGenerator = numberGenerator; 
 }
}

NumberGenerator를 주입하는 객체를 만들어 사용합니다.

NumberGenerator를 시나리오에 맞게 상속받아 사용하면 테스트하기 편한 구조로 변합니다.

다만 테스트 코드를 위해 프로덕션의 구조를 바꾸는 것은 지양해야 하고 어떤 방식으로 코드를 작성하는 게 적절한지 먼저 판단한 후에 이 규칙을 수행하는 것이 맞다고 생각합니다.