테스트케이스의 중요성은 말하지않아도 잘 알것이다. 예전에는 작성한 코드를 검증하는 것을 목적으로 테스트케이스를 작성하였지만, 요새는 TDD라고해서 테스트케이스를 먼저 작성하고 해당 테스트케이스가 통과하는 형식으로 차후에 코드를 작성하는 테스트주도개발이 많이들린다.
나 또한 개발자로서 테스트케이스를 작성하고는 있지만, 테스트케이스를 잘 알고 쓰기보다는 기존에 작성되어있는 코드를 복사하여 주먹구구식으로 작성했던 적이 많았다. 마침 사내에서 Junit in Action 3판을 주제로 스터디 기회가 생겨서 이번기회에 junit을 정리해보기로 했다.
1장. 밑바닥 부터 시작하기
1장에서는 테스트를 사용해야하는 이유와 junit 설치 방법에 관해 설명한다.
junit은 작성한 코드가 제대로 동작하는것을 알 수 있어야하며, 더 중요한것은 애플리케이션에 다른기능을 추가할때 또는 이후에 수정할때마다 해당 코드가 여전히 잘 동작하는지에 대한 검증에서부터 출발하였다.
junit이 등장하기 전에는 try catch등으로 오류를 확인해야했다. 이것은 테스트를 위하여 코드를 복잡하게 만들고 차후 유지보수때도 개발자에게 큰 부담이 되었다. 이러한 불편함이 맞물려 Junit 프레임워크가 개발되었다.
junit은 3가지 구체적인 목표를 정의하였다.
- 프레임워크는 유용한 테스트를 작성하는데 도움이 되어야한다.
- 프레임워크는 시간이 지나도 그 가치를 유지하는 테스트를 만드는데 도움이 되어야한다.
- 프레임워크는 코드를 재사용하여 테스트를 작성하는 비용을 낮추는 데 도움이 되어야한다.
junit5는 junit4와 다르게 모듈 아키텍처를 가지고있으며 jar파일을 클래스패스에 추가하지 않는다.
junit5를 사용하기위해서는 pom.xml에 해당 의존성을 추가해야 한다.
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.6.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.6.0</version>
<scope>test</scope>
</dependency>
2장. Junit 핵심 살펴보기
2장에서는 junit 핵심 어노테이션과 생애주기에 관해 설명한다.
- 테스트 클래스 : 클래스, 정적 멤버 클래스, 하나 이상의 테스트 메서드를 포함하는 @Nested 가 붙은 내부 클래스
- 추상 클래스일 수 없으며, 단일 생성자를 가지고 있어야 한다.
- 생성자는 파라미터가 아예 없거나, 런타임에 의존성 주입으로 동적 리졸브할수있는 파라미터만 사용가능하다.
- 5부터는 public으로 선언하지않아도 된다.(default 가능)
- 테스트 메서드 : @Test, @RepeatedTest, @ParameterizedTest, @TestFactory, @TestTemplate 가 붙은 메서드
- 추상메서드여서는 안된다.
- 반환값을 가질 수 없다 (반드시 void)
junt은 테스트 메서드의 격리성을 보장하고 실행순서에 관계없이 동일한 결과를 얻기위해 @Test메서드를 호출하기전에 테스트 클래스 인스턴스를 매번 생성한다. 이렇게되면 인스턴스 변수를 재사용할 수 없다.
대신 @TestInstance(Lifecycle.PER_CLASS) 애노테이션 추가시 junit은 동일한 테스트 인스턴스를 가지고 클래스에 있는 모든 테스트 메서드를 실행한다.
어노테이션 | 설명 |
@BeforeAll | 전체 테스트가 실행되기 전에 한번 실행. @TestInstance(Lifecycle.PER_CLASS)가 없다면 static선언 |
@BeforeEach | 각 테스트가 실행되기 전에 실행 |
@Test | 실제 테스트할 테스트케이스를 실행 |
@AfterAll | 각 테스트가 실행되고 난 후에 실행 |
@AfterEach | 전체 테스트가 실행되고 난 후에 한번 실행 @TestInstance(Lifecycle.PER_CLASS)가 없다면 static선언 |
@DisplayName | 클래스 메서드에 사용가능. 테스트 설명용도로 사용. 주석대신 이걸 쓰자 |
@Disabled | 클래스, 메서드에 사용가능. 테스트 비활성화 용도 |
@Tag | 특정 카테고리만 실행하도록 범주화 |
@Disabled("기능이 아직 개발 중")
class DisabledClassTest {
private SUT systemUnderTest = new SUT("테스트 대상 시스템");
@Test
void testRegularWork() {
boolean canReceiveRegularWork = systemUnderTest.canReceiveRegularWork();
assertTrue(canReceiveRegularWork);
}
@Test
void testAdditionalWork() {
boolean canReceiveAdditionalWork = systemUnderTest.canReceiveAdditionalWork();
assertFalse(canReceiveAdditionalWork);
}
}
@Tag사용시, 상황에 맞게 특정 카테고리의 테스트만 수행할 수 있다.
메이븐 파일로 특정 카테고리 테스트만 수행하도록 설정 가능. 이클립스같은 ide에서도 설정 가능
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
<!--
<configuration>
<groups>individual</groups> 실행할 tag
<excludedGroups>repository</excludedGroups> 제외할 tag
</configuration>
-->
</plugin>
</plugins>
</build>
package com.manning.junitbook.ch02.tags;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("repository")
public class CustomersRepositoryTest {
private String CUSTOMER_NAME = "John Smith";
private CustomersRepository repository = new CustomersRepository();
@Test
void testNonExistence() {
boolean exists = repository.contains("John Smith");
assertFalse(exists);
}
@Test
void testCustomerPersistence() {
repository.persist(new Customer(CUSTOMER_NAME));
assertTrue(repository.contains("John Smith"));
}
}
단언문
결과값을 검증하기 위해 junit5의 Aseestions 클래스에서 제공하는 단언문 메서드를 사용해야한다.
이런게 있다고만 알아두고 실무에서는 AssertJ를 쓰자.
단언문 메서드 | 설명 |
assertAll | 오버로딩이 적용되어있다. 안에있는 executable 객체 중 어느 것도 예외를 던지지 않는다고 단언한다. |
assertArrayEquals | 오버로딩이 적용되어있다. 예상 배열과 실제 배열이 동등하다고 단언한다. |
assertEquals | 오버로딩이 적용되어있다. 예상 값과 실제 값이 동등하다고 단언한다. |
assertX(..., String msg) assertTrue assertFalse assertNull ... |
실패했을 경우 msg를 테스트 프레임워크에 전달한다. |
assertX(..., Supplier<String> messageSupplier) | 실패했을 경우 messageSupplier를 테스트 프레임워크에 전달하는 단언문이다. 실패 메시지는 messageSupplier에서 지연전달 된다. |
JUnit5는 과거에 Hamcrest 매처와 함께 사용했던 assertThat 메서드는 더이상 지원하지 않는다.
권장하는 방식은 MatcherAssert.assertThat을 오버로딩한 메서드를 사용하는 것이다.
assertAll 메서드의 좋은 점은 일부 단언문이 실패하더라도 모든 단언문을 항상 검증한다는 것이다. JUnit4에서는 여러개 assert메서드 중 하나가 실패하면 그 실패로 인해 전체 메서드가 중단됐다.
package com.study.junit.ch02;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class AssertAllTest {
@Test
@DisplayName("기본적으로 테스트 대상 시스템은 검증하지 않는다.")
void testSystemNotVerified() {
SUT systemUnderTest = new SUT("테스트 대상 시스템");
assertAll("테스트 대상 시스템을 검증하지 않았는지 확인", // 아래 단언문중 예외 발생시 표시할 메시지
() -> assertEquals("테스트 대상 시스템",systemUnderTest.getSystemName()),
() -> assertFalse(systemUnderTest.isVerified()));
}
@Test
@DisplayName("테스트 대상 시스템을 검증한다")
void testSystemUnderVerification() {
SUT systemUnderTest = new SUT("테스트 대상 시스템");
systemUnderTest.verify();
assertAll("테스트 대상 시스템을 검증했는지 확인", // 아래 단언문중 예외 발생시 표시할 메시지
() -> assertEquals("테스트 대상 시스템", systemUnderTest.getSystemName()),
() -> assertTrue(systemUnderTest.isVerified()));
}
}
단언문 메서드 | 설명 |
assertTimeout | executable 객체가 작업을 마칠때까지 기다린다. 만약 테스트가 주어진 시간을 초과하면 얼마나 늦어졌는지 알려준다. |
assertTimeoutPreemptively | executable 객체가 작업중이여도 시간이 지나면 중지시킨다. |
package com.study.junit.ch02;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
public class AssertTimeoutTest {
private SUT systemUnderTest = new SUT("테스트 대상 시스템");
@Test
@DisplayName("작업을 마칠 때까지 기다리는 assertTimeout 메서드")
void testTimeout() throws InterruptedException {
systemUnderTest.addJob(new Job("Job 1"));
assertTimeout(Duration.ofMillis(500), () -> systemUnderTest.run(600));
}
@Test
@DisplayName("시간이 지나면 작업을 중지시키는 assertTimeoutPreemptively 메서드")
void testTimeoutPreemptively() throws InterruptedException {
systemUnderTest.addJob(new Job("Job 1"));
assertTimeoutPreemptively(Duration.ofMillis(500), () -> systemUnderTest.run(600));
}
}
단언문 메서드 | 설명 |
assertThrows | 예외가 발생한다고 단언한다. 예외가 발생했을 때, Throwable 객체를 반환한다. |
assertThrows객체는 예외발생시 Throwable 객체를 반환하므로 해당 객체를 단언문으로 검증가능.
package com.study.junit.ch02;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class AssertThrowsTest {
private SUT systemUnderTest = new SUT("테스트 대상 시스템");
@Test
@DisplayName("예외가 발생하는지 검증한다")
void testExpectedException() {
//systemUnderTest객체가 run() 호출시 NoJobException이 발생하는지 검증
assertThrows(NoJobException.class, systemUnderTest::run);
}
@Test
@DisplayName("예외가 발생하고 예외에 대한 참조가 유지되는지 검증한다")
void testCatchException() {
//systemUnderTest.run(1000) 문장이 NoJobException을 던지는지 검증, throwable 예외에 대한 참조가 유지되었는지도 검증
Throwable throwable = assertThrows(NoJobException.class,() -> systemUnderTest.run(1000));
//에러메시지가 다음 문장과 일치하는지 검증
assertEquals("테스트 대상 시스템은 현재 작업이 없는지 확인",
throwable.getMessage());
}
}
가정문
테스트를 수행하는데 필수인 전제 조건이 충족되었는지를 검증하는 문장.
가정문에 의해 중단된 테스트는 실패한것으로 간주.
가정문 메서드 | 설명 |
assumeTrue | 가정문이 참인지 판단 |
assumingThat(..., String msg) | 가정문이 참일때만 실행 |
해당 가정이 통과할때만 테스트가 실행되도록 구현 가능하다.
package com.study.junit.ch02;
import static org.junit.jupiter.api.Assumptions.*;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class AssumptionTest {
private static String EXPECTED_JAVA_VERSION = "1.8";
private TestsEnvironment environment = new TestsEnvironment(
new JavaSpecification(
System.getProperty("java.vm.specification.version")
),
new OperationSystem(
System.getProperty("os.name"),
System.getProperty("os.arch")
)
);
private SUT systemUnderTest = new SUT();
@BeforeEach //각 테스트가 실행 되기 전 실행되는 메서드로 OS가 window인지 확인한다.
void setUp() {
assumeTrue(environment.isWindows());
}
@Test
void testNoJobToRun() {
assumingThat(
//자바 버전이 1.8인지 검증한다.
() -> environment.getJavaVersion().equals(EXPECTED_JAVA_VERSION),
//자바 버전이 1.8일때만 현재 실행중인 작업이 없음을 검증한다.
() -> assertFalse(systemUnderTest.hasJobToRun())
);
}
@Test
void testJobToRun() {
//현재 아키텍처가 사전에 가정한 환경인지 검증한다.
assumeTrue(environment.isAmd64Architecture());
//아키텍처가 AMD64인 경우에만 시스템에서 새로운 작업을 수행한다.
systemUnderTest.run(new Job("Job 1"));
//그리고 시스템에 실행할 작업이 있는지 검증한다.
assertTrue(systemUnderTest.hasJobToRun());
}
}
Junit 5 의존성 주입
이전 버전의 junit 에서는 생성자나 메서드가 파라미터에 있는것이 허용되지않았고 반드시 기본 생성자만 사용해야했다.
-> junit 5 부터는 의존성 주입을 통해서만 생성자나 메서드의 파라미터를 가질 수 있도록 허용하였다.
ParameterResolver 인터페이스는 런타임에 파라미터를 동적으로 리졸브한다.
현재 junit은 아래 3개의 리졸버를 기본으로 내장하고있다.
다른 파라미터 리졸버를 사용하려면 @ExtendWith로 적절한 extension을 적용하여 파라미터 리졸버를 명시해야한다.
내장 인터페이스 | 설명 |
TestInfoParameterResolver | 현재 실행중인 테스트나 컨테이너에 관한 정보를 제공하기 위해 사용되는 TestInfo 객체를 파라미터로 사용 가능 디스플레이 네임, 테스트 클래스, 테스트 메서드, 관련 태그 정보 등 |
TestReporterParameterResolver | 현재 실행되는 테스트에 추가적인 정보를 제공하기위해 테스트 리포트를 만들 때 사용되는 TestRepoter 객체를 파라미터로 사용 가능 |
RepetitionInfoParameterResolver |
@RepeatedTest, @BeforeEach, @AfterEach 애노테이션이 달린 메서드의 파라미터가 RepetitionInfo 타입일때 RepetitionInfo 인스턴스를 리졸브하는 역할. RepetitionInfo는 반복 테스트에 대한 현재 반복 인덱스와 총 반복 횟수에 대한 정보를 가진다. |
import org.junit.jupiter.api.TestInfo;
public class TestInfoTest {
TestInfoTest(TestInfo testInfo) { // 의존성 주입
assertEquals("TestInfoTest", testInfo.getDisplayName());
}
}
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestReporter;
import java.util.HashMap;
import java.util.Map;
public class TestReporterTest {
@Test
void testReportSingleValue(TestReporter reporter) { // 의존성 주입
reporter.publishEntry("Single value");
}
@Test
void testReportKeyValuePair(TestReporter reporter) {
reporter.publishEntry("Key", "Value");
}
@Test
void testReportMultipleKeyValuePairs(TestReporter reporter) {
Map<String, String> values = new HashMap<>();
values.put("user", "John");
values.put("password", "secret");
reporter.publishEntry(values);
}
}
지원하는 플레이스 홀더
- {displayName} : @RepeatedTest 애노테이션이 붙은 메서드의 디스플레이 네임
- {currentRepetition} : 현재 반복 인덱스
- {totalRepetitions} : 총 반복 횟수
import com.study.junit.ch01.Calculator;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.RepetitionInfo;
import org.junit.jupiter.api.TestReporter;
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class RepeatedTestsTest {
private static Set<Integer> integerSet = new HashSet<>();
private static List<Integer> integerList = new ArrayList<>();
@RepeatedTest(value = 5, name = "{displayName} - repetition {currentRepetition}/{totalRepetitions}")
@DisplayName("Test add operation")
void addNumber() {
Calculator calculator = new Calculator();
assertEquals(2, calculator.add(1,1), "1+1 should equal 2");
}
@RepeatedTest(value = 5,
name = "the list contains {currentRepetition} elements(s), the set contains 1 element")
void testAddingToCollections(TestReporter reporter, RepetitionInfo repetitionInfo) {
integerSet.add(1);
integerList.add(repetitionInfo.getCurrentRepetition());
reporter.publishEntry("Repetition number", String.valueOf(repetitionInfo.getCurrentRepetition()));
assertEquals(1, integerSet.size());
assertEquals(repetitionInfo.getCurrentRepetition(), integerList.size());
}
}
파라미터 테스트
하나의 테스트를 다양한 파라미터를 가지고 여러 번 실행하게 해주는 기능. @ParameterizedTest 반드시 사용.
- @ValueSource : 문자열 배열을 입력값으로 지정
- @EnumSource : 열거형을 입력값으로 지정
package com.manning.junitbook.ch02.parametrized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ParameterizedWithValueSourceTest {
private WordCounter wordCounter = new WordCounter();
@ParameterizedTest
@ValueSource(strings = {"Check three parameters", "JUnit in Action"})
void testWordsInSentence(String sentence) { // 2개의 파라미터가 각각 실행 - 총 2번
assertEquals(3, wordCounter.countWords(sentence));
}
}
package com.manning.junitbook.ch02.parametrized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.params.provider.EnumSource.Mode.EXCLUDE;
class ParameterizedWithEnumSourceTest {
private WordCounter wordCounter = new WordCounter();
@ParameterizedTest
@EnumSource(Sentences.class)
void testWordsInSentence(Sentences sentence) { // 전체 다 실행 - 3번
assertEquals(3, wordCounter.countWords(sentence.value()));
}
@ParameterizedTest
@EnumSource(value = Sentences.class, names = {"JUNIT_IN_ACTION", "THREE_PARAMETERS"})
void testSelectedWordsInSentence(Sentences sentence) { // 지정한 2개만 실행
assertEquals(3, wordCounter.countWords(sentence.value()));
}
@ParameterizedTest
@EnumSource(value = Sentences.class, mode = EXCLUDE, names = {"THREE_PARAMETERS"})
void testExcludedWordsInSentence(Sentences sentence) { // 지정한 1개만 제외하고 실행. 총2번
assertEquals(3, wordCounter.countWords(sentence.value()));
}
enum Sentences {
JUNIT_IN_ACTION("JUnit in Action"),
SOME_PARAMETERS("Check some parameters"),
THREE_PARAMETERS("Check three parameters");
private final String sentence;
Sentences(String sentence) {
this.sentence = sentence;
}
public String value() {
return sentence;
}
}
}
Hamcrest
단언문의 내용이 길어질 경우, 한번에 읽기 어렵기때문에 복잡한 단언문을 간명하게 만드는 도움이 되는 라이브러리로 프레임워크는 아니다. 매처를 중첩하여 읽기 쉽게 만들어줌.
반드시 사용하지않아도 무방하지만, 메서드의 가독성을 위해서 사용.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assertions.*;
public class HamcrestListTest {
private List<String> values;
@BeforeEach
public void setUp() {
values = new ArrayList<>();
values.add("John");
values.add("Michael");
values.add("Edwin");
}
@Test
@DisplayName("Hamcrest를 사용하지 않은 테스트")
public void testWithoutHamcrest() {
assertTrue(values.contains("Oliver")
|| values.contains("Jack")
|| values.contains("Harry"));
}
@Test
@DisplayName("Hamcrest를 사용해서 자세한 실패 정보를 나타내는 테스트")
public void testListWithHamcrest() {
assertThat(values, hasItem(anyOf(equalTo("Oliver"),
equalTo("Jack"), equalTo("Harry"))));
}
}
다만, hamcrest 라이브러리를 사용하는것보다 AssertJ 라이브러리를 사용하는것을 추천한다.
그리고 내게 hamcrest는 책에 써있는것처럼 단번에 눈에 들어오는 효과는 없었다..
애초에 assertJ는 junit이 공식적으로 사용하라고 권장하는 서드파티 라이브러리다.
지금까지 위에 쭉 설명했던것은 junit쪽에서 제공하는 메서드였고, 실무에서는 AssertJ가 제공하는 메서드를 사용하는 경우가 더 많다. 우선, junit에서 제공하는것은 기본적인 메서드 밖에없기때문에 해당 메서드를 가지고 쓰려면 복잡하게 써야한다. 하지만 AssertJ는 다양한 메서드를 미리 만들어놓았으므로 우리는 가져다 쓰기만 하면된다.
그리고 가독성도 더 좋다.
// JUnit
assertTrue(winners.containsAll(List.of("애쉬", "스플릿")) && winners.size() == 2);
assertArrayEquals(winners.toArray(), new String[]{"애쉬", "스플릿"});
assertTrue(winners.containsAll(List.of("애쉬", "스플릿")));
// AssertJ
assertThat(winners).containsExactlyInAnyOrder("애쉬", "스플릿");
assertThat(winners).containsExactly("애쉬", "스플릿");
assertThat(winners).contains("애쉬", "스플릿");
@Test
@DisplayName("AssertJ 사용 테스트")
public void testWithAssertJ() {
Assertions.assertThat(values).containsAnyOf("Oliver", "Jack", "Harry");
}
왜 AssertJ를 써야하는지는 아래 포스팅을 보면 잘 알 수 있으므로 자세한 설명은 생략한다.
'IT > 자바' 카테고리의 다른 글
classpath (0) | 2023.03.26 |
---|---|
ExecutorService (0) | 2023.03.26 |
Java8 람다(Lambda)를 이용한 프로그래밍 (0) | 2022.01.23 |
Java8 스트림(Stream)연산을 사용해보자 (0) | 2022.01.23 |
Java8 람다(Lambda)를 사용해보자 (0) | 2022.01.23 |
댓글