테스트 코드 복습
[기본] JUnit으로 비즈니스 로직에 단위 테스트 적용하기
이 전 챕터에서 여러분은 JUnit을 사용하지 않고, 단위 테스트를 작성하는 기본적인 방법을 학습했습니다.
이번 챕터에서는 JUnit을 학습하면서 이 전 챕터에서 Junit을 사용하지 않고 작성한 테스트 케이스에 JUnit을 적용해 보도록 하겠습니다.
JUnit이란?
JUnit은 Java 언어로 만들어진 애플리케이션을 테스트하기 위한 오픈 소스 테스트 프레임워크로서 사실상 Java의 표준 테스트 프레임워크라고 해도 과언이 아닙니다.
TestNG라는 JUnit의 강력한 경쟁자가 있긴 하지만 JUnit은 여전히 Java 애플리케이션 테스트를 위한 핵심입니다.
JUnit은 2022년 현재 Junit 5가 릴리스 된 상태이며, 이번 학습에서도 JUnit 5를 이용해서 테스트 케이스를 작성합니다.
Spring Boot의 디폴트 테스트 프레임워크는 JUnit입니다. 따라서 우리는 TestNG가 아닌 JUnit 5를 학습한다는 사실을 기억하세요.
TestNG에 대해서 더 알고 싶다면 아래 [심화 학습]을 참고하세요.
JUnit 기본 작성법
이제 JUnit을 사용해서 단위 테스트를 수행하는 방법을 단계적으로 알아보도록 하겠습니다.
Spring Boot Initializr에서 Gradle 기반의 Spring Boot 프로젝트를 생성하고 오픈하면 기본적으로 ‘src/test’ 디렉토리가 만들어집니다. JUnit 테스트 케이스는 프로젝트의 ‘src/test/java/com/springboot/’ 아래에 작성되어 있으니 참고 바랍니다.
Spring Boot Intializr를 이용해서 프로젝트를 생성하면 기본적으로 testImplementation >'org.springframework.boot:spring-boot-starter-test' 스타터가 포함되며, JUnit도 포함이 되어 있습니다.
여러분들은 별다른 설정 없이 JUnit을 사용하면 됩니다.
✔ JUnit을 사용한 테스트 케이스의 기본 구조
JUnit을 사용하는 테스트 케이스의 기본 구조는 기본적으로 굉장히 심플하며, 여러분들이 기본 구조만 작성하는 부분은 큰 어려움이 없을 거라 생각합니다.
import org.junit.jupiter.api.Test;
public class JunitDefaultStructure {
// (1)
@Test
public void test1() {
// 테스트하고자 하는 대상에 대한 테스트 로직 작성
}
// (2)
@Test
public void test2() {
// 테스트하고자 하는 대상에 대한 테스트 로직 작성
}
// (3)
@Test
public void test3() {
// 테스트하고자 하는 대상에 대한 테스트 로직 작성
}
}
[코드 3-180] JUnit을 사용한 테스트 케이스의 기본 구조
테스트 케이스에 JUnit을 적용하는 기본 구조는 코드 3-180과 같습니다.
여러분들이 보면 알겠지만 굉장히 심플합니다. (1), (2), (3)과 같이 애플리케이션에서 테스트하고자 하는 대상(Target)이 있으면 public void test1(){…} 같은 void 타입의 메서드 하나 만들고, @Test 애너테이션을 추가해 줍니다.
그리고 그 내부에 테스트하고자 하는 대상 메서드에 대한 테스트 로직을 작성해 주면 됩니다.
그럼 JUnit에 대한 Hello, JUnit을 작성해 보면서 사용법을 간단하게 익혀 볼까요?
✔ Assertion 메서드 사용하기
Assertion은 ‘예상하는 결과 값이 참(true)이길 바라는 논리적인 표현’이다라고 이 전 챕터에서 설명을 한 적이 있습니다.
Assertion의 의미가 아직 명확하지 않은 분들을 위해 ‘검증한다’라는 표현을 함께 사용하겠습니다.
JUnit에서는 Assertion과 관련된 다양한 메서드를 사용해서 테스트 대상에 대한 Assertion을 진행할 수 있습니다.
- assertEquals()
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class HelloJUnitTest {
@DisplayName("Hello JUnit Test") // (1)
@Test
public void assertionTest() {
String expected = "Hello, JUnit";
String actual = "Hello, JUnit";
assertEquals(expected, actual); // (2)
}
}
[코드 3-181] assertEquals() 사용 예
코드 3-181은 JUnit에서 사용할 수 있는 assertEquals() 메서드를 사용하는 예입니다.
assertEquals() 메서드를 사용하면 기대하는 값과 실제 결과 값이 같은지를 검증할 수 있습니다.
(2)에서는 기대하는 문자열(expected)과 실제 결과 값(autual)이 일치하는지를 검증하고 있습니다.
(1)은 테스트 케이스를 실행시켰을 때, 실행 결과 창에 표시되는 이름을 지정하는 부분입니다.
그럼 해당 테스트 케이스를 실행해 보도록 하겠습니다. 테스트 케이스 실행은 어떻게 할까요?
[그림 3-71] JUnit 테스트 케이스 실행 예
[그림 3-71]의 (1)을 클릭한 후에 [Run] 버튼을 클릭하면 클래스 내의 전체 테스트 케이스를 실행할 수 있습니다.
(2)에서의 [Run] 버튼을 클릭하면 해당 테스트 케이스만 실행할 수 있습니다.
우리는 (2)를 눌러서 코드 3-181의 테스트 케이스 실행 결과를 확인해 보겠습니다.
[그림 3-72] JUnit 테스트 케이스 실행 결과 화면 예
테스트 케이스 실행 결과 중에서 (2)와 같이 테스트에 통과(passed) 한 결과를 보기 위해서는 (1)의 체크 박스에 체크가 되어 있어야 합니다.
(2)를 보면 [코드 3-181]의 assertionTest1() 메서드에 추가한 @DisplayName("Hello JUnit Test")의 값이 실행 결과에 표시되는 걸 확인할 수 있습니다.
테스트 케이스가 성공(passed )이면 테스트 결과에 초록색 체크 아이콘이 표시됩니다.
그렇다면 테스트에 실패할 경우에는 실행 결과가 어떻게 표시될까요?
코드 3-181의 expected 변수의 값을 “Hello, World”로 바꿔보겠습니다.
[그림 3-73] JUnit 테스트 케이스 실행 결과 실패 예
그림 3-73을 보면 (1)을 통해서 테스트 실행 결과가 실패(failed) 임을 알 수 있고, (2)를 통해서 왜 실패했는지에 대한 설명을 볼 수 있습니다.
기대했던 값은 “Hello, World”인데, 실제 결과 값은 “Hello, JUnit”이다라고 친절하게 보여주고 있습니다.
학습을 진행하면서 JUnit에서 지원하는 Assertion 메서드 중에서 설명하지 않은 나머지 Assertion 메서드에 대해서 더 알아보고 싶다면 아래 \[심화 학습\]을 참고하세요.
이후부터는 테스트 성공은 “passed”, 실패는 “failed”로 간단하게 표현하겠습니다. 참고하세요!
- assertNotNull() : Null 여부 테스트
import com.springboot.CryptoCurrency;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
public class AssertionNotNullTest {
@DisplayName("AssertionNull() Test")
@Test
public void assertNotNullTest() {
String currencyName = getCryptoCurrency("ETH");
// (1)
assertNotNull(currencyName, "should be not null");
}
private String getCryptoCurrency(String unit) {
return CryptoCurrency.map.get(unit);
}
}
[코드 3-182] assertNull() 사용 예
(1)에서 *assertNotNull()* 메서드를 사용하면 테스트 대상 객체가 null 이 아닌지를 테스트할 수 있습니다.
assertNotNull() 메서드의 첫 번째 파라미터는 테스트 대상 객체이고, 두 번째 파라미터는 테스트에 실패했을 때, 표시할 메시지입니다.
실행 결과는 “ETH”에 해당하는 암호 화폐 이름이 map에 저장이 되어 있기 때문에 “passed”입니다.
CryptoCurrency 클래스 코드는 아래의 코드 3-183을 참고하세요.
import java.util.HashMap;
import java.util.Map;
public class CryptoCurrency {
public static Map<String, String> map = new HashMap<>();
static {
map.put("BTC", "Bitcoin");
map.put("ETH", "Ethereum");
map.put("ADA", "ADA");
map.put("POT", "Polkadot");
}
}
[코드 3-183] 테스트를 위한 CryptoCurrency 클래스
- assertThrows() : 예외(Exception) 테스트
이번에는 assertThrows()를 사용해서 호출한 메서드의 동작 과정 중에 예외가 발생하는지 테스트해보겠습니다.
import com.springboot.CryptoCurrency;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class AssertionExceptionTest {
@DisplayName("throws NullPointerException when map.get()")
@Test
public void assertionThrowExceptionTest() {
// (1)
assertThrows(NullPointerException.class, () -> getCryptoCurrency("XRP"));
}
private String getCryptoCurrency(String unit) {
return CryptoCurrency.map.get(unit).toUpperCase();
}
}
[코드 3-184] 예외가 발생하는지 테스트
코드 3-184에서는 getCryptoCurrency() 메서드를 호출했을 때, NullPointerException이 발생하는지 테스트하고 있습니다.
(1)에서 assertThrows()의 첫 번째 파라미터에는 발생이 기대되는 예외 클래스를 입력하고, 두 번째 파라미터인 람다 표현식에서는 테스트 대상 메서드를 호출하면 됩니다.
테스트 케이스를 실행하면 getCryptoCurrency() 메서드가 호출되고, 파라미터로 전달한 “XRP”라는 키에 해당하는 암호 화폐가 있는지 map에서 찾습니다.
하지만 XRP에 해당하는 암호 화폐는 map에 존재하지 않기 때문에 map에서 반환된 값은 null이 될 것입니다.
그리고 map에서 반환된 값이 null인 상태에서 toUpperCase()를 호출해서 대문자로 변환하려고 했기 때문에 NullPointerException이 발생할 것입니다.
따라서 (1)에서 NullPointerException이 발생할 것이라고 기대했기 때문에 테스트 실행 결과는 “passed”입니다.
✅ 예외 타입이 다를 경우에는 “passed”일까? “failed”일까?
그런데 만약 [코드 3-184]에서 assertThrows()의 첫 번째 파라미터로 NullPointerException.class 대신에 IllegalStateException.class으로 입력 값을 바꾸면 어떻게 될까요?
테스트 실행 결과는 “failed”입니다. 우선 기본적으로 IllegalStateException.class과 NullPointerException.class은 다른 타입이고, IllegalStateException.class이 NullPointerException.class의 상위 타입도 아니기 때문에 테스트 실행 결과는 “failed”입니다.
그렇다면 만약 NullPointerException.class 대신에 RuntimeException.class 또는 Exception.class으로 입력 값을 바꾸면 이번에는 테스트 실행 결과가 어떻게 될까요?
이 경우, 테스트 실행 결과는 “passed”입니다.
NullPointerException은 RuntimeException을 상속하는 하위 타입이고, RuntimeException은 Exception을 상속하는 하위 타입입니다.
이처럼 assertThrows()를 사용해서 예외를 테스트하기 위해서는 예외 클래스의 상속 관계를 이해한 상태에서 테스트 실행 결과를 예상해야 된다는 사실을 기억하기 바랍니다.
✅ 테스트 케이스 실행 시, 예외가 발생한다고 전부 “failed”인가요?
여러분들이 앞에서 예외 발생 테스트를 해보았기 때문에 그렇게 생각하지 않을 거라 기대(expected)하지만(^^), 노파심에서 한번 더 이야기하겠습니다.
애플리케이션에서 예외가 발생한다면 어떤 문제가 있는 것이기 때문에 ‘실패’, ‘문제 발생’ 같은 부정적인 단어를 떠올릴 수 있겠지만 테스트 세계에서는 다릅니다.
테스트 케이스 실행에서 예외가 발생한다 하더라도 여러분이 예외가 발생한다라고 기대하는 순간(expected) 예외가 발생하는 것은 “passed”가 되는 것입니다.
여러분이 ‘이 로직은 테스트해보면 예외가 발생 안 할 거야’ 라고 기대했는데, 예외가 발생하면 “failed”인 것입니다.
테스트 케이스는 무조건 이렇게 예상하든 저렇게 예상하든 예상(기대) 한 결과가 나와야지만 “passed”라는 사실 꼭 잊지 마세요.
여러분이 이해했을 거라고 기대합니다(expected).
Executable 함수형 인터페이스
assertThrows()의 두 번째 파라미터인 람다 표현식은 JUnit에서 지원하는 Executable 함수형 인터페이스입니다.
Executable 함수형 인터페이스는 void execute() throws Throwable; 메서드 하나만 정의되어 있으며 리턴값이 없습니다.
Java에서 지원하는 함수형 인터페이스 중에서 리턴값이 없는 Consumer에 해당된다고 보면 되겠습니다.
✔ 테스트 케이스 실행 전, 전처리
테스트 케이스를 실행하기 전에 어떤 객체나 값에 대한 초기화 작업 등의 전처리 과정을 해야 할 경우가 많습니다. 이 경우 JUnit에서 사용할 수 있는 애너테이션이 바로 @BeforeEach와 @BeforeAll()입니다.
- @BeforeEach
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
// (1)
public class BeforeEach1Test {
@BeforeEach
public void init() {
System.out.println("Pre-processing before each test case");
}
@DisplayName("@BeforeEach Test1")
@Test
public void beforeEachTest() {
}
@DisplayName("@BeforeEach Test2")
@Test
public void beforeEachTest2() {
}
}
[코드 3-185] @BeforeEach 애너테이션의 동작 방식
코드 3-185에는 두 개의 비어 있는 테스트 케이스가 있습니다. 테스트 케이스는 비어있지만 @BeforeEach의 동작 방식을 이해하기에는 적절합니다.
코드 3-185의 모든 테스트 케이스 메서드를 실행하면 어떤 결과가 콘솔에 출력이 될까요?
IntelliJ에서 테스트 케이스를 실행시킬 때, (1)과 같이 클래스 좌측에 표시되는 녹색 버튼(Run)을 실행시켜야 클래스 내의 모든 테스트 케이스 메서드가 실행이 됩니다.
Pre-processing before each test case
Pre-processing before each test case
실행 결과를 보면 init() 메서드가 총 두 번 실행되어서 “Pre-processing before each test case”가 콘솔에 두 번 출력되었습니다.
이처럼 @BeforeEach 애너테이션을 추가한 메서드는 테스트 케이스가 각각 실행될 때마다 테스트 케이스 실행 직전에 먼저 실행되어 초기화 작업 등을 진행할 수 있습니다.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
public class BeforeEach2Test {
private Map<String, String> map;
@BeforeEach
public void init() {
map = new HashMap<>();
map.put("BTC", "Bitcoin");
map.put("ETH", "Ethereum");
map.put("ADA", "ADA");
map.put("POT", "Polkadot");
}
@DisplayName("Test case 1")
@Test
public void beforeEachTest() {
map.put("XRP", "Ripple");
assertDoesNotThrow(() -> getCryptoCurrency("XRP"));
}
@DisplayName("Test case 2")
@Test
public void beforeEachTest2() {
System.out.println(map);
assertDoesNotThrow(() -> getCryptoCurrency("XRP"));
}
private String getCryptoCurrency(String unit) {
return map.get(unit).toUpperCase();
}
}
[코드 3-186] @BeforeEach 애너테이션 예
자, 그럼 코드 3-186의 테스트 케이스를 클래스 레벨에서 실행시킬 경우의 실행 결과를 예상해 봅시다.
테스트 실행 결과는 어떻게 될까요? Test case1과 Test case 2의 결과를 예상해 보세요.
[그림 3-74] 코드 3-186 실행 결과
실행 결과를 보면, Test case 2는 “failed”이고, Test case 1은 “passed”입니다.
Test case 1은 assertDoesNotThrow()로 Assertion 하기 전에 map에 “XRP”의 값을 추가했습니다.
그렇기 때문에 map에 “XRP”의 값이 존재하므로 예외가 발생하지 않습니다.
assertDoesNotThrow() 메서드는 예외가 발생하지 않는다고 기대하는 Assertion 메서드입니다.
따라서, 예외가 발생하지 않는다고 기대했으므로 테스트 실행 결과는 “passed”입니다.
그런데 Test case 2는 Assertion 하기 전에 map에 “XRP”를 추가하지 않습니다.
따라서 Test case 2가 실행되기 전에 init() 메서드가 호출되면서 이 전에 한번 사용했던 map 객체가 다시 초기화됩니다.
Test case1에서 map에 “XRP”를 추가했다 하더라도 추가한 “XRP”는 Test case2 실행 전에 init() 메서드가 다시 호출되면서 map이 초기화되기 때문에 초기화된 상태로 되돌아갑니다.
따라서, Test case 2에서 예외가 발생하지 않는다고 기대했지만 NullpointerException이 발생하므로 테스트 실행 결과는 “failed”입니다.
콘솔에 출력된 “{BTC=Bitcoin, POT=Polkadot, ETH=Ethereum, ADA=ADA}”는 Test case 2 실행 시, map의 상태를 출력한 것입니다. map 안에 “XRP”는 없는 것을 확인할 수 있습니다.
- @BeforeAll()
@BeforeAll()은 @BeforeEach()와 달리 클래스 레벨에서 테스트 케이스를 한꺼번에 실행시키면 테스트 케이스가 실행되기 전에 딱 한 번만 초기화 작업을 할 수 있도록 해주는 애너테이션입니다.
public class BeforeAllTest {
private static Map<String, String> map;
@BeforeAll
public static void initAll() {
map = new HashMap<>();
map.put("BTC", "Bitcoin");
map.put("ETH", "Ethereum");
map.put("ADA", "ADA");
map.put("POT", "Polkadot");
map.put("XRP", "Ripple");
System.out.println("initialize Crypto Currency map");
}
@DisplayName("Test case 1")
@Test
public void beforeEachTest() {
assertDoesNotThrow(() -> getCryptoCurrency("XRP"));
}
@DisplayName("Test case 2")
@Test
public void beforeEachTest2() {
assertDoesNotThrow(() -> getCryptoCurrency("ADA"));
}
private String getCryptoCurrency(String unit) {
return map.get(unit).toUpperCase();
}
}
[코드 3-187] @BeforeAll 애너테이션 예
코드 3-187에서는 @BeforeAll 애너테이션을 사용해서 map 객체를 한 번만 초기화하기 때문에 두 개의 테스트 케이스 실행 결과는 모두 “passed”입니다.
그리고, 콘솔에는 아래와 같이 “initialize Crypto Currency map”이 한 번만 출력됩니다.
initialize Crypto Currency map
@BeforeAll 애너테이션을 추가한 메서드는 정적 메서드(static method)여야 한다는 사실을 기억하세요!
✔ 테스트 케이스 실행 후, 후처리
JUnit에서는 테스트 케이스 실행이 끝난 시점에 후처리 작업을 할 수 있는 @AfterEach, @AfterAll 같은 애너테이션도 지원합니다.
이 애너테이션은 @BeforeEach , @BeforeAll과 동작 방식은 같고, 호출되는 시점만 반대입니다.
@AfterEach, @AfterAll 은 여러분이 직접 한 번 예제를 만들어서 동작 방식을 확인해 보길 바랍니다.
✔ Assumption을 이용한 조건부 테스트
Junit 5에는 Assumption이라는 기능이 추가되었습니다.
Assumption은 ‘~라고 가정하고’라는 표현을 쓸 때의 ‘가정’에 해당합니다.
JUnit 5의 Assumption 기능을 사용하면 특정 환경에만 테스트 케이스가 실행되도록 할 수 있습니다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
public class AssumptionTest {
@DisplayName("Assumption Test")
@Test
public void assumptionTest() {
// (1)
assumeTrue(System.getProperty("os.name").startsWith("Windows"));
// assumeTrue(System.getProperty("os.name").startsWith("Linux")); // (2)
System.out.println("execute?");
assertTrue(processOnlyWindowsTask());
}
private boolean processOnlyWindowsTask() {
return true;
}
}
[코드 3-188] Assumption 예제
코드 3-188은 Assumption 기능을 사용하는 예제 코드입니다.
(1)에서 assumeTrue() 메서드는 파라미터로 입력된 값이 true이면 나머지 아래 로직들을 실행합니다.
만약, 여러분들이 코드 3-188의 테스트 케이스를 실행하는 PC의 운영체제(OS)가 윈도우(Windows)라면 assumeTrue() 메서드의 파라미터 값이 true가 될 것이므로 assumeTrue() 아래 나머지 로직들이 실행이 될 것이고, 여러분들의 PC 운영체제(OS)가 윈도우(Windows)가 아니라면 assumeTrue() 아래 나머지 로직들이 실행되지 않을 것입니다.
이처럼, assumeTrue()는 특정 OS 환경 등의 특정 조건에서 선택적인 테스트가 필요하다면 유용하게 사용할 수 있는 JUnit 5의 API입니다.
JUnit으로 비즈니스 로직에 단위 테스트 적용해 보기
public class StampCalculator {
public static int calculateStampCount(int nowCount, int earned) {
return nowCount + earned;
}
public static int calculateEarnedStampCount(Order order) {
return order.getOrderCoffees().stream()
.map(orderCoffee -> orderCoffee.getQuantity())
.mapToInt(quantity -> quantity)
.sum();
}
}
[코드 3-189] 단위 테스트 대상인 StampCalculator 헬퍼 클래스
코드 3-189는 이 전 챕터에서 JUnit 없이 단위 테스트를 진행했던 테스트 대상 클래스입니다.
우리는 아래의 코드 3-190과 같이 JUnit을 사용하지 않고, StampCalculator의 메서드에 대해 단위 테스트를 진행했었습니다.
public class StampCalculatorTestWithoutJUnit {
public static void main(String[] args) {
calculateStampCountTest(); // (1) 첫 번째 단위 테스트
calculateEarnedStampCountTest(); // (2) 두 번째 단위 테스트
}
private static void calculateStampCountTest() {
// given
int nowCount = 5;
int earned = 3;
// when
int actual = StampCalculator.calculateStampCount(5, 3);
int expected = 7;
// then
System.out.println(expected == actual);
}
private static void calculateEarnedStampCountTest() {
// given
Order order = new Order();
OrderCoffee orderCoffee1 = new OrderCoffee();
orderCoffee1.setQuantity(3);
OrderCoffee orderCoffee2 = new OrderCoffee();
orderCoffee2.setQuantity(5);
order.setOrderCoffees(List.of(orderCoffee1, orderCoffee2));
// when
int actual = StampCalculator.calculateEarnedStampCount(order);
int expected = 8;
// then
System.out.println(expected == actual);
}
}
[코드 3-190] JUnit 없이 진행했던 StampCalculator 클래스에 대한 단위 테스트