본문 바로가기

[TDD 시작하기] 후기 및 정리

by hseong 2023. 6. 4.

0.

테스트를 작성하는 올바른 방법에 대해 학습할 적절한 자료를 찾던 중 최범균님의 '테스트 주도 개발 시작하기' 에 대해 알게 되었습니다.

TDD에 대한 기초 뿐만 아니라 테스트 작성 시 주의점, Junit5, Mockito, AssertJ 등 테스트를 작성하는데 필요한 전반적인 지식을 쌓을 수 있는 책이었습니다. 제가 실제 프로젝트를 진행하면서 작성한 테스트 코드에 어떠한 문제점들이 있었는지 알 수 있었습니다. 만일 테스트 코드를 작성하는 방법에 대해 지식과 경험이 있으시다면 적절한 책은 아닙니다.

본 게시글에서는 해당 서적의 몇 가지 내용에 대해서 정리해보고자 합니다.

 

1. TDD란

TDD란 무엇인가?

TDD는 기능을 검증하는 테스트 코드를 먼저 작성하고 이를 통과시키기 위한 개발을 진행한다.

테스트를 작성하면서 아직 존재하지 않는 클래스와 메서드를 사용한다. 이 과정에서 이름은 어떤 것을 사용할지, 파라미터는 몇 개나 전달해야할지 등에 대해서 충분한 고민을 거친다. 테스트를 작성했다면 컴파일 에러가 발생할 것이다. 이를 해결하기 위해 클래스를 생성하고, 메서드를 추가한다.

실행 가능한 테스트를 작성했다면 실제로 실행해본다. 기능 구현이 되어 있지 않기 때문에 테스트는 실패한다. 그리고 테스트를 통과할만큼만 기능을 구현한다. 간단하게 상수를 반환하여 통과시킬 수도 있다. 통과한 것을 확인했다면 테스트 예를 추가하여 여전히 통과하는지 검증한다. 만일 통과하지 않는다면 다시 통과할만큼 기능을 구현한다. 조건에 따른 상수를 반환하도록 구현할 수도 있고, 아니면 일반화를 통해 새로운 로직을 구현할 수도 있다.

이처럼 TDD는 테스트를 먼저 작성하고 테스트에 실패하면 테스트를 통과시킬 만큼 코드를 추가하는 과정을 반복하며 점진적으로 기능을 완성해 나간다. 테스트 케이스를 추가하면서 리팩토링의 여지가 보인다면 리팩토링 역시 계속해서 수행하여 가독성, 로직을 개선해나간다.

기능 구현이 완료되었다면 test 폴더 아래에 작성한 클래스들을 배포 대상인 main 폴더로 이동시켜 구현을 종료한다.

 

TDD 흐름

TDD는 기능을 검증하는 테스트를 먼저 작성한다.

작성한 테스트가 통과하지 못하면 테스트가 통과할 만큼만 코드를 작성한다.

테스트를 통과한 뒤에 코드의 가독성, 로직의 개선 여지가 있다면 리팩토링을 수행한다. 리팩토링을 완료한 뒤에는 다시 테스트 실행하여 기존에 통과한 테스트가 여전히 통과하는지 확인한다.

이 과정을 반복하면서 점진적으로 기능을 완성해나가는 것이 전형적인 TDD의 흐름이다.

 

테스트 코드 작성 순서

테스트 케이스를 작성할 때는 아래의 순서에 따라 점진적으로 작성한다.

  • 쉬운 경우에서 어려운 경우로 진행
  • 예외적인 경우에서 정상인 경우로 진행

초반부터 복잡한 조합을 검사하는 테스트 코드를 작성하는 경우 해당 테스트를 통과시키기 위해 한 번에 구현해야 할 코드가 많아진다.

구현하기 쉬운 테스트부터 시작하기

구현하기 쉬운 테스트부터 점진적으로 기능을 구현해나간다. 한 번에 구현하는 시간이 짧아지면 머릿속에 작성한 코드의 내용도 생생히 남아있기 때문에 디버깅 시에도 유리하다.

예외 상황을 먼제 테스트하기

다양한 예외 상황은 복잡한 if-else 블록을 동반할 때가 많다. 예외 상황을 고려하지 않은 코드에 예외 상황을 추가하려다 보면 코드의 구조를 뒤집거나 예외 상황을 처리하기 위한 조건문을 중복해서 추가하는 일이 발생하여 코드를 복잡하게 만든다.

초반에 예외 상황을 미리 테스트하면 예외 상황을 위한 if-else 구조가 미리 만들어지기 때문에 많은 코드를 완성한 뒤에 예외 상황을 반영할 때보다 코드 구조가 덜 바뀐다.

완급 조절

테스트를 만들고 통과시키는 과정에서 구현이 막힐 때가 있다. 이럴 때 아래 단계에 따라 점진적으로 구현을 진행하자.

  1. 정해진 값을 리턴
  2. 값 비교를 이용해서 정해진 값을 리턴
  3. 다양한 테스트를 추가하면서 구현을 일반화

지속적인 리팩토링

테스트를 통과한 뒤에는 리팩토링을 진행한다. 적당한 후보가 보이지 않는다면 다음 테스트 후로 미뤄도 괜찮다. 리팩토링을 통해 가독성을 높이고 유지보수성을 높이자.

테스트하다보면 큰 범위의 리팩토링이 필요할 수도 있다. 이때는 큰 리팩토링을 다음 할 일 목록에 추가시키고 테스트를 통과시키는데 집중한다.

테스트할 목록 정리하기

TDD를 시작할 때 테스트할 목록을 미리 정리하면 좋다. 테스트 중에 새로운 사례를 발견하면 그 사례를 추가해서 놓치지 않도록 한다.

하나의 테스트 코드를 만들고 이를 통과시키고 리팩토링하고 다시 테스트 코드를 만드는 과정을 반복하자. 짧은 개발 주기를 통해 개발 집중력을 향상시키자.

시작이 안 될때는 단언부터 고민

이럴 땐 검증하는 코드부터 작성해본다. 검증 대상을 어떻게 표현할지, 이를 계산하기 위한 코드는 어떻게 작성할지, 파라미터는 몇개나 전달하지 차례차례 생각해보자.

구현이 막히면

어떻게 해야 할지 생각이 잘 나지 않거나 무언가 잘못한 느낌일 들 때, 과감하게 코드를 지우고 다시 시작한다. 어떤 순서로 테스트를 진행했는지 생각하고 순서를 바꿔서 다시 진행해본다.

  • 쉬운 테스트, 예외적인 테스트
  • 완급 조절

 

2. 테스트를 위해 필요한 지식

대역

스텁(Stub)

  • 구현을 단순한 것으로 대체한다.
  • 테스트에 맞게 단순히 원하는 동작을 수행한다.

가짜(Fake)

  • 제품에는 적합하지 않지만, 실제 동작하는 구현을 제공한다.
  • 실제 DB 대신에 인메모리 데이터베이스를 구현하는 것이 이에 해당한다.

스파이(Spy)

  • 호출된 내역을 기록한다.
  • 기록한 내용은 테스트 결과를 검증할 때 사용한다.
  • 스텁이기도 하다.

모의(Mock)

  • 기대한 대로 상호작용하는지 행위를 검증한다.
  • 기대한 대로 동작하지 않으면 익셉션을 발생할 수 있다.
  • 스텁이자 스파이도 된다.

TDD 과정에서 실제 구현을 사용한다면 불필요한 테스트 시간이 길어질 수 있다. 이러한 상황에서 적절한 대역을 사용한다면 개발 속도를 높이는 데 도움이 된다.

 

테스트 가능한 설계

  • 하드 코딩된 상수를 생성자나 메서드 파라미터로 받기
  • 의존 대상을 주입 받기
  • 테스트하고 싶은 코드를 분리하기
  • 시간이나 랜덤 값 생성 기능 분리하기
  • 외부 라이브러리는 직접 사용하지 말고 감싸서 사용하기

 

유지보수 하기 좋은 테스트

변수나 필드를 사용해서 기댓값 표현하지 않기

검증 대상과 기대한 대상의 값이 서로 다르면 어째서 그런 결과가 나왔는지 변수와 필드를 오가며 확인해야 한다. 변수나 필드 대신 실제 값을 사용하자.

두 개 이상을 검증하지 않기

하나의 테스트에서 두 가지 개념을 검증하지 말자. 테스트에 실패했을 때 어떤 개념에 문제가 있었는지 일일히 확인해야 한다.

만일 하나의 테스트가 두 가지 개념을 검증한다면 서로 다른 테스트로 분리하자.

정확하게 일치하는 값으로 목 객체 설정하지 않기

given(memberRepository.findById(1L)).willReturn(member);

이러한 코드는 작은 변화에도 실패한다. 코드의 실행을 위해 1L 이 아닌 다른 값을 인자로 전달하게 되면 테스트는 빨간 불이 들어오고 어디서 실패했는지 불필요한 시간을 소모하게 된다.

목 객체는 가능한 범용적인 값을 사용해서 기술해야 한다. Mockito.any(), Mockito.anyLong() 과 같은 범용적인 값을 전달하자.

과도하게 구현 검증하지 않기

then(memberRepository).should().findById(any());

내부 구현을 검증하는 것이 나쁜 것은 아니다. 그러나 구현이 조금이라도 변경되는 순간 테스트가 깨질 가능성이 생기게 된다.

내부 구현은 언제든지 바뀔 수 있기 때문에 내부 구현보다는 테스트 결과를 검증하는데 집중해야 한다.

셋업을 이용해서 중복된 상황을 설정하지 않기

@BeforeEach 메서드를 통해 각 테스트 메서드에 동일한 상황을 줄 수 있다.

시간이 흐른 후 해당 테스트를 다시 돌려서 빨간불이 뜬다면 내가 어떤 상황을 설정했는지 확인하기 위해 테스트 메서드와 @BeforeEach 메서드를 번갈아가면서 확인하는 상황이 발생할 수 있다.

다른 문제로는 테스트가 깨지기 쉬운 구조가 된다. 모든 테스트 메서드가 동일한 상황을 공유하기 때문에 조금만 내용을 변경해도 테스트가 깨질 수 있다.

테스트 메서드는 검증을 목표로 하는 하나의 완전한 프로그램이어야 한다. 각각의 테스트 메서드는 그 자체만으로 검증 내용을 잘 설명할 수 있어야 한다. 이를 위해서는 상황 구성 코드가 테스트 메서드 내부에 위치해야 한다.

통합 테스트에서 데이터 공유 주의하기

통합 테스트 코드를 만들 때는 다음 두 가지로 초기화 데이터를 나눠서 생각해야 한다.

  • 모든 테스트가 같은 값을 사용하는 데이터
  • 해당 테스트 메서드에서만 피룡한 데이터

통합 테스트의 상황 설정을 위한 보조 클래스 사용하기

테스트 메서드에서 상황을 구성하면서 코드의 중복을 줄이고 싶다면 특정 상황을 만들어주기 위한 보조 클래스를 만들어서 사용해보자.

실행 환경이 다르다고 실패하지 않기

대표적으로 파일 경로가 있다. 윈도우와 맥의 경로는 서로 다르다. 이럴 때는 프로젝트 폴더를 기준으로 한 상대 경로를 사용할 수 있다.

실행 시점이 다르다고 실패하지 않기

LocalDate 와 같이 시간을 다루는 클래스를 검증 대상으로 사용하는 경우 명확한 시간을 지정해주어야 한다. 만일 LocalDate.now() 와 같이 테스트 실행 시점에 따라 값이 달라지는 경우 시간이 흘러 테스트가 통과하지 못하는 경우가 생길 수 있다.

랜덤하게 실패하지 않기

대표적으로 Random 클래스를 이용해서 테스트를 작성하는 경우가 있다. 이 경우 테스트를 실행할 때마다 주어진 상황이 달라져 랜덤하게 실패하는 테스트가 만들어지게 된다.

필요하지 않은 값 설정하지 않기

검증하고자 하는 값에만 집중할 수 있어야 한다. 상황 설정을 위해 불필요한 값을 설정하는 상황을 최대한 줄이자.

상황 설정을 위해 적극적으로 보조 클래스, 빌더 패턴을 활용하자.

조건부로 검증하지 않기

조건문을 이용해서 특정 상황에서만 검증이 수행되어선 안된다.

만일 조건에 따라 실패해야 하는 테스트라면 assertThat(actual).isTrue() 와 같이 해당 조건이 true인지 검사를 통해 실패한 테스트를 놓치는 것을 방지하자.

통합 테스트는 필요하지 않은 범위까지 연동하지 않기

대표적으로 @SpringBootTest가 있다. 해당 애노테이션을 사용하는 경우 모든 스프링 빈을 초기화하는 과정을 통해 테스트 실행 시간이 오래 걸리게 된다. 만일 DB와 연동하는 부분만 처리하고 싶다면 @DataJpaTest 와 같이 DB 연동과 관련된 설정만 초기화하는 방법이 있다.

더 이상 쓸모없는 테스트 코드 제거하기

어떤 클래스의 사용 방법을 익히기 위한 테스트는 사용법을 익혔다면 오래 유지할 필요는 없다. 또한, 아주 간단한 상황을 테스트하는 코드도 마찬가지이다.