최근 수강했던 테스트 코드와 관련된 강의에서 실질적으로 테스트 코드를 작성할 때 생각해봐야하는 구체적 조언에 관한 파트가 있었는데, 해당 내용들을 정리해보고자 한다.
테스트의 단위를 메서드로 잡으려고 생각하다 보면, 메서드가 반환하는 결과의 종류가 여러가지일 수 있다.
간단하게 예시를 들어보자. 두 int형 변수를 더한 값을 반환하는 add()라는 메서드가 있다고 했을 때, add() 메서드의 정상 동작을 확인하기 위한 테스트가 필요하고, int형이 아닌 변수가 들어왔을 때는 동작하지 않는다는 것을 검증해야한다.
// 2개의 int형 변수를 더한 값을 반환하는 메서드
int add(int a, int b){
return a+b;
}
@Test
@DisplayName("add가 정상적으로 동작하는지 확인한다. 메서드에 int형이 아닌 변수가 전달되면 정상동작하지 않는다.")
void testAdd() {
// * given
int a = 1;
int b = 2;
// * when
if (!(a instanceof int) || !(b instanceof int)) {
// 예외가 발생하는 경우를 테스트하려면 아래와 같은 검증코드를 사용해야한다.
assertThatThrownBy(() -> add(?,?))
.isInstanceOf(IllegalArgumentException.class)...
}
int result = add(a,b); -> 만약 a,b가 int형 변수가 아니라면 예외를 발생시키며 테스트 코드는 종료된다.
// * then
assertThat(result).isEqualTo(3); // 정상동작 검증
}
위처럼 하나의 테스트에서 모든 결과를 검증하려고 한다면, 조건문 또는 반복문을 사용하게 될 수 밖에 없고, 이는 추후에 타인이 테스트 코드를 보았을 때, 조건문/반복문에 의해 생기는 논리구조를 해석하기 위해 시간을 소비해야한다. 즉, 테스트의 @DisplayName을 한 문장으로 구성할 수 있을 범위의 정도로만 테스트를 설계하는 것이 바람직하다.
현재시간, 랜덤 값, 외부 시스템과의 연동과 같은 제어할 수 없는 값들을 테스트하고자 하는 메서드 안에 하드코딩하게 되면, 테스트의 결과가 무작위성을 갖게 된다.
예를 들어, 테스트하고자 하는 메서드에 현재시간을 사용하는 코드가 있다고 해보자.
// 시간이 오후인지 확인하는 메서드
boolean isAfterNoon(){
int hour = LocalDateTime.now().getHour();
return hour >= 12 ? true : false;
}
// 제어할 수 없는 값을 상위 계층으로 빼낸 메서드
boolean isAfterNoonFixed(LocalDateTime targetTime){
int hour = targetTime.getHour();
return hour >= 12 ? true : false;
}
@Test
@DisplayName("주어진 시간이 오후인지 확인할 수 있다.")
void testIsAfterNoon() {
// * given
LocalDateTime targetTime = LocalDateTime.of(2023,12,11,13,00,00);
// * when
boolean result1 = isAfterNoon();
boolean result2 = isAfterNoonFixed(targetTime);
// * then
assertThat(result1).isTrue(); -> 현재 시스템 시간에 따라 테스트 결과가 상이하다.
assertThat(result2).isTrue(); -> 항상 True인 테스트 결과를 보장한다.
}
위의 isAfterNoon() 메서드는 메서드 내부에서 LocalDateTime.now()를 통해 현재 시간을 갖고온다. 이런 경우, 테스트를 하는 입장에서 시간 값을 제어할 수 없다. 따라서, 테스트를 실행하는 시간에 따라 테스트가 통과할 수도, 실패할 수도 있다.
그러므로, 이러한 제어할 수 없는 값들을 상위 계층으로 분리하여(ex. 메서드의 매개변수로 빼내기 등) 테스트가 가능한 구조로 만드는 것이 좋다. 상위 계층으로 분리함으로써, 테스트하는 사람이 원하는 값을 직접 주입할 수 있게 해야한다. 또한, 가능하다면 테스트를 하는 동안에는 변동가능성이 있는 값보다는 고정된 값을 지정해서 사용하는 것을 지향해야한다.
given/when/then와 같은 BDD 방식으로 테스트 코드를 작성할 때, given에서 보통 테스트 환경에 대한 구성을 수행한다. 이 때, given절의 환경 구성 과정에서는 현재 테스트의 결과를 변화시킬 수 있는(어쩔 땐 성공, 어쩔 땐 실패) 코드가 포함되면 안된다. 테스트의 주제와 맞지 않는 부분에서 테스트가 실패를 할 수 있고, 테스트의 검증과 결과반환은 when, then절에서 수행되어야하기 때문이다.
예를 들어, 2번의 예시를 그대로 갖고와서 생각해보자.
// (제어할 수 없는 값을 상위 계층으로 빼낸) 시간이 오후인지 확인하는 메서드
boolean isAfterNoonFixed(LocalDateTime targetTime){
int hour = targetTime.getHour();
return hour >= 12 ? true : false;
}
@Test
@DisplayName("주어진 시간이 오후인지 확인할 수 있다.")
void testIsAfterNoon() {
// * given
LocalDateTime targetTime = LocalDateTime.of(2023,12,11,13,00,00);
targetTime.something(); -> 독립성을 해칠 수 있는 코드
// * when
boolean result1 = isAfterNoon();
boolean result2 = isAfterNoonFixed(targetTime);
// * then
assertThat(result1).isTrue(); -> 현재 시스템 시간에 따라 테스트 결과가 상이하다.
assertThat(result2).isTrue(); -> 항상 True인 테스트 결과를 보장한다.
}
위의 예시가 구체적이지 않아서 이해가 안될 수도 있겠지만, 간단하게 targetTime이 오후인지 확인하기전에, 어떤 가공처리를 해야한다고 해보자. 그래서 위의 코드에서 given절에는 targetTime.something() 라는 코드가 들어가 있다.
이럴 경우에, given 절의 환경 구성에서 독립성을 해치는 코드가 존재하기 때문에, 테스트 코드의 맥락을 이해하는데 추가적인 논리적 사고를 요구한다. 즉, something() 이라는 메서드가 어떤 동작을 하는지 추가적으로 파악을 해야한다는 것이다. 또한, 만약 something() 이라는 메서드에서 예외가 발생하게 되면 위의 테스트는 isAfterNoonFixed()를 테스트하려는 것인데, something() 때문에 테스트가 실패하기 때문에 테스트의 근본적인 목적에 어긋나게 되어버린다.
그러므로, given 절에서 테스트 환경을 조성할 때는 해당 테스트 환경의 독립성을 보장하기 위해 가급적 순수한 생성자나 빌더만을 이용하는 것이 좋다. (팩토리 메서드도 의도를 갖고 생성한 메서드이기 때문에 지양해야한다.)
보통 스프링 부트를 이용해 백엔드 서버를 개발하는 경우, Tomcat에서 멀티쓰레드에 대한 처리를 대신 해준다. 그래서 개발자들은 마치 싱글쓰레드 환경이라고 생각하고 실제 비즈니스 로직을 작성한다. 하지만, 실제로는 멀티쓰레드 환경이기 때문에 실제 프로덕션 코드를 개발할 때도 공유자원에 대한 부작용을 고려해야한다.
테스트를 작성할 때도 위에 언급한 부작용을 고려해야한다. 두 가지 이상의 테스트가 하나의 자원을 공유하는 경우, 테스트 간 순서에 따라 테스트의 성공 실패가 갈릴 수 있다. 하지만, 기본적으로 테스트 간에는 순서라는게 없어야한다. 각각의 테스트는 독립적으로 언제 수행되는 항상 같은 결과를 내야한다. 그러므로, 공유자원은 되도록 사용을 지양하는 것이 좋다.
테스트 목적, 테스트 환경을 위해서 원하는 상태 값으로 고정시킨 객체들을 Test Fixture라고 한다.
given절에서 Test Fixture를 구성하다보면, 여러 개의 테스트에 걸쳐 반복적으로 동일한 Test Fixture 구성용 코드가 발생한다. 결국 공통 코드를 줄이기 위해 @BeforeEach 같은 setUp 메서드 로 Test Fixture를 구성하게되는데, 이는 단점이 많다.
그러므로, @BeforeEach를 이용한 setUp 메서드에는 각 테스트의 입장에서 봤을 때,
'setUp 메서드의 내용을 아예 몰라도 테스트 내용을 이해하는데 문제가 없는가?' 를 기준으로 삼으면 좋다.
추가로, 'setUp 메서드를 수정하더라도 모든 테스트에 영향을 주지 않는가?' 를 확인해야 한다.
Test Fixture를 생성할 때 빌더가 길어지는 경우, 이를 메서드로 추출해서 사용하는 경우가 많다. 만약 메서드로 추출해서 생성하는 경우, 해당 메서드의 파라미터에는 테스트 클래스 내에서 필요한 것들만 남겨놓는 것이 좋다.
Test Fixture를 클렌징해주는 과정에서, @Transactional을 통해 트랜잭션 롤백을 해줄 수도 있고, deleteAll()이나 deleteAllInBatch()를 이용해서 직접 데이터를 지워줄수도 있다. 세개 모두 가능한 방법이지만, 각각의 방법이 내부적으로 어떤 방식으로 수행되는지, 어떤 사이드 이펙트를 갖고 있는지 를 고려해서 사용해야한다.
하나의 테스트 주제에 여러가지 케이스가 있는 경우에 하나의 테스트 코드 내에서 모든 케이스를 다루고 싶을 수 있다. 하지만 위에서도 얘기했듯, 이를 수행하기 위해 테스트 코드 내에 조건문 또는 반복문을 넣는 것은 지양해야한다. 만약 단순하게 하나의 테스트 주제인데 값을 여러 개로 바꿔보면서 테스트 하고 싶다면, @ParameterizedTest 를 사용해야 한다.
아래는 1번 예시를 @ParameterizedTest를 이용해 여러 케이스를 테스트하는 예시이다.
// 2개의 int형 변수를 더한 값을 반환하는 메서드
int add(int a, int b){
return a+b;
}
@Test
@ParameterizedTest
@CsvSource({"1,3,4","123,456,579","wrong,wrong,wrong"})
@DisplayName("add가 정상적으로 동작하는지 확인한다. 메서드에 int형이 아닌 변수가 전달되면 정상동작하지 않는다.")
void testAdd(int a , int b, int expected) {
// * when
if (!(a instanceof int) || !(b instanceof int)) {
// 예외가 발생하는 경우를 테스트하려면 아래와 같은 검증코드를 사용해야한다.
assertThatThrownBy(() -> add(?,?))
.isInstanceOf(IllegalArgumentException.class)...
}
int result = add(a,b); -> 만약 a,b가 int형 변수가 아니라면 예외를 발생시키며 테스트 코드는 종료된다.
// * then
assertThat(result).isEqualTo(expected); // 정상동작 검증
}
위의 예시처럼 @ParameterizedTest와 @CsvSource를 이용해서 여러 케이스를 하나의 테스트 코드로 다룰 수 있다. @CsvSource 말고도 다양한 Source용 어노테이션이 있으니 이를 활용하면 된다.
주의해야할 점은 1번에서 얘기했던 것처럼 '한 가지 테스트에서는 한 가지 목적의 검증만 수행하는 것이 좋다.' 를 명심해야한다. @ParameterizedTest를 이용한 테스트가 한 가지 목적의 검증을 벗어나지 않아야한다.
테스트에서 공유자원은 되도록 사용을 지양하는 것이 좋다. 하지만, 하나의 환경을 설정해 놓고, 이 환경에 변화를 주면서 중간중간 검증 + 특정 행위를 했을 때 검증하는 등의 시나리오를 테스트 하고 싶을 때가 있다.
즉, 공통의 환경으로부터 출발하여 단계별로 어떤 행위와 검증을 계속 수행하고 싶을 수 있다.
이때, @DynamicTest를 사용해야 한다.
// 2개의 int형 변수를 더한 값을 반환하는 메서드
int add(int a, int b){
return a+b;
}
@TestFactory
@DisplayName("add() 메서드를 이용한 DynamicTest 예시")
Collection<DynamicTest> DynamicTestAdd() {
// * given
int a = 1;
int b = 2;
int c = 3;
int d = 4;
int e = 5;
int f = 6;
return List.of(
DynamicTest.dynamicTest("a+b",() ->{
// * when
int result = add(a,b);
// * then
assertThat(result).isEqualTo(3);
}),
DynamicTest.dynamicTest("c+d",() ->{
// * when
int result = add(c,d);
// * then
assertThat(result).isEqualTo(7);
}),
DynamicTest.dynamicTest("e+f",() ->{
// * when
int result = add(e,f);
// * then
assertThat(result).isEqualTo(11);
})
);
}
위의 예시가 환경의 변화를 주거나 단계별로 어떤 행위를 검증하는 의미를 갖고 있진 않지만, 아주 간단한 예시라고 생각하자. @TestFactory와 DynamicTest를 이용해 여러개의 테스트를 List로 반환하면, 반환된 순서에 맞게 테스트가 수행된다.
Gradle의 test Task 같은 동작으로 여러 개의 테스트 클래스를 한번에 실행시키는 경우, 테스트의 환경에 따라 스프링 부트 서버가 여러번 실행되는 상황이 생길 수 있다. 서버가 여러번 실행되는 만큼 테스트 총 실행시간은 길어진다.
이를 줄이기 위해서는, 동일한 테스트 환경을 필요로 하는 테스트끼리 환경을 묶어서 TestSupport 같은 클래스를 만들어 상속 받아서 부트 서버가 한번 실행됐을때 여러 개의 테스트가 모두 동작하도록 하는 것이 좋다.
테스트 환경에 따라 TestSupport 클래스는 여러개 일 수 있다.
Mocking 여부에 따라서 테스트 환경이 달라지는 것도 당연하게 고려를 해야한다. 특정 테스트에서는 특정 빈을 Mocking을 처리하였는데, 또 다른 테스트에서는 해당 빈을 Mocking 처리하지 않았다면, 다른 테스트 환경이 구성되어야하는 것이고, 따라서 스프링 컨테이너의 구성이 달라져야한다.
어떤 객체 혹은 클래스가 공개 API, 즉 public 메서드를 갖고 있다는 것은 외부에서 이를 사용하는 다른 클라이언트가 그것만 알고 있으면 된다 는 것이다.
따라서, 테스트 클래스 또한 특정 객체 혹은 클래스를 테스트할 때 이를 사용하는 클라이언트이므로 공개 API만 알면된다. 클라이언트 입장에서는 외부로 노출되지 않은 내부 기능까지 알아야할 필요가 없다. private 메서드를 테스트하지 않아도 내부적으로 이를 사용하는 public 메서드를 테스트하다보면 자연스럽게 같이 검증이 된다라는 관점으로 바라봐야한다.
만약 private method가 테스트를 통해 검증해야된다고 생각된다면, 해당 시점에 '객체를 분리할 시점인가?' 에 대해 고민해봐야한다.
테스트에만 필요한 메서드가 생겼는데 프로덕션 코드에서는 필요없다면(ex. 요청용 DTO 빌더), 해당 메서드를 만들어놓되, 보수적으로 접근해야한다. 무엇을 테스트하고 있는지를 명확하게 인지하고, 그에 필요한 코드만 작성하는 것이 좋다.