이 글에서는 Junit4에서 Junit5으로 migration 하는 과정을 공유드립니다.

Junit5로 마이그레이션은 Junit4를 그대로 사용해도 되지만 Junit5에서는 어떤 기능들이 변경되었고 어떤 기능이 추가되었는지 확인하고자 Junit5 migrate 방법신규 기능 적용 내용들을 정리해 작성했습니다.

Migration 과정

1. Dependency 변경

Spring Boot는 2.2 부터 JUnit5가 기본으로 채택되었습니다.

 

pom.xml

  1. maven-surefire-plugin 2.22.0 버전 이상 plugin 추가
<plugin>
   <artifactId>maven-surefire-plugin</artifactId>
   <version>2.22.2</version>
</plugin>
  1. Spring Boot 2.2 이상 버전 spring-boot-starter-test dependency 추가
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring-boot.version}</version>
<scope>test</scope>
  1. 기존 junit4와 관련된 dependency 제거
<!-- 제거 해야합니다 -->
<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>${junit.version}</version> <!-- 4.12 -->
    <scope>test</scope>
</dependency>

 

주의사항 1:  ScriptEvaluationException

다음과 같이 진행할 경우 다음 오류가 발생할 수 있습니다.

Caused by: java.lang.ClassNotFoundException: org.junit.jupiter.api.extension.ScriptEvaluationException

위의 경우, 한 클래스 내에서 junit4와 junit5를 혼합해 사용하면 발생하는 이슈였습니다. 오류 발생시 남아있는 junit4 logic을 확인해보시길 바랍니다. (저 같은 경우 @RunWith를 @ExtendWith로 변경하지 않아 발생한 오류였습니다.)

주의사항 2:  maven-surefire-plugin version

말로는 2.22.0 버전 이상을 사용할 경우 이상이 없다고 하지만, 어플리케이션 테스트 관련 이슈가 존재해 plugin version을 2.22.2버전을 사용하였습니다. (ex) @ExtentionWith 실행시 오류 발생 등)

2. Migration

 

junit4와 junit5의 변경된 부분은 크게 두 가지 부분이 존재합니다.

  • annotation
  • dependency (org.junit.jupiter.api 사용)

2-1 annotation

annotation은 크게 다음과 같은 내용들이 변경되었습니다.

  1. @RunWith(SpringRunner.class) -> @ExtendWith(SpringExtension.class)
  2. @Rule -> @ExtendWith
  3. @Before, @After -> @BeforeEach, @AfterEach
  4. @BeforeClass, @AfterClass -> @BeforeAll, @AfterAll
  5. @Ignore -> @Disabled or @DisabledIf(조건문)

2-2 dependency

  1. junit5 는 org.junit.jupiter.api를 사용합니다.
  2. 기존에 사용하던 org.junit.Assert는 AssertJ, Hamcrest, Truth 등으로 변경합니다.

Junit5 기능 적용하기 (feat. 회사 프로젝트 남몰래 바꾸기)

migration 이후 개인적으로 어떤 부분이 junit 4에서 5로 기능을 수정하면 좋을까에 대한 고민을 해봤습니다.

가이드를 참고하더라도 실제로 적용해보지 않으니 어떤 기능이 개선된 것인지 감이 오지않아 Condition Checker에 junit5의 일부 기능을 도입해 보았습니다.

추가된 어노테이션들을 대상으로 리팩토링을 진행하였습니다.

 

1. @DisplayName (Junit5 User Guide(공식 문서) - 2.4. Display Names)

기존에는 테스트 코드 실행시 테스트 메소드 명칭이 나왔으나 junit5부터는 @DisplayName 어노테이션을 통해 통합 IDE에서 테스트 실행시 보여지는 메소드 명칭을 수정할 수 있습니다.

이를 통해 테스트 수행시 좀 더 해당 메소드가 수행하는 내용을 직관적으로 설명이 가능해집니다. 아래 테스트 내용을 통해 실행 결과가 어떻게 변경되었는지 보여드리겠습니다.

변경 전

변경 후

해당 코드를 작성하지 않은 사람이 테스트 코드를 수행할 경우 어떤 기능을 테스트하고 있는지 이전보다 더 직관적으로 보여주는 것을 확인할 수 있습니다.

 

2. @Disabled (Junit5 User Guide(공식 문서) - 2.8. Conditional Test Execution)

Junit4에서 사용되는 @Ignore가 Junit5에서는 @Disabled로 변경되었습니다.

이 뿐만 아니라, @DisabledIf, @EnabledIf, @EnabledOnOs 등 다양한 조건문 어노테이션을 통해 테스트를 조건부로 실행시킬 수 있습니다.

예시

@Disabled("서버마다 공통된 mount 정보가 없어 테스트 불가능.")
@Test
public void test_get_mountDetailInfo_FAILED_when_data_not_exist() throws Exception {
    // Given
    MountStatus mountStatus = new MountStatus(getMountPath());

    // When
    String result = mountStatus.getDetail();

    // Then
    assertEquals("Nas path : devfs\n" + "Mount local path : /dev/\n" + 
    "Total : 189\n" + "Used : 189\n" + "Usage percent : 100%\n", result);
}

 

3. @TestMethodOrder (Junit5 User Guide(공식 문서) - 2.10. Test Execution Order)

기존 junit4에서는 @FixedMethodOrderMethodSorters의 조합으로 테스트 순서를 정의할 수 있었습니다. 하지만 해당 어노테이션으로는 테스트 메소드 순서 정의에 있어 기능이 적어 일부 제한이 존재하였습니다.
(MethodSorters.DEFAULT, MethodSorters.JVM, MethodSorters.NAME_ASCENDING 세 개가 전부)

Junit5에서는 @TestMethodOrderTestClassOrder를 지원하며 @Order를 통해 원하는 테스트 클래스/메소드 실행 순서를 정의하여 실행이 가능해집니다.

 

예시

@TestMethodOrder(OrderAnnotation.class)
class OrderedTestsDemo {
 @Test
 @Order(1)
 void nullValues() {
 // perform assertions against null values
 }
 @Test
 @Order(2)
 void emptyValues() {
 // perform assertions against empty values
 }
 @Test
 @Order(3)
 void validValues() {
 // perform assertions against valid values
 }
}

 

해당 어노테이션은 ConditionInfoCheckerTest->ConditionCheckerTest 테스트 클래스에 적용되었으니 condition checker junit5 @TestMethodOrder 적용 건을 확인해보셔도 좋을 것 같습니다.

 

4. @Nested(Junit5 User Guide(공식 문서) - 2.12. Nested Tests)

기존 junit4에서는 inner class를 이용한 테스트 그룹핑이 불가능하였으나 junit5부터는 @Nested 어노테이션을 이용할 시 테스트간 그룹핑이 가능해졌습니다.

하나의 테스트 클래스에서 테스트를 수행하지만 수행하는 테스트의 기능이 여러가지일 경우 기능별로 묶어 수행 결과에 대해 좀 더 직관적으로 확인이 가능해집니다.

 

적용 대상 클래스를 확인해보니 3가지 그룹으로 그룹핑이 가능했고,  이들을 @Nested로 그룹화하여 inner class로 코드를 리팩토링하였습니다.

 

기존 결과(변경 전)

변경 후 결과

다음과 같이 변경된 이후 좋았던 것은 그룹핑 전엔 보이지 않았던 기능별 테스트 목록이 그룹핑 후 조금 더 직관적으로 보였다는 부분입니다. @DisplayName과 함께 사용하니 그룹별로 어떤 기능의 테스트가 미흡한지 보다 더 잘 알 수 있어 테스트 코드를 추가로 보완할 수 있었습니다.

 

5. @RepeatedTest (Junit5 User Guide(공식 문서) - 2.15. Repeated Tests)

반복 테스트로서 해당 어노테이션을 정의한 테스트는 원하는 반복 횟수를 정의하면 정의한 횟수만큼 테스트를 수행합니다.

@RepeatedTest를 테스트 메서드에 붙일 경우 다음과 같은 실행 결과를 얻을 수 있습니다.

 

RepeatedTest 예시

 

6. @ParameterizedTest(Junit5 User Guide(공식 문서) - 2.16. Parameterized Tests)

기존 junit4에서의 테스트 메소드는 파라미터를 받을 수 없는 구조로 구현되어 있었습니다. 따라서 테스트 코드 작성시 내부에서 테스트 입력 값은 메소드 내부에서 하드코딩해 생성하거나 @BeforeClass, @Before를 통해 값을 생성하였습니다.

Junit5 부터는 @ParameterizedTest를 통해 테스트 데이터를 어노테이션을 통해 파라미터값으로 입력받을 수 있습니다.

 

대표 어노테이션

  • @CsvSource: Csv 파일과 같은 값으로 테스트 파라미터를 받을 수 있음
  • @ValueSource: type을 지정해 해당 타입의 값을 파라미터로 정의할 수 있음
  • @EnumSource: Enum 클래스를 정의 또는 인용하여 테스트 가능
  • @MethodSource: 메소드 결과 값으로 반환된 결과를 사용할 수 있음(외부 메소드도 적용 가능)
    • 주의 사항: 반드시 인자의 stream을 만들어야 함

해당 어노테이션들은 테스트 수행시 명시적으로 또는 암시적으로 사용할 수 있는 기능이 존재합니다.

 

암시적 선언 예시

@ParameterizedTest
@ValueSource(strings = "42 Cats")
void testWithImplicitFallbackArgumentConversion(Book book) {
  assertEquals("42 Cats", book.getTitle());
}

명시적 선언 예시

@ParameterizedTest
@ValueSource(strings = "42 Cats")
void testWithImplicitFallbackArgumentConversion(String title) {
  assertEquals("42 Cats", title);
}

이제 NotificationServiceStatusTest를 통해 해당 테스트의 예시를 들어보겠습니다. 해당 코드 또는 아래 내용을 참고해주시면 감사하겠습니다.

기존 코드 (변경 전)

public class NotificationServiceStatusTest {


    @Test
    public void test_collector_status_false() {
        assertFalse(new NotificationServiceStatus(WRONG_DOMAIN, NotificationService.COLLECTOR).check());
    }

    @Test
    public void test_file_status_true() {
        assertTrue(new NotificationServiceStatus(conditionConfiguration.getStatsApiUrl(), NotificationService.FILE).check());
    }

    @Test
    public void test_file_status_false() {
        assertFalse(new NotificationServiceStatus(WRONG_DOMAIN, NotificationService.FILE).check());
    }

    @Test
    public void test_webHook_status_true() {
        assertTrue(new NotificationServiceStatus(conditionConfiguration.getStatsApiUrl(), NotificationService.WEBHOOK).check());
    }

    @Test
    public void test_webHook_status_false() {
        assertFalse(new NotificationServiceStatus(WRONG_DOMAIN, NotificationService.WEBHOOK).check());
    }

    @Test
    public void test_tag_status_true() {
        assertTrue(new NotificationServiceStatus(conditionConfiguration.getStatsApiUrl(), NotificationService.TAG).check());
    }

    @Test
    public void test_tag_status_false() {
        assertFalse(new NotificationServiceStatus(WRONG_DOMAIN, NotificationService.TAG).check());
    }

}

실행 결과 (변경 전)


변경 후

해당 코드는 모두 NotificationService enum 클래스의 값으로 테스트를 수행하므로 @EnumSource를 이용한 테스트로 통합하였습니다.

public class NotificationServiceStatusTest{

    @ParameterizedTest
    @EnumSource(NotificationService.class)
    @DisplayName("NotificationServiceStatus Status check using NotificationService - SUCCESS")
    public void test_notificationServiceStatus_true(NotificationService notificationService) {
        assertTrue(new NotificationServiceStatus(conditionConfiguration.getStatsApiUrl(), notificationService).check());
    }

    @ParameterizedTest
    @EnumSource(NotificationService.class)
    @DisplayName("NotificationServiceStatus Status check using NotificationService and Wrong Domain - FAIL")
    public void test_notificationServiceStatus_false(NotificationService notificationService) {
        assertFalse(new NotificationServiceStatus(WRONG_DOMAIN, notificationService).check());
    }
}

 

실행 결과(변경 후)

 

7. @MethodSource(Junit5 User Guide(공식 문서) - 2.16. Parameterized Tests)

@ParameterizedTest의 연장선상에 있는 기능입니다. @MethodSource을 사용할 경우 호출한 메소드를 테스트 메소드의 파라미터로 받을 수 있게 됩니다.

주의사항으로서는 무조건 Stream 객체로 반환해야 된다는 점이 있습니다.

 

예시

@ParameterizedTest
@MethodSource("statsStatusSuccessDomainList")
@DisplayName("NotificationServiceStatus Status check - SUCCESS(basic, add last slash, HTTP protocol)")
public void test_stats_status_true(NotificationServiceStatus notificationServiceStatus) {
    assertTrue(notificationServiceStatus.check());
}

static Stream<NotificationServiceStatus> statsStatusSuccessDomainList() {
    return Stream.of(new NotificationServiceStatus(properties.getApiUrl(), NotificationService.STATS),
            new NotificationServiceStatus(properties.getApiUrl() + "/", NotificationService.STATS),
            new NotificationServiceStatus("http://" + properties.getApiUrl(), NotificationService.STATS));
}

 

8. @NullAndEmptySource(Junit5 User Guide(공식 문서) - 2.16. Parameterized Tests)

@ParameterizedTest의 연장선상에 있는 어노테이션입니다. 해당 어노테이션을 추가할 경우 Null과 빈값을 파라미터로 받을 수 있습니다.

각각 호출하고 싶은 경우 @NullSource 및 @EmptySource를 사용하시면 될 것 같습니다.

참고 예제는 코드를 통해 확인해 주시면 감사하겠습니다.

주의사항

inner class내에서 @BeforeAll을 적용할 경우 정상적으로 테스트코드가 적용되지 않는 오류가 발생했습니다. 오류 내용은 다음과 같습니다.

org.junit.platform.commons.JUnitException: @BeforeAll method 
'public void com.test.nhn.condition.master.ConditionTest.beforeClass() throws java.io.IOException'
must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).

오류의 마지막 문구에 @TestInstance(Lifecycle.PER_CLASS) 적용시 static을 적용하지 않아도 된다는 문구가 존재하여 @TestInstance(Lifecycle.PER_CLASS)를 사용해 테스트 코드를 작성하였습니다.

 

결론

현재 적용한 기능 말고도 여러 기능이 있지만 예시로 든 프로젝트에서는 테스트 코드에 큰 복잡성이 없어서 모든 기능을 다 사용해보진 못한 것 같습니다.

 

테스트 코드에 junit5를 적용하면서 느낀 것은 다음과 같습니다.

  • @DisplayName을 통해 해당 테스트가 무엇을 수행하는지 명시적으로 작성할 수 있습니다.
    • 예제에는 맨 마지막에 SUCCESS, FAIL을 붙였는데 접두사로 사용하면 좀 더 괜찮지 않을까.. 싶습니다.
  • JunitParams(@ValueSource 등)을 통해 어떤 값을 테스트 인자로 사용하는지를 직관적으로 알 수 있습니다.
  • @Nested를 통해 테스트 그룹핑이 용이해졌습니다.
  • @DisabledIf와 같은 조건문을 통해 환경별/상황별 테스트 실행 유무를 관리할 수 있습니다. 

제가 사용한 Junit5의 어노테이션들외에도 다양한 추가 기능들에 대해서는 별첨. 미사용 기능에 추가작성 하였으니 테스트 프레임워크에 대한 관심이 높으신 분들께서는  junit5 dynamic test Junit5 User Guide(공식 문서)를 참고해주시면 감사하겠습니다.

 

저 또한 미사용 기능들에 대해 한 번씩은 사용해보기 위해 좀 더 복잡한 로직을 찾아 코드를 작성해보고 이를 공유해보도록 하겠습니다. (회사 코드를 바꿔가면서 진행해보려 합니다.)

 

읽어주셔서 감사합니다.

별첨. 미사용 기능

  • @TestFactory
  • @TestTemplate
  • @TestInstance
  • @Tag
  • @DisplayNameGeneration
  • @RegisterExtension
  • @Timeout
  • @TempDir

Reference

'기타 > 테스트' 카테고리의 다른 글

3. Junit 5  (0) 2023.05.01
1. 테스트 코드는 왜 만들까?  (0) 2023.04.08
2. Test Double  (0) 2023.03.25

오늘은 Java의 대표적인 테스트 프레임워크인 Junit5에 대해 자세하게 알아보는 시간을 가져보겠습니다.

 

공식 문서를 참고하면 junit5는 다음과 같이 정의할 수 있습니다.

JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

그렇다면 세 가지 구성 요소에 대해 알아볼까요?

 

Junit5의 3요소

Junit Platform

  • JVM 위에서 테스트 프레임워크가 실행되도록 기초를 제공
  • 플랫폼에서 실행되는 테스트 프레임워크를 개발하기 위한 테스트 엔진 API를 정의
  • 플랫폼을 시작할 수 있는 콘솔 런처를 제공(command line 기반)

JUnit Jupiter

  • 테스트를 위한 프로그래밍 모델과 확장 모델의 조합
  • JUnit 5에서 테스트를 작성하고 확장할 수 있도록 하는 Jupiter 기반 테스트 엔진

JUnit Vintage

  • JUnit 3과 JUnit 4 를 지원하기 위한 테스트 엔진 제공

 

요약하자면 Junit5는 새로운 기능을 Junit Platform + JUnit Jupiter으로 제공하며 기존 junit3와 junit4를 호환시키는 JUnit Vintage를 포함한 Junit의 차세대 테스팅 프레임워크입니다.

 

 

Junit5 호환 버전

  • Junit4: JDK5 버전 이상
  • Junit5: JDK8 버전 이상

 

Junit5의 특징

1. JUnit Vintage가 junit3과 junit4를 호환합니다.

말 그대로 junit3와 junit4를 호환하기 때문에 기존에 junit을 이용하고 있던 사용자들도 부담없이 버전을 업그레이드 할 수 있습니다.

 

2. 좀 더 직관적인 annotation

테스트 코드 어노테이션이 보다 더 직관적으로 변경되었습니다.
기존 junit4에서 사용하던  몇몇 annotation의 의미에 대해 모호하게 느낄 수 있었는데 해당 부분이 개선되었습니다.

 

다음은 Junit4와 Junit5에서 제공하는 동일한 기능의 어노테이션을 비교한 자료입니다.

 

Feature JUnit4 vs Junit5

메소드 기능 설명 Junit4 Junit5
Declare a test method @Test @Test
Execute before all test methods in the current class @BeforeClass @BeforeAll
Execute after all test methods in the current class @AfterClass @AfterAll
Execute before each test method @Before @BeforeEach
Execute after each test method @After @AfterEach
Disable a test method/class @Ignore @Disabled
Test factory for dynamic tests N/A @TestFactory
Nested tests NA @Nested
Tagging and filtering @Category @Tag
Register custom extensions N/A @ExtendWith

해당 Feature와 어노테이션을 비교할 경우, 각각의 표현에 있어 Junit5가 보다 더 직관적임을 알 수 있습니다.

 

3. Reflection에 의존하지 않아도 됩니다.

Junit4에서는 plugin과 ide의 통합 지원이 없었기 때문에 reflection에 의존해야만 했습니다.
Junit5 부터는 JUnit Platform이라는 테스트코드 통합 지원 플랫폼이 제공되어 테스트 코드 실행이 해당 플랫폼 위에서 수행됩니다.

 

4. 다양한 기능의 추가

Junit4에서 있었던 기능들이 신규로 추가/개선되어 제공되고 있습니다. Junit5 도입시 보다 더 다양한 기능으로 테스트 코드를 작성할 수 있습니다. (다음 글에서 작성 예정)

 

5. private 메소드 테스트 허용

junit4에서는 모든 test method는 public 접근 제한자를 선언해야만 이용이 가능하였습니다. Junit5에서는 Reflection을 통해 자동으로 테스트 클래스를 검색하여 테스트 수행이 가능해집니다.

 


결론

개발자가 테스트 코드를 작성하기 위해 Junit을 도입할 경우 Junit5를 도입해야만 합니다. 이유는 다음과 같습니다.

 

  • junit3, junit4를 계속 사용하셔도 호환되기 때문에 개발에 크게 문제되지 않습니다.
  • 개발자는 기존 코드를 유지한 채로 신규 테스트코드에 한하여 junit5를 도입할 수 있습니다.

다음과 같은 이유로Junit5 도입에 있어 두려움을 크게 가질 필요가 없을 것이라고 생각됩니다. 여러분도 한 번 junit5 도입을 적극적으로 고려하는건 어떨까요? 다음 글에서는 junit5 기능에 대해 좀 더 상세히 다루는 시간을 가져보겠습니다.

 

감사합니다.

Reference

https://howtodoinjava.com/junit5/junit-5-vs-junit-4/

'기타 > 테스트' 카테고리의 다른 글

4. How to Migrate and Using Junit 5  (0) 2023.05.01
1. 테스트 코드는 왜 만들까?  (0) 2023.04.08
2. Test Double  (0) 2023.03.25

이 글은 https://yozm.wishket.com/magazine/detail/1964을 읽고 내용 및 생각을 정리한 글입니다.

 

테스트 코드는 왜 만들까? | 요즘IT

지금 돌이켜 생각하면 부끄러운 일이지만, 처음 테스트 코드를 마주했을 때 든 생각은 '왜 귀찮은 테스트 코드를 만들어야 하는 걸까?'였습니다. 물론 지금은 테스트 코드의 중요성을 깨달아 열

yozm.wishket.com

해당 본문이 테스트 코드에 대한 정리가 잘 되어 있어서 해당 글을 정독하시는걸 추천드립니다.

 

 

테스트 코드란?

  • 소프트웨어의 기능과 동작을 테스트하는데 사용되는 코드. 
  • 개발자가 작성한 코드를 실행하고 예상된 결과가 나오는지 확인하는데 사용
  • 테스트 코드의 종류로는 단위 테스트. 통합 테스트, 시스템 테스트, 사용자 인수 테스트 등이 존재

개발자가 주로 다루는 테스트 코드

단위 테스트 (Unit Test): 개별적인 코드 단위(ex) 함수,메서드)가 의도한대로 작동하는지 확인하는 과정

통합 테스트(Integration Test): 서로 다른 모듈들 간의 상호작용을 테스트하는 과정.

 

테스트 코드를 작성하는 이유

  • 테스트 코드를 작성하면 요구사항의 기능적인 항목들을 정리하는 경험을 가질 수 있다.
  • 테스트 코드를 통해 기능 수정간 발생할 수 있는 에러 상황에서 보호받을 수 있다.
  • 테스트 코드를 작성하면서 복잡한 의존성에 대한 고민을 하게 되고, 더 나은 코드를 작성할 수 있게 된다.

 

내가 생각하는 테스트 코드란 ?

테스트 케이스 작성간 발생할 수 있는 단점은 테스트 코드에 대한 이해도가 부족한 개발자가 작성한 테스트 코드 뿐이라고 생각합니다. 올바른 테스트 코드 작성은 서비스의 코드 품질을 높이며 유지보수에 큰 도움을 줄 수 있습니다. 그렇기에 테스트 코드의 올바른 이해가 작성 이전에 진행되어야 된다고 생각하고 저 또한 더 좋은 테스트 코드 작성을 위해 많은 공부를 하고 있습니다.

 

TDD(Test Driven Development) 방식도 존재하지만 이러한 방식에 대해서는 주니어 개발자의 입장에서 다소 회의적입니다. '숙련된 개발자일수록 TDD가 용이해 질 것이라 생각하지만 개발 순서의 차이일 뿐 동일한 결과물이 나오지 않을까...?' 또는 '오히려 시간이 더 소요되지 않을까..?' 하는 생각도 들고요.

주니어 개발자들은 개발을 진행하고 TC를 작성하며 더 좋은 코드를 향해 리팩토링하는 과정을 거치는 것이 보다 더 좋은 코드를 작성할 수 있는 방법이 아닐까 싶습니다. 어느 정도 코드 작성에 숙련되었다 생각된다면 TDD에 대한 고려도 필요하겠지만요.

 

위의 의견을 요약하면 내가 만드는 기능이 어떤 요구사항이 존재하는지, 어떤 케이스가 예외 사항이 될 수 있을지 정리하고 테스트 코드를 더 간결하게 짜기 위해 서비스 로직 구성을 고려해야 합니다. 그러다 보면 어느새 좋은 코드(clean code)를 작성하는 개발자가 되어 있을 것이라고 생각한다는 의견을 남기며 글을 마무리하겠습니다.

 

다음 글에서는 테스트 기법에 대해 다루는 시간을 가져보겠습니다.

읽어주셔서 감사합니다.

'기타 > 테스트' 카테고리의 다른 글

4. How to Migrate and Using Junit 5  (0) 2023.05.01
3. Junit 5  (0) 2023.05.01
2. Test Double  (0) 2023.03.25

이 글은 Test Doubles을 읽고 내용 정리 및 개인 의견에 대해 작성하였습니다.

Test Double 이란?

단위 테스트는 개발자의 생산성을 유지하고 코드의 결함을 줄이는 데 중요한 도구입니다. 간단한 코드에 대해서는 작성하기 쉬울 수 있지만 코드가 복잡해지면 작성하기가 어려워집니다.

Test Double은 이러한 경우에 유용합니다. Test Double이란 테스트에서 실제 구현을 대신할 수 있는 개체 또는 메소드를 말합니다. Test Double을 통해 실제로 실행하지 않고 무거운 메소드가 호출되도록 하는 등 시스템의 특정 세부 정보를 검증할 수 있습니다.

Test Double을 사용하는 기술

 

모의 프레임워크

소위 잘 알고있는 Mokito나 mock이 Test Double을 더 쉽게 생성할 수 있게 해주는 소프트웨어 라이브러리입니다. 이 라이브러리에서는 바로 아래서 설명드릴 Stubbing과 Interaction testing을 지원합니다.

Faking

  • 실제 구현과 유사하게 작용하지만 제품에는 적합하지 않은 경량화된 API를 의미합니다.
  • 예시
AuthorizationService fakeAuthorizationService = 
    new FakeAuthorizationService(); 
AccessManager accessManager = new AccessManager( fakeAuthorizationService ):

 

Stubbing

  • 자체적으로 동작이 없는 메소드에 동작을 부여하는 프로세스입니다. 메소드에 반환할 값을 정확히 지정하며, 이 반환되는 값을 Stub이라 부릅니다.
  • ex) when(...).thenReturn(...)

 

Interaction testing (mocking)

  • 구현된 메소드를 실제로 호출하지 않고 메소드가 호출되는 방식을 검증하는 방법입니다. 메소드가 올바른 방식으로 호출되지 않을 경우 테스트가 실패합니다.
  • ex) verify(...)

Google에서 본 Test Double

Test Double은 복잡한 시스템에서 귀중한 테스트 도구로서의 수단이 될 수 있습니다. 수많은 개발자들이 mocking framework를 통한 개발을 진행중에 있으며 손쉽게 테스트 코드를 작성할 수 있다는 점에서 많은 사랑을 받고 있습니다.

하지만 mocking framework의 남용은 실제 구현과 동기화되지 않을뿐더러 리팩토링을 어렵게 만드는 반복 코드가 생성되는 문제점을 야기했습니다.

 

단위 테스트가 Test Double에 너무 많이 의존하는 경우, 엔지니어는 통합 테스트를 실행하거나 기능이 예상대로 작동하는지 수동으로 확인해봐야 동일한 수준의 신뢰를 얻을 수 있습니다.

이러한 추가 작업을 수행할 경우 개발자가 실제 구현된 객체를 사용한 테스트 수행 시간에 비해 시간이 너무 많이 소요되는 문제가 발생하며, 수동으로 확인하는 작업을 건너뛸 경우 버그가 발생할 수 있는 딜레마가 발생합니다.

 

Google에서는 이러한 스타일의 테스트가 확장하기 어렵다는 것을 알게되었고 테스트 중인 시스템을 설계할 때 엄격한 지침을 따르도록 정책을 변경했습니다.
테스트에서 실제 구현을 선호하는 것을 클래식 테스트라 부르는데 Google 엔지니어들은 클래식 테스트 스타일에 더 적합한 방식으로 코드를 작성하게 되었습니다.

@DoNotMock annotation

모의 프레임워크에 과도하게 의존하는 테스트를 방지하고자 Google은 다음과 같은 어노테이션을 만들었습니다.

@DoNotMock("Use SimpleQuery.create() instead of mocking.")
public abstract class Query {
  public abstract String getQueryValue();
}

다음 어노테이션을 사용할 경우 "해당 클래스는 무조건 클래식 테스트로 구현해!"라는 의미를 API 소유자가 코드에 의미를 담았다고 생각하시면 좋을 것 같습니다.
그렇다면 Google은 도대체 어느 경우에 Test Double을 사용하는 걸까요?

Test Double을 고려해야 되는 테스트는 무엇일까?

Google은 테스트에서 실제 구현된 객체를 사용하는 방식을 선호한다고 했으나 일부 상황에서는 Test Double 사용을 고려해볼 필요가 존재합니다.


테스트 코드 작성시 클래식 테스트와 Test Double 두 가지 중 하나를 선택한다고 할 때 상호간의 trade-off가 존재하기 때문에 만약 Test Double을 사용할 경우 다음과 같은 사항들을 고려해야 합니다.

Execution time (실행 시간)

단위 테스트의 가장 중요한 특성 중 하나는 빨라야 한다는 것입니다. Test Double은 실제 구현된 코드가 느릴 경우 유용하게 사용할 수 있습니다.

 

ex) 실제 구현된 메소드 테스트시 호출당 1초가 소요된다 했을 때 5개의 tc를 생성할 경우 5초 소요

 

이렇게 실제 구현된 내용을 사용할 경우 빌드 및 테스트 시간에 오랜 시간을 소요해야 된다는 단점이 존재합니다.
'어떤 것이 옳다' 하는 확실한 답이 존재하지 않기 때문에 개발자의 가치관에 따라 클래식 테스트 방식을 선택할지 Test Double을 선택할지가 결정됩니다.

Determinism (결정론)

테스트를 실행하면 항상 동일한 결과가 나오는 경우 테스트는 결정적 입니다 . 반대로 테스트가 시스템이 변경되지 않은 경우에도 결과가 변경될 수 있는 경우가 있는데 이를 비결정적이라 부릅니다. (ex) 외부 시스템 api 호출)

 

테스트는 항상 통과하거나 항상 실패하기에 테스트의 비결정성으로 취약성이 발생할 수 있습니다. 이러한 취약성이 자주 발생하는 경우 Test Double 사용을 고려합니다.

Dependency construction (종속성 구성)

실제 구현을 사용할 때 모든 종속성을 구성해야 하는 문제가 존재합니다. Test Double에는 종속성이 없는 경우가 많으므로 실제 구현된 내용을 이용하는 것보다 Test Double을 이용하는 것이 테스트 코드 작성간 훨씬 간단할 수 있습니다.

Test Double 기술별 사용방법

Faking

실제 구현을 사용하는 것이 테스트 내에서 가능하지 않은 경우 가장 좋은 옵션은 Faking 기법을 대신 사용하는 것입니다. Faking은 실제 구현과 유사하게 동작하여 다른 Test Double 기술보다 선호되는 방식입니다.

예시

// This fake implements the FileSystem interface. This interface is also
// used by the real implementation.
public class FakeFileSystem implements FileSystem {
  // Stores a map of file name to file contents. The files are stored in
  // memory instead of on disk since tests shouldn’t need to do disk I/O.
  private Map<String, String> files = new HashMap<>();
  @Override
  public void writeFile(String fileName, String contents) {
    // Add the file name and contents to the map.
    files.add(fileName, contents);
  }
  @Override
  public String readFile(String fileName) {
    String contents = files.get(fileName);
    // The real implementation will throw this exception if the
    // file isn’t found, so the fake must throw it too.
    if (contents == null) { throw new FileNotFoundException(fileName); }
    return contents;
  }
}

 

Faking의 장점

  • Faking은 빠르게 실행되고 실제 구현을 사용할 때의 단점 없이 코드를 효과적으로 테스트할 수 있도록 도와줍니다.
  • 모든 종류의 API를 Faking으로 정의한다고 가정해보면 소프트웨어 조직 전체의 개발 속도를 향상시킬 수 있습니다.
  • 테스트 속도가 실제 구현 로직보다 빠릅니다.

Faking의 단점

  • 명확하지 않은 Faking은 취약점을 유발하며 올바른 테스트가 되지 않을 수 있습니다.
  • 실제 구현과 유사하게 동작해야 하므로 만드는데 더 많은 노력과 도메인 경험이 필요합니다.
  • 유지관리가 필요하며, 실제 구현이 변경될 경우 Faking 객체 또한 변경되어야 합니다.
  • 내부적으로 Faking 객체에 대한 자체 테스트를 필요로 합니다.

 

Google의 일부 팀은 Faking을 이용한 API를 제공해 다른 팀에서 이용할 수 있도록 제공하여 전체적인 개발 속도를 향상시켰습니다.

이러한 장단점들을 고려하며 Faking 기법을 이용하는 것이 과연 실제 구현된 로직을 이용하는 것보다 생산성 향상을 가져다 줄 것인지 한 번 더 고민해봐야 합니다.

Stubbing

Stubbing은 테스트에 적용하기가 매우 쉽기 때문에 실제 구현을 사용하는 것이 쉽지 않을 때마다 이 기술을 사용하고 싶을 수 있습니다.
그러나 Stubbing을 과도하게 사용하면 이러한 테스트를 유지해야 하는 개발자의 생산성이 크게 저하될 수 있습니다.

Stubbing 남용 예시

@Test public void creditCardIsCharged() {
  // Pass in test doubles that were created by a mocking framework.
  paymentProcessor = new PaymentProcessor(mockCreditCardServer, mockTransactionProcessor);

  // Set up stubbing for these test doubles.
  when(mockCreditCardServer.isServerAvailable()).thenReturn(true);
  when(mockTransactionProcessor.beginTransaction()).thenReturn(transaction);
  when(mockCreditCardServer.initTransaction(transaction)).thenReturn(true);
  when(mockCreditCardServer.pay(transaction, creditCard, 500)).thenReturn(false);
  when(mockTransactionProcessor.endTransaction()).thenReturn(true);

  // Call the system under test.
  paymentProcessor.processPayment(creditCard, Money.dollars(500));

  // There is no way to tell if the pay() method actually carried out the
  // transaction, so the only thing the test can do is verify that the
  // pay() method was called.
  verify(mockCreditCardServer).pay(transaction, creditCard, 500);
}

 

리팩토링한 테스트

과도한 Stubbing을 피하기 위해 mockCreditCardServer가 아닌 faking 객체인 creditCardServer 혹은 실제 구현된 creditCardServer를 사용할 수 있습니다.

@Test public void creditCardIsCharged() {
  paymentProcessor = new PaymentProcessor(creditCardServer, transactionProcessor);

  // Call the system under test.
  paymentProcessor.processPayment(creditCard, Money.dollars(500));

  // Query the credit card server state to see if the payment went through.
  assertThat(creditCardServer.getMostRecentCharge(creditCard)).isEqualTo(500);
}

 

Stubbing 사용은 언제 적절할까?

테스트 목적을 명확히 하기 위해 Stub된 각 메소드는 테스트의 검증 내용과 직접적인 관계가 있어야 합니다. 그렇기에 각 테스트는 일반적으로 적은 Stub을 가져야만 합니다.

많은 메소드를 Stubbing 해야 하는 테스트는 Stubbing이 과도하게 사용되고 있거나 테스트 중인 시스템이 너무 복잡하여 리팩터링해야 한다는 시그널일 수 있습니다.

 

따라서 일반적으로는 Faking을 통한 구현 또는 실제 구현된 객체를 이용하는 테스트가 가장 적합하지만 테스트가 지나치게 복잡해지지 않도록 하기 위해 일부를 Stubbing하는 방식으로 사용할 경우 가장 합리적인 사용이 될 수 있습니다.

Interaction testing (mocking)

Interaction testing (mocking)의 주요 문제는 테스트 중인 시스템이 제대로 작동하는지 알 수 없다는 것입니다. 또한, 테스트 중인 시스템의 구현 세부 정보를 활용한다는 것입니다.

Interaction testing (mocking)은 언제 적절할까요?

  • 실제 구현된 로직이 너무 느리며 Faking을 사용할 수 없는 경우
    • 대안으로 활용(이상적이진 않으나, 시스템이 예상대로 작동한다는 기본 수준의 신뢰를 제공함)
  • 함수 호출 횟수나 순서가 다르면 원하지 않는 동작이 발생할 수 있는 경우
    • 상태 테스트로는 이러한 로직을 검증하기가 어려움

예시(과도하게 지정한 mocking)

@Test 
public void displayGreeting_renderUserName() {
  when(mockUserService.getUserName()).thenReturn("Fake User");
  userGreeter.displayGreeting(); // Call the system under test.

  // The test will fail if any of the arguments to setText() are changed.
  verify(userPrompt).setText("Fake User", "Good morning!", "Version 2.1");

  // The test will fail if setIcon() is not called, even though this
  // behavior is incidental to the test since it is not related to
  // validating the user name.
  verify(userPrompt).setIcon(IMAGE_SUNSHINE);
}

 

예시(잘 지정된 mocking)

@Test 
public void displayGreeting_renderUserName() {
  when(mockUserService.getUserName()).thenReturn("Fake User");

  userGreeter.displayGreeting(); // Call the system under test.

  verify(userPrompter).setText(eq("Fake User"), any(), any());
}
@Test 
public void displayGreeting_timeIsMorning_useMorningSettings() {
  setTimeOfDay(TIME_MORNING);

  userGreeter.displayGreeting(); // Call the system under test.

  verify(userPrompt).setText(any(), eq("Good morning!"), any());
  verify(userPrompt).setIcon(IMAGE_SUNSHINE);
}



결론

test double이 코드를 종합적으로 테스트하고 테스트가 빠르게 실행되도록 보장할 수 있기 때문에 엔지니어링 속도에 매우 중요하다는 것을 알 수 있습니다.

하지만 이를 오용하면 불분명하고 취약하며 덜 효과적인 테스트로 이어질 수 있기 때문에 개발 생산성이 크게 저하될 수 있으며, 테스트를 안하니만 못하다는 문제점을 가지고 있습니다.

따라서 실제 구현된 로직를 사용할 것인지 또는 Test Double 방식을 이용할 것인지에 대한 선택은 팀 또는 개발자가 적합하다고 생각하는 기준을 몇가지 정의하고 사용해야만 합니다.

TL;DR (요약)

  • Test Double 보다는 실제 구현된 로직을 이용한 테스트가 선호되어야 합니다.
  • 테스트에서 실제 구현을 사용할 수 없는 경우 Faking이 이상적인 솔루션입니다.
  • Stubbing을 과도하게 사용할 경우 테스트는 불문명하고 불안정해집니다.
  • Mocking 테스트는 가능하면 피해야 합니다. 테스트 중인 시스템의 구현 세부 정보를 노출하기 때문에 깨지기 쉬운 테스트로 이어집니다.

개인 의견 (다소 주관적)

 

적절한 Faking Stubbing을 통해 높은 테스트 신뢰도를 제공하자

테스트 빌드 및 실행시간 단축은 개발 생산성에 매우 큰 영향을 준다고 생각합니다.

하지만, 실제 구현된 로직들을 가지고 테스트 코드를 작성해야 한다!라는 글의 방향과 Google의 선택이 올바른지에 대해서는 한 번 더 고민해볼 필요가 있다고 생각합니다.


저는 '적절한 Stubbing과 Faking 객체만 잘 이용한다면 실제 구현된 로직을 테스트 코드로 사용하지 않아도 되지 않을까..?' 라는 입장이어서 굳이 클래식 테스트를 지향하지 않아도 된다는 의견을 가지고 있습니다.

Stubbing과 Mocking을 최소화하고 Faking 객체를 만들어 TC들을 리팩토링한다면 실제 구현된 로직을 테스트 코드에 사용하지 않더라도 신뢰도가 높은 TC를 제공할 수 있다고 생각합니다.

Mocking을 줄이자

mock을 최소화하고 각 로직에 Faking 객체를 좀 더 많이 만들어보는 노력을 해야되지 않을까하는 의견을 제안합니다. 이럴 경우 개발 생산 속도는 떨어지지만 개발자 테스트를 최소화 할 수 있지 않을까 하는 생각을 가지고 있습니다.

@DoNotMock annotation을 활용해보자

Faking이 활성화 될 경우, 일부 TC에 대해 @DoNotMock annotation을 활용해 Faking 객체 또는 실제 구현된 객체를 이용하라는 메세지를 공유하면 더 좋은 TC를 만들 수 있습니다.

Stubbing은 검증하는 로직에 한해서만 사용한다.

테스트 코드 작성시 Stubbing은 검증하는 로직에 한해서 사용되는게 좋지 않을까 합니다.

 

 

다음 글에서는 테스트 프레임워크인 junit을 활용한 테스트 코드 작성에 대해 공유하는 시간을 가져보겠습니다.

 

읽어주셔서 감사합니다.


Reference

Test Doubles
Mock Roles, not Objects
State Verification

'기타 > 테스트' 카테고리의 다른 글

4. How to Migrate and Using Junit 5  (0) 2023.05.01
3. Junit 5  (0) 2023.05.01
1. 테스트 코드는 왜 만들까?  (0) 2023.04.08

+ Recent posts