개발환경
IDE : intelliJ
FrameWork : springboot 3.x
Launguage : java 17
TestTool : Junit5
들어가기 전에
개발 방식을 TDD로 전환하면서 테스트 코드 작성 시 여러 난관에 부딪혔다. 공식 가이드를 보며 이것저것 시도해보기도하고, 구글링과 ChatGPT를 통해 예제 코드도 많이 봤고, Git에서 떠돌아다니는 오픈소스의 테스트코드들을 뜯어보며 어떻게 작성하는 게 좋을지에 대해 많은 고민을 했다.
처음에는 Mock이고 Stub이고 하는것들의 개념 없이 그냥 서비스 레이어의 메서드 단위로 테스트를 시작했다. 뭐가 뭔지도 잘 모르는 상태에서 단순히 그냥 '코드를 먼저 작성하는' 정도에 그쳤다. 특히 Controller 관련 코드를 작성할 때는 원하는 대로 안 돼서 원인을 한참 찾았던 기억이 난다.( 이 내용은 블로그 초기에 작성한 내용에도 포함되어 있는데 지금 보면 헛웃음이 나고 부끄럽지만 그것 또한 나의 발자취기에 그냥 냅뒀다. 책 후반부에 관련 내용이 나와 리뷰할 때 리팩토링 하는 느낌으로 재작성할 예정이다.) 그럼에도 불구하고 소스 작성시 에러가 눈에 띄게 줄고, 리팩터링 할 때 편하다는 느낌을 많이 받았다.
어떻게 적응해보겠다고 꾸역꾸역 하다 보니 많은 게시글들에서 외치던 단위테스트가 무엇을 말하는지, 테스트 프레임워크에서 제공하는 기능들을 어떻게 써야 하는지 좀 감을 잡은 것 같기도 하다.
테스트하는 방식이야 팀별로 다르겠지만 나는 일반적인 계층형 아키텍처 구조에서 Service와 Repository는 단위 테스트로, Controller에서는 통합테스트를 진행하고 있다.
최근 작업한 프로젝트에 대해 시니어분께서 빌드시 테스트코드 실행속도 개선을 위해 좀 더 Stub을 잘 쓰는 방안에 대해 연구해 보면 좋을 것 같다는 피드백을 주셨다. 관련해서 정보를 찾아보다가, Junit In Action (TDD를 위한 테스트 원칙, 도구 및 활용)이라는 책을 발견 했다. Junit5가 포함되어 있는 것도 좋았고, 전반적인 개발방법론까지 폭넓게 정리된 것 같아 바로 구매했다.
TDD에 대해 관심있어하는 개발자 두 분을 영업해서 함께 리뷰하는 시간을 갖게 되었는데, 그 내용들을 공유해볼까 한다.
책이 꽤 두꺼워서 아마 두세달 이상 장기적으로 리뷰할 것 같다 😊
단위 테스트란?
개별적인 작업 단위의 동작을 검사하는 테스트로 작업 단위는 다른 작업의 완료에 직접적으로 의존하지 않는다.
자바 애플리케이션에서 개별 작업 단위는 (항상 그런 것은 아니지만) 단일 메서드인 경우가 많다. 반면 통합 테스트와 인수 테스트는 다양한 컴포넌트가 제대로 상호작용하는지 검사한다.
간단하게 말하면 '예상되는 입력 범위 내에서의 각 입력에 대해 메서드가 예상되는 값을 반환하는지 확인하는'용도로 단위 테스트를 수행한다.
단위 테스트에는 프레임워크가 따라야 하는 몇 가지 모범 사례가 있다.
- 단위 테스트의 모범 사례(best practice)
- 단위 테스트는 다른 단위 테스트와 독립적으로 실행되어야 한다. ( 서로 다른 클래스 인스턴스에서 실행될 수 있어야 한다.)
- 프레임워크는 각 단위 테스트의 오류를 파악하여 알려 주어야 한다.
- 어떤 테스트를 실행할지 쉽게 정의할 수 있어야 한다.
간단한 Junit5 소개
Junit5는 애플리케이션을 테스트하기 위해 제공되는 오픈소스 기반 프레임워크이다. 애노테이션 기반으로 동작하며 Java8 이상에서 동작한다.
Junit5를 사용하기 위해서는 의존성 추가가 필요한데, 이를 위해 maven 혹은 gradle과 같은 buildTool 사용이 권장된다.
아래는 공식 홈페이지를 참고한 build.gradle설정 파일이다.
dependencies {
testImplementation(platform('org.junit:junit-bom:5.10.2'))
testImplementation('org.junit.jupiter:junit-jupiter')
testRuntimeOnly('org.junit.platform:junit-platform-launcher')
}
test {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed"
}
핵심 애노테이션
일반적으로 테스트 코드 작성 시 기본적인 내용은 아래와 같다.
- 테스트 클래스 : 클래스, 정적 멤버 클래스, 하나 이상의 테스트 메서드를 포함하는 @Nested 애노테이션이 붙은 내부 클래스를 의미한다.
- 테스트 메서드 : @Test, @RepeatedTest, @ParameterizedTest, @TestFactory, @TestTemplate 애노테이션이 붙은 메서드를 말한다. 테스트 메서드는 추상 메서드일 수 없으며 반환 값을 가질 수 없다. 테스트의 반환 타입은 반드시 void여야 한다.
- 생애 주기 메서드: @BeforeAll, @AfterAll, @BeforeEach, @AfterEach 애노테이션이 붙은 메서드를 말한다.
JUnit은 테스트 메서드의 격리성을 보장하고 테스트 코드에서 의도치 않은 부수 효과를 방지하기 위해 @Test 메서드를 호출하기 전에 클래스 인스턴스를 매번 새로 만든다. 테스트는 실행 순서에 관계없이 동일한 결과를 얻을 수 있어야 하는 것이 당연하기 때문이다.
따라서 각 테스트 메서드는 매번 새로 만들어진 테스트 클래스 인스턴스에서 실행되므로 테스트 메서드 간에 인스턴스 변수를 재사용할 수는 없다. ( 테스트 메서드 단위가 아니라 클래스 단위로 생성하고 싶다면 @TestInstance(Lifecycle.PER_CLASS)를 사용할 수 있기는 하다 )
- @BeforeAll : 테스트가 실행되기 전에 한번 실행된다. @BeforeAll 애노테이션이 붙은 메서드는 테스트 클래스에 @TestInstance(LifeCycle.PER_CLASS) 애노테이션이 없다면 정적(static)으로 선언해야 한다.
- @BeforeEach : 테스트가 실행되기 전에 실행 된다.
- @Test : 해당 애노테이션이 붙은 테스트끼리는 독립적으로 실행 된다.
- @AfterEach : 각 테스트가 실행된 이후에 실행 된다.
- @AfterAll : 애노테이션이 붙은 메서드는 전체 테스트가 실행된 후 한 번 실행 된다. @AfterAll 애노테이션이 붙은 메서드는 테스트 클래스에 @TestInstance(LifeCycle.PER_CLASS) 애노테이션이 없다면 정적(static)으로 선언해야 한다.
@DisplayName
@DisplayName 애노테이션은 테스트 클래스, 테스트 메서드에서 사용할 수 있다. 공백, 특수문자, 이모지도 사용할 수 있으며 일반적으로 테스트 목적을 알려 줄 수 있는 완전한 문장 수준으로 적는 것이 일반적이다.
.
아래는 내가 실제로 작성한 테스트 코드의 일부이다.
@DisplayName("비즈니스 요구사항 검증 - 호출 횟수 기준 상위 세개의 apiServiceID 검색" )
@Test
void Test1() {
...
}
@Disabled
@Disabled 애노테이션은 테스트 클래스나 테스트 메서드에서 사용할 수 있다. @Disabled 사용 시 테스트가 왜 비활성화되어야 하는지에 대해 구체적으로 작성하면 팀원끼리 소통할 때 편리하게 알 수 있다.
예를 들면 이런 식이다.
@Disabled("기능 개발 중")
void test() {
...
}
@Nested, 중첩 테스트
두 개의 클래스의 결합도가 지나치게 높다면, 내부 클래스와 외부 클래스로 만들어 내부 클래스에서 외부 클래스의 모든 인스턴스 변수에 접근할 수 있도록 해주는 것이 합리적이다.
예를 들어 두 가지 승객 타입이 있는 항공편을 테스트한다고 했을 때, 항공편과 관련된 행동은 외부 클래스가 담당하고 두 가지 승객 유형과 관련한 행동은 내부 클래스 담당하는 것을 들 수 있다.
이렇게 inner Class가 존재할 때 중첩 테스트를 지원할 수 있게 하는 것이 @Nested이다.
예시를 보자.
import org.junit.jupiter.api.Nested;
public class NestedTestTest {
private static final String FIRST_NAME = "John";
private static final String LAST_NAME = "Smith";
@Nested
class BuilerTest {
private String MIDDLE_NAME = "Michael";
@Test
void customerBuilder() throws ParseException {
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MM-dd-yyyy");
LocalDate customerDate = LocalDate.parse( "04-21-2019", dateFormatter);
Customer customer = new Customer.Builder(Gender.MALE, FIRST_NAME, LAST_NAME)
.withMiddleName(MIDDLE_NAME)
.withBecomeCustomer(customerDate)
.build();
assertAll(
() -> {
assertEquals(Gender.MALE, customer.getGender());
assertEquals(FIRST_NAME, customer.getFirstName());
assertEquals(LAST_NAME, customer.getLastName());
assertEquals(MIDDLE_NAME, customer.getMiddleName());
assertEquals(customerDate, customer.getBecomeCustomer());
});
}
}
}
중첩 테스트는 개발자가 비즈니스 로직을 잘 따르게 하는 한편, 분명한 테스트 코드를 작성하도록 유도하여 개발자가 테스트 프로세스에 더욱 자연스럽게 적응하도록 만든다.
@Tag
@Tag 애노테이션은 Junit4에서 제공하던 @Category와 유사하다. 태그를 사용하면 테스트를 발견하거나 실행할 때 필터를 적용할 수 있다. 실무에서는 테스트를 몇몇 카테고리로 범주화할 때 태그를 사용한다. 비즈니스 로직이나 기타 기준으로 태그를 달아 테스트를 그룹으로 묶을 수 있다.
사용 방법은 다음과 같다.
@Tag("individual")
public class CustomerTest {
@Test
void test1() {
...
}
}
묶인 태그는 IDE에서 [Run]-[Edit Configurations]에서 관리할 수 있다. 테스트하려는 분류는 개발자가 마음대로 지정해서 관리할 수 있으므로 편리하다.
실제로 나는 Unit과 Integration이라는 키워드를 CustomAnnotation으로 선언하고 사용하고 있다.
아래는 실제 사용 예시이다.
- 커스텀 애노테이션 선언
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Tag("unit")
@Test
public @interface UnitTest {
}
- 단위 테스트를 위한 코드 작성
@UnitTest
void test() {
...
}
- [Run]-[Edit Configurations]에서 Tags 지정
- Run 클릭 시 Tag 별로 지정해서 테스트를 할 수 있다.
- 테스트 커버리지 확인
단언문
결괏값을 검증하려면 Junit5의 Assertions(org.junit.jupiter.api.Assertions) 클래스에서 제공하는 단언문 메서드를 사용해야 한다. Assertions에서 제공하는 메서드들은 정적으로 가져올 수 있다! ( static )
알아두어야 할게, Junit5는 과거에 Hamcrest 매처와 함께 사용했던 assertThat을 더는 지원하지 않는다. 따라서 org.junit.jupiter.api.Assertions 대신 org.hamcrest.MatcherAssert를 사용해야 한다.
cf. Hamcrest
Junit으로 소프트웨어 테스트를 만드는 작업을 도와주는 프레임워크이다. 단언문에 사용자 정의를 한 매처를 이용하여 테스트에서 매치 규칙을 선언적으로 정의할 수 있도록 지원한다.
Junit5에서 제공하는 단언문 메서드는 assertAll, assertEquals, assertX이다. 여기서 assertX는 assertTrue, assertFalse, assertThrow 등 다양하게 제공된다.
assertAll | 오버로딩이 적용되어 있다. 안에 있는 excutable 객체 중 어느 것도 예외를 던지지 않는다고 단언한다. |
assertArrayEquals | 오버로딩이 적용되어 있다. 예상 배열과 실제 배열이 동등하다고 단언한다. |
assertEquals | 오버로딩이 적용되어 있다. 예상 값과 실제 값이 동등하다고 단언한다. |
assertX(..., String message) | 실패했을 경우 message를 테스트 프레임워크에 전달하는 단언문이다. |
assertX(..., Supplier <String> messageSupplier | 실패했을 경우 messageSupplier를 테스트 프레임워크에 전달하는 단언문이다. 실패 메시지는 messageSupplier에 지연(Lazy)전달 된다. |
- assertAll
assertAll 메서드의 좋은 점은 일부 단언문이 실패하더라도 모든 단언문을 항상 검증한다는 것이다.
아래와 같은 검증 조건을 실행해 보자
assertEquals("테스트 대상 시스템", systemUnderTest.getSystemName());
assertTrue(systemUnderTest.isVerified()));
만약 assertEquals 가 검증 실패한다면, assertTrue는 실행되지 않는다. 하지만 assertAll을 사용하면 이런 문제를 해결할 수 있다.
assertAll("테스트 대상 시스템을 검증했는지 확인",
() -> assertEquals("테스트 대상 시스템",
systemUnderTest.getSystemName()),
() -> assertTrue(systemUnderTest.isVerified()));
- assertX(..., Supplier <String> messageSupplier) 꼴
Supplier 인터페이스를 사용하는 것은, assertAll 뿐만 아니라 다른 단언문에서도 사용이 가능하다. 이런 식으로 람다식이나 메서드 참조를 사용하여 시스템을 검증할 경우, 지연 전달 덕분에 성능을 높일 수 있다.
아래 코드에서, systemUserTest.isVerified()가 true를 만족한다면, () -> "테스트 대상 시스템을 검증했는지 확인"이 호출되지 않는다.
assertTrue(systemUnderTest.isVerified(), () -> "테스트 대상 시스템을 검증했는지 확인"); // 람다식을 파라미터로 사용할 경우 지연 전달 덕분에 성능이 향상 됨
- assertTimeout, assertTimeoutPreemptively
Junit5에서는 Juni4에서 제공하던 Timeout Rule을 대체하는 assertTimeout, assertTimeoutPreemptivley를 제공한다. 해당 메서드들을 사용해서 시스템의 성능이 충분한지 확인할 수 있다.
assertTimeout은 객체가 작업을 마칠 때까지 기다렸다가 주어진 시간을 초과하면 테스트가 얼마나 늦었는지 알려주고, assertTimeoutPreemptivley는 주어진 시간이 지나면 객체를 중지시킨다.
@Test
@DisplayName("작업을 마칠 때까지 기다리는 assertTimeout 메서드")
void testTimeout() throws InterruptedException {
systemUnderTest.addJob(new Job("Job 1"));
assertTimeout(ofMillis(500), () -> systemUnderTest.run(200)); // assertTimeout은 객체가 작업을 마칠때까지 기다렸다가 주어진 시간을 초과하면 테스트가 얼마나 늦었는지 알려줌
}
@Test
@DisplayName("시간이 지나면 작업을 중지시키는 assertTimeoutPreemptively 메서드")
void testTimeoutPreemptively() throws InterruptedException {
systemUnderTest.addJob(new Job("Job 1"));
assertTimeoutPreemptively(ofMillis(500), () -> systemUnderTest.run(200)); // assertTimeoutPreemptively는 주어진 시간이 지나면 객체를 중지시킴
}
- assertThrows
Junit5에서는 Juni4에서 제공하던 ExpectedException를 대체하는 방안으로 assertThrows를 제공한다. assertThrows 메서드는 예외가 발생했을 때 Throwable 객체를 반환한다. 개발자는 반환한 Throwable 객체를 단언문으로 검증할 수 있다. 이렇게 하면 시스템에서 할당한 객체가 없을 때 예외를 던지는지 확인할 수 있으므로 테스트 가독성이 좋아진다.
@Test
@DisplayName("예외가 발생하는지 검증한다")
void testExpectedException() {
// Job을 할당하지 않고 run 수행
assertThrows(NoJobException.class, systemUnderTest::run);
}
@UnitTest
@DisplayName("유효하지 않은 데이터 입력시 에러 반환")
void when_INVALID_PARAM_Exception() throws Exception {
//given
LogRequest request = null;
//when, then
CustomException customException = assertThrows(CustomException.class,
() -> workService.getLogStatistics(request));
assertEquals(customException.getErrorCode(), WorkErrorCode.INVALID_PARAM);
}
단언문은 종류가 꽤 다양하고, 직관적인 함수명으로 인해 처음 테스트 코드를 작성하는 사람이라도 손쉽게 사용 가능하다. 소개한 내용 외에도 직접 테스트 코드를 작성해 보는 것을 추천한다. ( 나는 실무에서 assertAll, assertTrue/false, assertEquals, assertThrows를 가장 많이 쓴다 )
가정문
외부 환경이나 우리가 제어할 수 없는 날짜, 시간대 같은 문제 탓에 테스트가 실패할 수도 있다. 이럴 때 가정문을 사용한다면 부적절한 조건에서 테스트가 실행되는 것을 사전에 막을 수 있다. 가정문을 사용하면, 테스트를 수행하는 데 필수인 전제 조건이 충족되었는지 검증할 수 있다. 테스트 리포트에서 가정문에 의해 중단된 테스트는 실패한 것으로 처리한다.
assumeTrue, assumeThat 등을 사용하여 구현할 수 있다.
private TestsEnvironment environment = new TestsEnvironment(
new JavaSpecification(
System.getProperty("java.vm.specification.version")),
new OperationSystem(
System.getProperty("os.name"),
System.getProperty("os.arch"))
);
SUT systemUnderTest = new SUT();
@BeforeEach
void setUp() {
//assumeTrue(environment.isWindows()); // 가정문을 사용 -> 가정문에 따라 테스트를 수행할지 여부를 정할 수 있다.
assumeTrue(environment.isMac()); // 가정문을 사용 -> 가정문에 따라 테스트를 수행할지 여부를 정할 수 있다.
}
@Test
void testNoJobToRun() {
assumingThat(() -> environment.getJavaVersion().equals(EXPECTED_JAVA_VERSION),
() -> assertFalse(systemUnderTest.hasJobToRun()));
} // environment.getJavaVersion().equals(EXPECTED_JAVA_VERSION) 가 만족되어야 뒤에 있는 구문이 실행 된다
@Test
void testJobToRun() {
assumeTrue(environment.isAmd64Architecture()); // 해당가정이 맞을 때만 하위 로직을 수행한다.
systemUnderTest.run(new Job());
assertTrue(systemUnderTest.hasJobToRun());
}
Junit5의 의존성 주입
이전 버전의 Junit에서는 생성자나 메서드에 파라미터가 있는 것을 허용하지 않았고 테스트는 반드시 기본 생성자만 사용해야 했다. Junit5부터는 생성자와 메서드에서 파라미터를 가질 수 있도록 허용하지만, 의존성 주입으로 해결해야 한다는 점이 다르다.
현재 Junit5에서는 TestInfoParameterResolver, TestReporterParmaeterResolver, RepetitionInfoParameterResolver가 내장되어 있다. 다른 파라미터 리졸버를 사용하고 싶은 경우 @ExtendWith을 사용하여 적절한 extension적용 후 파라미터 리졸버를 명시해야 한다.
이 부분은 단순한 서비스 레이어의 단위 테스트를 진행할 때보다는 테스트의 전반적인 규격을 관리할 때 유용하다고 느꼈다. 특히 TestReporter는 잘 응용하면 테스트 로깅 시 편리하게 응용이 가능하다고 본다.
- TestInfoParamterResolver
TestInfo는 현재 실행 중인 테스트나 컨테이너에 관한 정보를 제공하기 위해 사용한다. @Test, @BeforeEach, @AfterEach, @BeforeAll, @AfterAll 애노테이션이 달린 메서드에서 TestInfo 객체를 파라미터로 사용할 수 있다.
/**
* TestInfoParameterResolver를 사용하는 예제 -> 테스트 클래스 생성자나 테스트 메서드에서 [TestInfo] 객체를 파라미터로 사용할 수 있음.
* TestInfo -> @Test, @BeforeEach, @AfterEach, @BeforeAll, @AfterAll 애노테이션이 달린 메서드에서 TestInfo 객체를 파라미터로 사용할 수 있음.
* -> TestInfo객체는 현재의 컨테이너 또는 테스트에 대응함, 생성자나 메서드에서 테스트에 관한 정보를 제공하는 데 사용한다.
* */
public class TestInfoTest {
TestInfoTest(TestInfo testInfo){
// displayName이 TestInfoTest인지 검증함 -> 클래스의 이름 확인
assertEquals("TestInfoTest", testInfo.getDisplayName()); // displayName의 기본 값은 메서드명임.
}
@BeforeEach
void setUp(TestInfo testInfo) {
String displayName = testInfo.getDisplayName();
assertTrue(displayName.equals("display name of the method") || displayName.equals("testGetNameOfTheMethod(TestInfo)"));
}
@Test
void testGetNameOfTheMethod(TestInfo testInfo) {
assertEquals("testGetNameOfTheMethod(TestInfo)", testInfo.getDisplayName());
}
@Test
@DisplayName("display name of the method")
void testGetNameOfTheMethodWithDisplayNameAnnotation(TestInfo testInfo) {
assertEquals("display name of the method", testInfo.getDisplayName());
}
}
- TestReporterParameterResovler
TestReporter는 함수형 인터페이스이므로 람다식이나 메서드 참조로 사용할 수 있다. @BeforeEach, @AfterEach, @Test 애노테이션이 달린 테스트 메서드에 주입할 수 있다. TestReporter 객체를 사용하면, 현재 실행되는 테스트에 추가적인 정보를 제공할 때 사용한다.
/**
* TestReporterParameterResolver를 사용하는 예제 -> TestReporter은 한 개의 추상 메서드 publishEntry와 publishEntry 메서드를 오버로딩한 디폴트 메서드 여러개를 가짐
* TestReporter 타입의 파라미터 -> @BeforeEach, @AfterEach, @Test 애노테이션이 달린 테스트 메서드에 주입할 수 있음
* -> 현재 실행되는 테스트에 추가적인 정보를 제공할 때 사용함
* */
public class TestReporterTest {
@Test
void testReportSingleValue(TestReporter testReporter) {
testReporter.publishEntry("Single value");
}
@Test
void testReportKeyValuePair(TestReporter testReporter) {
testReporter.publishEntry("Key", "Value");
}
@Test
void testReportMultipleKeyValuePairs(TestReporter testReporter) {
Map<String, String> values = new HashMap<>();
values.put("user", "John");
values.put("password", "secret");
testReporter.publishEntry(values);
}
}
- RepititionInfoParameterResolver
@RepeatTest, @BeforeEach, @AfterEach 애노테이션이 달린 메서드의 파라미터가 RepetitionInfo 타입일 때 RepetitionInfo 인스턴스를 리졸브 하는 역할을 한다. RepititionInfo는 @RepeatedTest 애노테이션이 달린 테스트에 대한 현재 반복 인덱스와 총 반복 횟수에 대한 정보를 가지고 있다.
public class RepeatedTestsTest {
private static Set<Integer> integerSet = new HashSet<>();
private static List<Integer> integerList = new ArrayList<>();
/**
* {displayName} : @RepeatedTest 애노테이션이 붙은 메서드의 디스플레이 네임
* {currentRepetition} : 현재 반복 인덱스
* {totalRepetitions} : 총 반복 횟수
* */
@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 testReporter, RepetitionInfo repetitionInfo) {
integerSet.add(1);
integerList.add(repetitionInfo.getCurrentRepetition());
testReporter.publishEntry("Repetition number", String.valueOf(repetitionInfo.getCurrentRepetition()));
assertEquals(1, integerSet.size());
assertEquals(repetitionInfo.getCurrentRepetition(), integerList.size());
}
}
파라미터를 사용한 테스트(Parameterized)
파라미터를 사용한 테스트에는 @ParameterizedTest 애노테이션이 달려 있다. 테스트에 파라미터를 사용하려면 반드시 각 반복에 대한 파라미터를 제공하는 소스(@ValueSource, @EnumSource, @CsvSource)를 선언해야 한다.
@ValueSource 애노테이션을 사용하면 문자열 배열을 입력 값으로 지정할 수 있고, @EnumSource를 사용하면 파라미터에 열거형을 사용할 수 있다.
- @ValueSource를 사용한 예제
/**
* 하나의 테스트를 다양한 파라미터를 가지고 여러 번 실행하게 해준다. -> 다양한 입력을 두고 테스트를 실행할 수 있다.
*
* */
public class ParameterizedWithValueSourceTest {
private WordCounter wordCounter = new WordCounter();
@ParameterizedTest
@ValueSource(strings = {"Check three parameters", "Junit in Action"}) // @ValueSource를 사용하여 테스트 메서드의 파라미터로 전달할 값을 특정한다. 문자열의 수만큼 실행 된다.
void testWordsInSentence(String sentence) {
//countWords(String s)는 입력된 값에서 공백을 기준으로 split한 String 배열의 길이를 반환한다.
assertEquals(3, wordCounter.countWords(sentence)); // 총 두번 실행 됨
}
}
- @EnumSource를 사용한 예제
public class ParameterizedWithEnumSourceTest {
private WordCounter wordCounter = new WordCounter();
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;
}
}
@ParameterizedTest
@EnumSource(Sentences.class)
void testWordsInSentence(Sentences sentences) {
// countWords -> 들어온 문장을 공백을 기준으로 쪼개 배열로 반환
assertEquals(3, wordCounter.countWords(sentences.value())); // 총 세번 실행 됨
}
@ParameterizedTest
@EnumSource(value = Sentences.class, names = {"JUNIT_IN_ACTION", "THREE_PARAMETERS"})
void testSelectedWordsInSentence(Sentences sentences) {
assertEquals(3, wordCounter.countWords(sentences.value())); // 총 두번 실행 됨
}
@ParameterizedTest
@EnumSource(value = Sentences.class, mode = EXCLUDE, names = {"THREE_PARAMETERS"}) // THREE_PARAMETERS를 제외함
void testExcludeWordsInSentence(Sentences sentences) {
assertEquals(3, wordCounter.countWords(sentences.value())); // 총 한번 실행 됨
}
}
- @CsvSource를 사용한 예제
public class ParameterizedWithCsvSourceTest {
private WordCounter wordCounter = new WordCounter();
@ParameterizedTest
@CsvSource({"2, Unit testing", "3, Junit in Action", "4, write soilid Java Cod"}) // CSV 형식의 문자열에서 구문을 분석
void testWordsInSentence(int expected, String sentence) { // ','을 기준으로 쪼갬 첫번째 값은 expected, 두번째 값은 sentence에 할당 됨
// countWords -> 들어온 문장을 공백을 기준으로 쪼개 배열로 반환
assertEquals(expected, wordCounter.countWords(sentence));
}
}
동적 테스트
Junit5는 런타임에 테스트를 생성할 수 있는 동적 프로그래밍 모델을 도입했다. 개발자가 팩토리 메서드를 작성하기만 하면 프레임워크가 런타임에 실행할 테스트를 생성한다.
@TestFactory메서드는 일반적인 테스트가 아니라 테스트를 생성하는 팩토리다.
private final PositiveNumberPredicate predicate = new PositiveNumberPredicate();
@BeforeAll
static void setUpClass() {
System.out.println("@BeforeAll method");
}
@AfterAll
static void tearDownClass() {
System.out.println("@AfterAll method");
}
@BeforeEach
void setUp() {
System.out.println("@BeforeEach method");
}
@AfterEach
void tearDown() {
System.out.println("@AfterEach method");
}
/**
*
* DynamicTest 인터페이스는 디스플레이 네임과 Excutable로 이루어져 있고 런타임에 생성되는 테스트다.
* Excutable은 자바8에서 등장한 함수형 인터페이스이므로 동적 테스트는 람다식이나 메서드 참조 방식으로 구현할 수 있다.
*
* */
@TestFactory
Iterator<DynamicTest> positiveNumberPredicateTestCase() {
// predicate.check -> 입력값이 0 이상인지 반환
return asList(
dynamicTest("negative number", () -> assertFalse(predicate.check(-1))),
dynamicTest("zero", () -> assertFalse(predicate.check(0))),
dynamicTest("positive number", () -> assertTrue(predicate.check(1)))
).iterator();
}
dynamicTest("negative number", () -> assertFalse(predicate.check(-1))) 는 "negative number"라는 displayname을 가진 테스트를 만들어 실행시키는데, 여기서 실행하는 내용이 assertFalse(predicate.check(-1)))에 해당한다.
일반적으로 @BeforeEach, @AfterEach는 테스트마다 동작하는데, @TestFactory 사용 시 @TestFactory에서 생성한 테스트 별로 실행되지 않고 @TestFactory 단위로 실행되는 것에 유의하자.
위의 예제는 @TestFactory에서 DynamicNode의 Iterator를 반환했는데, 이 외에도 다음과 같은 반환 대상을 지원한다.
DynamicNode ( 추상 클래, DynamicContainer나 DynamicTest가 DynamicNode를 상속하였고, 인스턴스화가 가능한 구체 클래스다 ) |
DynamicNode 객체의 배열 |
DynamicNode 객체의 스트림 |
DynamicNode 객체의 컬렉션 |
DynamicNode 객체의 컬렉션 |
DynamicNode 객체의 Iterable |
DynamicNode 객체의 반복자 ( Iterator) |
- DynamicNode
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package org.junit.jupiter.api;
import java.net.URI;
import java.util.Optional;
import org.apiguardian.api.API;
import org.apiguardian.api.API.Status;
import org.junit.platform.commons.util.Preconditions;
import org.junit.platform.commons.util.ToStringBuilder;
@API(
status = Status.MAINTAINED,
since = "5.3"
)
public abstract class DynamicNode {
private final String displayName;
private final URI testSourceUri;
DynamicNode(String displayName, URI testSourceUri) {
this.displayName = Preconditions.notBlank(displayName, "displayName must not be null or blank");
this.testSourceUri = testSourceUri;
}
public String getDisplayName() {
return this.displayName;
}
public Optional<URI> getTestSourceUri() {
return Optional.ofNullable(this.testSourceUri);
}
public String toString() {
return (new ToStringBuilder(this)).append("displayName", this.displayName).append("testSourceUri", this.testSourceUri).toString();
}
}
- DynamicTest
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package org.junit.jupiter.api;
import java.net.URI;
import java.util.Iterator;
import java.util.Spliterators;
import java.util.function.Function;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import org.apiguardian.api.API;
import org.apiguardian.api.API.Status;
import org.junit.jupiter.api.function.Executable;
import org.junit.jupiter.api.function.ThrowingConsumer;
import org.junit.platform.commons.util.Preconditions;
@API(
status = Status.MAINTAINED,
since = "5.3"
)
public class DynamicTest extends DynamicNode {
private final Executable executable;
public static DynamicTest dynamicTest(String displayName, Executable executable) {
return new DynamicTest(displayName, (URI)null, executable);
}
public static DynamicTest dynamicTest(String displayName, URI testSourceUri, Executable executable) {
return new DynamicTest(displayName, testSourceUri, executable);
}
public static <T> Stream<DynamicTest> stream(Iterator<T> inputGenerator, Function<? super T, String> displayNameGenerator, ThrowingConsumer<? super T> testExecutor) {
Preconditions.notNull(inputGenerator, "inputGenerator must not be null");
return stream(StreamSupport.stream(Spliterators.spliteratorUnknownSize(inputGenerator, 16), false), displayNameGenerator, testExecutor);
}
@API(
status = Status.MAINTAINED,
since = "5.7"
)
public static <T> Stream<DynamicTest> stream(Stream<T> inputStream, Function<? super T, String> displayNameGenerator, ThrowingConsumer<? super T> testExecutor) {
Preconditions.notNull(inputStream, "inputStream must not be null");
Preconditions.notNull(displayNameGenerator, "displayNameGenerator must not be null");
Preconditions.notNull(testExecutor, "testExecutor must not be null");
return inputStream.map((input) -> {
return dynamicTest((String)displayNameGenerator.apply(input), () -> {
testExecutor.accept(input);
});
});
}
@API(
status = Status.MAINTAINED,
since = "5.8"
)
public static <T> Stream<DynamicTest> stream(Iterator<? extends Named<T>> inputGenerator, ThrowingConsumer<? super T> testExecutor) {
Preconditions.notNull(inputGenerator, "inputGenerator must not be null");
return stream(StreamSupport.stream(Spliterators.spliteratorUnknownSize(inputGenerator, 16), false), testExecutor);
}
@API(
status = Status.MAINTAINED,
since = "5.8"
)
public static <T> Stream<DynamicTest> stream(Stream<? extends Named<T>> inputStream, ThrowingConsumer<? super T> testExecutor) {
Preconditions.notNull(inputStream, "inputStream must not be null");
Preconditions.notNull(testExecutor, "testExecutor must not be null");
return inputStream.map((input) -> {
return dynamicTest(input.getName(), () -> {
testExecutor.accept(input.getPayload());
});
});
}
private DynamicTest(String displayName, URI testSourceUri, Executable executable) {
super(displayName, testSourceUri);
this.executable = (Executable)Preconditions.notNull(executable, "executable must not be null");
}
public Executable getExecutable() {
return this.executable;
}
}
'Dev > TDD' 카테고리의 다른 글
Junit In Action - TDD를 위한 테스트 원칙, 도구 및 활용 Review - Junit4와 Junit5 비교 (0) | 2024.06.26 |
---|---|
Junit In Action - TDD를 위한 테스트 원칙, 도구 및 활용 Review - Junit 5 아키텍처 개요 ( Junit4와 어떻게 달라졌을까? ) (2) | 2024.06.14 |
Junit In Action - TDD를 위한 테스트 원칙, 도구 및 활용 Review - 핵심 애노테이션 ( 2/2 ) (1) | 2024.06.14 |
MockMvc 을 사용해서 Controller 테스트 코드 작성하기 (1) | 2024.01.29 |
@Transactional 사용시 Insert/update 유의점 (0) | 2024.01.29 |