테스트 코드
테스트 코드 관련 용어 정리
실제 코드
실제로 서비스 제공을 위해 작성된 코드로 테스트 대상이 되는 코드다.
테스트 코드
실제 코드를 테스트하기 위해 실제 코드의 구성 요소를 참조해 함수를 호출하는 등의 동작을 하는 코드다.
일반적으로 실제 코드가 작성된 파일과 이를 테스트하는 코드가 작성된 파일은 일대 일 대응한다. 예를 들어 실제 코드를 Ball.lang에 작성했다면 테스트코드는 BallTest.lang에 작성 하는 것이 일반적이다.
테스트 케이스
각 테스트 코드에서 테스트 해야 할 시나리오를 구현한 것이다. 일반적으로 하나의 함수로 작성된다.
아주 단순한 테스트 코드가 아니라면, 기본적으로 3가지 단계를 가진다.
1.
준비 : 테스트를 위한 인스턴스를 생성하거나, 의존성을 설정하는 단계.
2.
실행 : 테스트를 위해 실제 코드의 함수를 호출하는 단계.
3.
단언 : 실행 단계에서 실행한 결과가 원하는 대로 반환되었는지 확인하는 단계.
위 단계는 given, when, then 이라고 불리기도 한다.
테스트 러너
테스트 코드를 실행하는 도구. 테스트 코드의 실행 결과를 보여준다.
테스트 코드의 필요성
테스트코드는 말 그대로 테스트를 위한 코드다. 실제로 서비스를 제공하는 코드가 정상적으로 작동하는지 확인하는 코드다.
코드를 단 한 줄만 고쳐도, 소프트웨어는 전혀 다르게 동작할 수 있다. 즉, 코드의 수정은 소프트웨어를 고장낼 가능성을 가지고 있다. 만약, 개발자가 자신이 수정한 코드가 소프트웨어가 의도대로 동작하는지 확인할 수단을 가지고 있다면, 코드를 수정하기 쉬워진다. 그 수단 중 하나가 테스트 코드다.
다시 말해, 테스트 코드는 실제 코드가 정상적으로 동작한다는 것을 보장하기 위한 것이다. 더 나아가, 테스트 코드가 제대로 작성되어있다면, 실제 코드를 리팩토링 할 경우에도 코드가 의도대로 동작한다는 것을 알 수 있기 때문에, 테스트 코드는 실제 코드의 재사용성을 높이고, 유지보수하기 쉽게 만든다.
테스트 코드는 실제 코드를 변경하기 쉽게 만들고, 유지보수하기 쉽게 만든다.
그러나, 동시에 잘못된 테스트 코드는 이런 특성을 정 반대로 발휘한다. 잘못된 테스트 코드는 실제 코드를 유지보수하기 어렵게 만든다.
없는 것만 못한 테스트 코드
테스트 코드가 제대로 실제 코드를 테스트하지 못하거나, 수정하기 어렵다면 테스트코드가 없는 것 보다 못하다.
테스트 코드가 실제 코드를 제대로 테스트하지 못한다면, 테스트 코드는 쓸데 없는 수준을 넘어 해를 끼치는 악마가 된다. 테스트 코드를 통과하면 안되는 경우를 통과시킨다면 이는 실제 서비스의 장애로 이어질 수 있고, 테스트 코드를 통과해야하는 경우를 통과시키지 않는다면 이는 쓸데 없이 개발자를 고민하게 만들고 야근이나 최악의 경우 일정 전체에 문제가 생기는 상황을 만들 수 있다.
테스트 코드를 수정하기 힘든 경우 역시 테스트 코드는 악마가 된다. 실제 코드의 버그를 수정한 것이 아니라 기능을 추가하거나 변경한 것이라면, 당연히 이를 테스트하는 테스트 코드 역시 변경되어야 한다. 그런데, 테스트 코드를 수정하기 어렵다면 개발자는 테스트 코드를 다시 작성하는데 많은 고민을 해야 한다. 이는 개발자가 테스트 코드를 아예 스킵하도록 유혹한다.
따라서, 개발자는 좋은 테스트 코드를 작성해야 한다. 좋은 테스트코드는 실제 코드를 잘 테스트하고, 수정하기 쉬워야 한다.
좋은 테스트 코드의 조건
테스트 코드는 실제 코드가 수정된 경우에만 실패해야 한다.
테스트 코드가 실제 코드가 수정되지 않은 경우에도 실패할 수 있다면, 개발자는 테스트의 실패가 정상적인 상황인지 알 수 없다. 즉, 테스트 코드가 양치기 소년이 되는 것이다.
반대로, 테스트 코드는 실제 코드의 버그 수정이나 리팩토링이 아닌 기능의 추가가 발생한 경우 반드시 실패해야 한다. 실제 코드의 동작이 변경되었다면, 당연히 실제 코드의 동작을 테스트하는 기준 역시 변경되었을 것이다. 그런데, 테스트 코드가 실패하지 않았다면, 기존의 테스트 코드가 테스트 하지 않거나 잘못된 테스트를 수행하고 있었다는 뜻이다.
기능 변경이 없다면 테스트 코드는 반드시 성공해야 한다. 반대로, 기능을 변경했다면 테스트 코드 역시 변경되어야 한다.
테스트 코드는 변경에 유연해야 한다.
실제 코드에 기능적인 변경이 발생한 경우, 테스트 코드 역시 변경되어야 한다. 따라서, 테스트 코드는 실제 코드만큼 변경에 유연해야 한다. 코드가 변경에 유리하다면, 자연스럽게 가독성도 좋아진다. 테스트 코드의 가독성이 좋으면 테스트의 의미를 명확히 판단할 수 있기 때문에 유리하다.
테스트 코드는 변경에 유연해야 한다. 그렇게 하면 자연스럽게 가독성도 좋아진다.
테스트 코드는 쉽게 실행할 수 있어야 한다.
실제 코드의 리팩토링 과정에서 개발자는 자신이 수정한 코드가 여전히 의도대로 동작하는지 확인해야 한다. 앞서 말한 대로 이 때 테스트 코드를 사용해 테스트를 수행한다.
테스트 코드의 실행이 오래 걸린다면, 테스트를 자주 수행할 수 없고, 자신이 수정한 코드에 문제가 있음을 너무 늦게 알게 될 수 있다. 최악의 경우, 테스트 수행을 스킵하는 선택을 할 수 있다.
테스트 케이스의 결과는 쉽고 명확하게 알 수 있어야 한다.
테스트 코드는 자주 실행되기 때문에, 그 결과를 분석하는데 시간이 오래 걸린다면 비효율적이다. 따라서, 별도의 파일을 분석하거나, 출력을 눈으로 비교하는 것 보단, 직관적으로 알 수 있어야 한다.
테스트가 실패한 경우, 실패한 이유가 무엇인지, 실패가 의미하는 것이 무엇인지 간단한 출력문으로 표현되어야 한다.
테스트 케이스는 독립적으로 수행되어야 한다.
여러 테스트 케이스 사이에 순서가 있어서, 한 테스트 케이스가 다른 테스트 케이스의 준비를 하면 안된다.
테스트 케이스 사이에 순서가 있다면, 앞의 테스트 케이스의 실패로 인해, 다른 테스트 케이스가 실패할 수 있다. 이와 동시에 앞의 테스트 케이스 실패와 무관한 문제로 인해 다른 테스트 케이스가 실패할 수도 있다. 이는, “테스트 케이스의 결과를 쉽고 명확하게 알 수 있어야 한다”는 규칙을 위반한 것이다.
좋은 테스트 코드를 만들기 위한 구체적인 방법
실제 코드의 가능한 모든 동작을 테스트한다.
기능이 정상적으로 동작하는 일반적인 입력 뿐 아니라, 특별한 입력에 대해 정상적으로 처리하는지, 예외가 발생해야 하는 상황에서 지정된 예외가 발생하는지 확인해야 한다.
이를 위해선 일반적으로, 기능(하나의 함수 혹은 메서드)에 대해 여러개의 테스트 함수가 필요하다.
예시 - 로또 생성
package christmas.domain.menu;
class SelectedMenusTest {
@DisplayName("없는 메뉴")
@ParameterizedTest
@ValueSource(strings = {"aaa", "bbb", "cxccc", "크림수프"})
void unKnownMenu(String menuName) {
Assertions.assertThatThrownBy(() -> new SelectedMenus(Map.of(menuName, 1)))
.isInstanceOf(PolicyViolationException.class)
.hasMessageContaining(NO_SUCH_MENU.getMessage());
}
@DisplayName("총 주문 개수 초과")
@Test
void totalMenuCountOverFlow() {
Assertions.assertThatThrownBy(() -> new SelectedMenus(Map.of("양송이수프", 21)))
.isInstanceOf(PolicyViolationException.class)
.hasMessageContaining(MENU_SELECTED_COUNT_OVERFLOW.getMessage());
}
@DisplayName("오직 음료만")
@Test
void onlyBeverage() {
Assertions.assertThatThrownBy(() -> new SelectedMenus(Map.of("제로콜라", 1, "레드와인", 1, "샴페인", 1)))
.isInstanceOf(PolicyViolationException.class)
.hasMessageContaining(BEVERAGE_ONLY_SELECTED.getMessage());
}
}
Java
복사
기능은 분명히 메뉴를 선택하는 기능이지만, 이를 테스트 하는 테스트 케이스는 총 3가지를 작성했다. 이는 예외가 발생해야 하는 상황에서 예외가 발생하는지 확인하는 테스트였다.
테스트 케이스는 한가지 동작만 테스트한다.
테스트 케이스는 한가지 동작만 테스트한다. 일반적으로, 실제 코드의 하나의 함수만을 테스트 하는 것이 좋다. 물론, 경우에 따라서 융통성있게 적용해야 한다.
package racingcar;
class InputManagerTest {
@Test
void 자동차_이동_시도_횟수_테스트() {
assertThatThrownBy(() -> InputManager.getMoveTryCount("a"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining(ExceptionMessage.POSITIVE_INTEGER);
assertThatThrownBy(() -> InputManager.getMoveTryCount("1234567891234"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining(ExceptionMessage.POSITIVE_INTEGER);
assertThat(InputManager.getMoveTryCount("1234"))
.isEqualTo(1234);
}
}
Java
복사
InputManagerTest 일부
위 테스트 코드는 자동차 이동 시도 횟수 입력에서 숫자가 아닌 입력을 확인하는 동작과, 너무 큰 숫자인지 확인하는 동작, 일반적인 입력에서의 동작을 함께 테스트하고 있다.
앞의 assert 메서드에서 실패했다면, 뒤의 assert 메서드가 호출되지 않기 때문에, 일부 동작은 테스트되지 않는다.
실제로 고의로 코드를 고장냈을 때 아래와 같은 테스트 결과를 받을 수 있었다.
위 메세지는 84번째 라인에서 어떤 문제가 발생했다는 메세지다. 하지만 구체적으로 무엇이 문제인지 알려주지 않는다. 또한 오류가 발생한 이후의 라인은 수행되지 않기 때문에, 테스트가 전부 수행되지 않았다.
적절한 assert 메서드가 없어서 여러개의 assert 메서드를 사용해 결과를 검증하는 경우가 아니라면, 테스트 케이스에는 하나의 assert 메서드만 있는 것이 바람직하다.
오직 테스트를 위해 실제 코드의 API를 공개하지 말아라.
테스트를 하지 않는다면 private으로 유지할 함수를 테스트를 하기 위해 public등으로 변경하지 말아야 한다.
private 함수는 외부에 공개된 함수에 의해 호출된다. 따라서, 외부에 공개된 함수를 적절히 테스트한다면, 자연스럽게 private 함수 역시 테스트되기 때문에 private 함수의 정상 동작을 보장할 수 있기 때문이다.
이 외에도 private 함수를 외부에 공개하는 것은 캡슐화를 깨는 행위이기 때문에 지양해야 한다.
테스트가 공개된 API만으로 힘들다면, 실제 코드가 너무 많은 동작을 하는 경우가 많다.
아주 가끔, 공개된 API가 아닌 것도 테스트해야 하는 경우가 있다. 특정 클래스에서 구현 세부 사항으로 간주되어 private 메서드로 작성되었지만, 중요한 동작이고, 테스트해야 하는 케이스가 많은 경우 별도의 테스트로 분리하고 싶을 때가 있다. 해당 동작을 따로 테스트하는 것에는 일리가 있지만, 테스트의 용이성을 떠나 객체지향적으로 생각했을 때, 클래스에 과한 책임이 부여되었을 가능성이 높다.
아예 실제 코드에서 그 기능을 담당하는 클래스를 분리하는 것이 좋을 것이다.
원래 클래스에서 해당 기능을 아예 분리해, 다른 클래스로 만들고, 원래 클래스가 새로운 클래스를 의존하게 한다면, 새로운 클래스의 공개 API를 이용해 테스트를 작성할 수 있을 것이다.
테스트에 직접적인 영향을 주지 않는 설정만 여러 테스트 케이스가 공유하도록 해라.
테스트 코드에서, 각 테스트 케이스가 공통된 필드를 사용하거나, 싱글톤 인스턴스를 사용하는 등, 공유자원을 가진다면 문제가 생길 수 있다.
class SettingTest{
private List<String> settings = new ArrayList<>();
@Test
void test1(){
settings.add("testSetting");
assertThat(settings)
.contains("testSetting");
}
@Test
void initSettingTest(){
assertThat(settings)
.isEmpty();
}
}
Java
복사
위 코드에서 test1이 initSettingTest 보다 먼저 수행된다면, initSettingTest은 실패할 것이다. 반대 순서로 실행된다면 두 테스트는 모두 성공할 것이다. 이런 상황을 막으려면 테스트의 결과에 영향을 주는 설정은 공통으로 설정하지 않아야 한다. 혹은, 각 테스트 케이스의 수행 전에 한번씩 수행되는 초기화 코드를 사용해 설정을 초기화 해야 한다.
반면, 테스트에 직접적인 영향을 주지 않는 설정은 공통으로 사용하는 것이 바람직하다.
테스트 케이스에서 적절한 방식으로 실제 코드의 정상 동작을 검증해라.
List에 필요한 요소가 포함되어있는지 확인해야 하는 경우
class SettingTest{
@Test
@DisplayName("필요한 설정이 모두 포함되어있는지 확인하는 테스트")
void checkSettingsContainRequired(){
private List<String> settings = Settings.toList();
assertThat(settings)
.isEqualsTo(List.of("a","b"));
}
}
Java
복사
위 코드는 얼핏 보면 적절한 테스트같아 보이지만, settings가 정확히 List.of("a","b")와 동등한지 비교하는 코드다. 만약, 요소의 순서가 다르거나, 다른 요소가 추가로 있는 경우 테스트는 실패할 것이다. 이 경우 contains를 이용하는 것이 적절하다.
의존성 주입이나 인터페이스를 사용해 실제 코드를 유연하게 만들어라.
의존성 주입이나 인터페이스를 이용해 클래스(혹은 이와 비슷한 구성요소)사이의 연결을 유연하게 할 수 있음은 이미 널리 알려져있다. 이는 테스트에서도 여러 이점을 가진다.
의존성이 고정되어있다면 테스트가 불가능 할 수 있다.
예를 들어, 어떤 클래스가 외부 API를 호출하는 클래스를 의존하고 있고, 의존성이 하드코딩되어있다면 이를 테스트할 수 없다.
쇼핑몰 어플리케이션에서 구매 기능을 담당하는 클래스를 테스트한다고 생각해보자. 당연히 결제 기능을 담당하는 클래스와 의존관계를 맺고 있을 것이고, 결제 기능은 외부 업체에서 제공하는 API를 호출해서 사용한다고 하자. 이를 코드로 표현하면 아래와 같다.
public class OrderService{
private final PayService payService = new PayService();
public Response buy(Order order){
...
PayResponse payResponse = payService.pay(order);
...
}
}
public class PayService{
public PayResponse pay(Order order){
// 외부 API를 호출하는 코드
}
}
Java
복사
이를 테스트하기위해 테스트코드를 작성해 테스트를 통과한다면 실제 결제가 진행될 것이다. 이는 개발보다도 회사의 회계 문제나 감사에서 문제가 있을 수 있다. 따라서, 이 상태로는 테스트가 불가능하다.
인터페이스와 의존 주입을 이용한다면 테스트가 가능하다.
public class OrderService{
private final PayService payService;
public OrderService(PayService payService){
this.payService = payService;
}
public Response buy(Order order){
...
PayResponse payResponse = payService.pay(order);
...
}
}
public interface PayService{
public PayResponse pay(Order order);
}
public class PayServiceImpl implements PayService{
@Override
public PayResponse pay(Order order){
// 외부 API를 호출하는 코드
}
}
Java
복사
이렇게 하면, 테스트코드에서 PayService의 테스트더블을 만들어 의존성을 주입해 테스트가 용이해질 것이다.
예시 : 우테코 로또 미션
import java.util.List;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class LottoGeneratorTest {
@DisplayName("정상 동작 테스트")
@Test
void ok() {
LottoNumberGenerator lottoNumberGenerator = () -> List.of(1, 2, 3, 4, 5, 6);
List<Lotto> lottos = LottoGenerator.generate(lottoNumberGenerator, 1);
lottos.forEach(lotto -> {
Assertions.assertThat(lotto.toString())
.isEqualTo("[1, 2, 3, 4, 5, 6]");
});
}
}
package lotto.lotto;
import java.util.List;
import java.util.stream.IntStream;
final class LottoGenerator {
static List<Lotto> generate(LottoNumberGenerator lottoNumberGenerator, int generateCount) {
return IntStream.range(0, generateCount)
.mapToObj(ignore -> lottoNumberGenerator.generate())
.map(Lotto::new)
.toList();
}
}
package lotto.lotto;
import java.util.List;
public interface LottoNumberGenerator {
List<Integer> generate();
}
package lotto.lotto;
import camp.nextstep.edu.missionutils.Randoms;
import java.util.List;
public final class RandomLottoNumberGenerator implements LottoNumberGenerator {
@Override
public List<Integer> generate() {
return Randoms.pickUniqueNumbersInRange(1, 45, 6);
}
}
Java
복사