Junit 5 간단 정리 - Kim-Taesu/study GitHub Wiki

junit 5 architecture

junit 5 architecture


JUnit의 테스트는 어떻게 실행될까?

  1. Test API로 사용자 테스트 코드를 작성
    • junit 5 이전 : junit 4.12 (junit-vintage)
    • junit 5 : junit-jupiter-api (junit-jupiter)
  2. IDE / build tools 에서 테스트 실행을 위해 Launcher(junit-platform-launcher)를 통해 execute 메소드를 실행
  3. TestEngine(junit-platform-engine) API를 사용하여 테스트 발견 & 실행한다.
    • 테스트 발견 : TestEngine::discover
    • 테스트 실행 : TestEngine::execute

  • Junit 5는 3개의 sub-project로 구성되어 있다.
  • Junit 5 = Junit Platform + Junit Jupiter + Junit Vintage

Junit Platform

  • JVM 상에서 테스트 프레임워크를 시작하기 위한 기반 역할
  • Launcher : Junit 5에서 테스트를 discover/filter/execute할 수 있는 모듈
    • Launcher APIjunit-platform-launcher 모듈에 존재한다.
  • 테스트 프레임워크를 개발하기 위한 TestEngine API를 정의
    • Junit은 Junit Junpiter programming model을 사용하여 테스트를 발견하고 실행시키는 TestEngine을 제공
    • TestEngine의 실체 구현체는 별도 모듈로 존재
      • Junit 3,4 : junit-vintage-engine
      • Junit 5 : junit-jupiter-engine
        • org.junit.platform.engine.TestEngine 인터페이스를 org.junit.jupiter.engine.JupiterTestEngine에서 구현한다.
        • @Testable 어노테이션으로 테스트를 발견한다.
  • Junit 4 기반 플랫폼에서 모든 TestEngine을 실행하기위한 Junit 4 기반 Runner 제공
    • junit-platform-runner

Junit Jupiter

  • Junit 5에서 테스트 및 extension을 작성하기 위한 programming model과 extension model의 조합
    • programming model : 테스트를 작성 (@Test, @ParameterizedTest, @AfterAll, ...)

      class SimpleTest {
        @Test
        @DisplayName("1 + 1 = 2")
        void addTest() {
          assertEquals(2, 1 + 1);
        }
      }
    • extention model : Junit Jupieter 확장 모델은 Extention API 이라는 개념을 사용한다.

      • Extention 자체는 marker interface 역할
      class MyCustomExtension implements BeforeEachCallback, AfterEachCallback {
        @Override
        publid void beforeEach(ExtensionContext context) {
          // setup
        }
      
        @Override
        public void afterEach(ExtensionContext context) {
          // teardown
        }
      }
      • extension을 등록하는 방법

        • 선언적 방식 : class나 메소드에 @ExtendWith 사용
        • 프로그래밍 방식 : @RegisterExtension 사용
        • 전역적인 방식 : java ServiceLoader 사용
      • Extension Points

        Step Interface/Annotation Description
        1 BeforeAllCallBack 모든 테스트가 실행되기 전에 한 번 실행되는 extension 코드
        2 @BeforeAll 모든 테스트가 실행되기 전에 한 번 실행되는 사용자 코드
        3 handleBeforeAllMethodExecutionException @BeforeAll에서 발생되는 exception을 handling할 코드
        4 BeforeEachCallback 개별 테스트가 실행되기 전에 실행되는 extension 코드
        5 @BeforeEach 개별 테스트가 실행되기 전에 실행되는 사용자 코드
        6 handleBeforeEachMethodExecutionException @BeforeEach에서 발생되는 exception을 handling할 코드
        7 BeforeTestExecutionCallback 개별 테스트가 실행되기 직전에 실행되는 extension 코드
        8 @Test 사용자 테스트 코드
        9 TestExecutionExceptionHandler @Test 실행 시 발생하는 exception을 handling할 코드
        10 AfterTestExecutionCallback 개별 테스트가 실행된 후 실행되는 extension 코드
        11 @AfterEach 개별 테스트가 실행된 후 실행되는 사용자 코드
        12 handleAfterEachMethodExecutionException @AfterEach에서 발생되는 exception을 handling할 코드
        13 AfterEachCallback 개별 테스트가 실행된 후 실행되는 extension 코드
        14 @AfterAll 모든 테스트가 실행된 후 실행되는 사용자 코드
        15 handleAfterAllMethodExecutionException @AfterAll에서 발생되는 exception을 handling할 코드
        16 AfterAllCallback 모든 테스트가 실행된 후 한 번 실행되는 extension 코드
      • other

        • ExecutionCondition : 테스트 실행 여부 제어 (profile에 따라 제어 가능)
        • TestInstancePostProcessor : 테스트 인스턴스가 생성 된 후 실행
        • ParameterResolver : 테스트 클래스 생성자, 메서드에 파라미터가 필요한 경우 사용
        • TestExecutionExceptionHandler : 특정 유형의 exception이 발생할 때 테스트 동작을 제어
        • ...
    • programming model + extension model

      @ExtendWith(MyCustomExtension.class)
      class SimpleTest {
        @RegisterExtension
        Extension extension = new AnotherExtension(42);
      
        @ExtendWith({FooExtension.class, BarExtension.class})
        @Test
        @DisplayName("1 + 1 = 2")
        void addTest() {
          assertEquals(2, 1 + 1);
        }
      }

Junit Vintage

  • 플랫폼에서 Junit 3, Junit 4 기반 테스트를 실행하기 위한 TestEngine을 제공

  • Junit 5는 런타임에서 Java 8 이상 버전이 필요하다
  • 컴파일은 8 이전 버전의 JDK도 가능하다.

  • 아래 코드는 Junit Jupiter 테스트의 최소 필요 사항으로만 작성된 테스트다.
import static org.junit.jupiter.api.Assertions.assertEquals;
import example.util.Calculator;
import org.junit.jupiter.api.Test;

class MyFirstJUnitJupiterTests {

    private final Calculator calculator = new Calculator();

    @Test // Test 선언
    void addition() {
        assertEquals(2, calculator.add(1, 1));
    }

}

Test Class

  • 적어도 한개의 @Test 메소드가 있는 Top level class, static member class, @Nested class
  • abstract class로 설정할 수 없다.

Test Method

  • @Test, @RepeatedTest, @ParameterizedTest, @TestFactory, @TestTemplate 어노테이션이 붙은 메소드.

Lifecycle Method

  • @BeforeAll, @AfterAll, @BeforeEach, @AfterEach 어노테이션이 붙은 메소드.

Test Method, Lifecycle Method는 테스트할 클래스, 부모 클래스, 인터페이스에서 선언한다.

  • 두 Method 모두 abstract로 설정하면 안된다.

  • 접근 지정자를 public 으로 설정하지 않아도 된다. 하지만 private로 설정하면 안된다.

    class StandardTests {
    
        @BeforeAll
        static void initAll() { 
        }
    
        @BeforeEach
        void init() {
        }
    
        @Test
        void succeedingTest() {
        }
    
        @Test
        void failingTest() {
            fail("a failing test");
        }
    
        @Test
        @Disabled("for demonstration purposes")
        void skippedTest() {
            // not executed
        }
    
        @Test
        void abortedTest() {
            assumeTrue("abc".contains("Z"));
            fail("test should have been aborted");
        }
    
        @AfterEach
        void tearDown() {
        }
    
        @AfterAll
        static void tearDownAll() {
        }
    
    }

  • 아래 어노테이션은 junit-jupiter-api의 어노테이션이다.

@Test

  • test 메소드임을 명시

  • Junit 4와 다른점은 attributes를 갖지 않는다.

    class BastTest {
    
      private final Calculator calculator = new Calculator();
    
      @Test
      void addition() {
        assertEquals(2, calculator.add(1, 1));
      }
    
      static class Calculator {
        int add(int a, int b) {
          return a + b;
        }
      }
    }

@ParameterizedTest

  • 테스트에서 여러 파라미터에 대한 테스트를 진행할 때 사용

    class BastTest {
    
      private final Calculator calculator = new Calculator();
    
      @ParameterizedTest
      @ValueSource(strings = {"1", "2", "3"})
      void parseTest(String numberString) {
        assertDoesNotThrow(() -> Integer.parseInt(numberString));
      }
    
      @ParameterizedTest
      @MethodSource(value = "parseTestParam")
      void parseTestWithMethodSource(Integer number, String numberString) {
        Integer parsedNum = assertDoesNotThrow(() -> Integer.parseInt(numberString));
        assertEquals(number, parsedNum);
      }
    
      private static Stream<Arguments> parseTestParam() {
        return Stream.of(
          Arguments.of(123, "123"),
          Arguments.of(456, "456"),
          Arguments.of(789, "789")
        );
      }
    
      @ParameterizedTest
      @CsvSource(value = {
        "1,2",
        "3,4",
        "4,5",
        "5,6",
      })
      void addition_withParam(int a, int b) {
        assertEquals(a + b, calculator.add(a, b));
      }
    
      static class Calculator {
        int add(int a, int b) {
          return a + b;
        }
      }
    }

@RepeatedTest

  • 반복적인 테스트를 진행할 때 사용

    class BastTest {
      @RepeatedTest(value = 10)
      void repeatedTest() {
        assertTrue(Math.random() > 0);
      }
    }

@DisplayName

  • IDE, test runners에서 테스트 실행 시 test report 화면에 표시되는 이름을 설정할 수 있다.
@DisplayName("A special test case")
class DisplayNameDemo {

    @Test
    @DisplayName("Custom test name containing spaces")
    void testWithDisplayNameContainingSpaces() {
    }

    @Test
    @DisplayName("╯°□°)╯")
    void testWithDisplayNameContainingSpecialCharacters() {
    }

    @Test
    @DisplayName("😱")
    void testWithDisplayNameContainingEmoji() {
    }

}

@TestFactory

  • Dynamic 테스트를 위한 테스트 Factory 역할

Dynamic Tests

  • 일반적인 테스트(@Test)는 complie 시점에서 모두 테스트로 지정되며 runtime 시점에서 테스트를 변경할 수 없다.
  • Dynamic test는 팩토리 메소드(@TestFactory 어노테이션을 설정한)에 의해 런타임 시점에서 실행된다.
  • @TestFactory 메소드는 테스트 케이스는 아니다, 테스트 케이스의 팩토리 메소드이다.
    • @TestFactory 메소드는 반드시 단일 DynamicNode, Stream, Collection, Iterable, Iterator, Array[DynamicNode]를 반환해야 한다.
  • Dynamic Test는 일반적인 테스트와는 달리 lifecycle callback이 없다.
    • @BeforeEach, @AfterEach, ... 와 같은 lifecycle callback은 dynamic test와 관련이 없다.

언제 Dynamic Test를 사용하는지?

  • 테스트 실행을 위한 설정, 파라미터들이 runtime에 셋팅되는 경우

  • runtime에서 테스트가 이뤄져야하는 경우

  • (사실 정확하게 어느 상황에서 써야하는지는 잘 모르겠다..)

  • 참조 링크

    테스트 코드 예시

    Dynamic Tests - DynamicNode

    @TestFactory
    DynamicNode dynamicNodeSingleTest() {
      return dynamicTest("'pop' is a palindrome", () -> assertTrue(isPalindrome("pop")));
    }

    Dynamic Tests - Stream

    @TestFactory
    Stream<DynamicTest> dynamicTestsFromStream() {
      return Stream.of("racecar", "radar", "mom", "dad")
        .map(text -> dynamicTest(text, () -> assertTrue(isPalindrome(text))));
    }

    Dynamic Tests - Collection

    @TestFactory
    Collection<DynamicTest> dynamicTestsFromCollection() {
      return Arrays.asList(
        dynamicTest("1st dynamic test", () -> assertTrue(isPalindrome("madam"))),
        dynamicTest("2nd dynamic test", () -> assertEquals(4, calculator.multiply(2, 2)))
      );
    }

    Dynamic Tests - Iterable

    @TestFactory
    Iterable<DynamicTest> dynamicTestsFromIterable() {
      return Arrays.asList(
        dynamicTest("3rd dynamic test", () -> assertTrue(isPalindrome("madam"))),
        dynamicTest("4th dynamic test", () -> assertEquals(4, calculator.multiply(2, 2)))
      );
    }

    Dynamic Tests - Iterator

    @TestFactory
    Iterator<DynamicTest> dynamicTestsFromIterator() {
      return Arrays.asList(
        dynamicTest("5th dynamic test", () -> assertTrue(isPalindrome("madam"))),
        dynamicTest("6th dynamic test", () -> assertEquals(4, calculator.multiply(2, 2)))
      ).iterator();
    }

@TestTemplate

  • 테스트 케이스의 template 역할 (@TestTemplate 자체로는 테스트 메서드가 아니다.)

  • @TestTemplate에서 확장한 extension(ExtendWith(XXXProvider.class))에서 반환하는 TestTemplateInvocationContext의 수에 따라 호출될 횟수를 정한다.

    • @TestTemplate 메서드는 하나 이상의 TestTemplateInvocationContextProvider가 등록된 경우에만 실행할 수 있다.
    • 각 provider는 TestTemplateInvocationContext 인스턴스의 Stream을 제공해야 한다.
    • 각 context는 사용자가 지정한 displayName과 @TestTamplate 메서드의 다음 호출에만 사용되는 추가 extension 목록을 지정할 수 있다.
    @Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})
    @Retention(RetentionPolicy.RUNTIME)
    @TestTemplate // TestTemplate임을 명시
    @ExtendWith(CustomTemplateInvocationContextProvider.class) // Stream<TestTemplateInvocationContext>을 반환하는 Provider 확장
    public @interface CustomTestTemplateTest {
    }

TestTemplateInvocationContextProvider

  • @TestTemplate 메서드의 호출을 위해 하나 또는 여러 context를 제공하려는 extension API를 정의한다.

  • TestTemplateInvocationContextProvider 사용하여 다른 context에서 @TestTemplate 메서드를 실행할 수 있다.

    • 다른 매개 변수를 사용하여 테스트 클래스 인스턴스를 다르게 준비
    • context를 수정하지 않고 여러 번 테스트를 실행
  • 해당 인터페이스에서 구현해야할 메소드는 supportsTestTemplateprociveTestTemplateInvocationContexts이다.

    • supportsTestTemplate : 테스트 프레임워크에 의해 호출되어 특정 extension이 곧 실행될 @TestTamplate 메서드에서 작동할지 여부를 결정한다.
      • 작동(active 상태)한다면 prociveTestTemplateInvocationContexts 메서드가 호출된다.
    • prociveTestTemplateInvocationContexts : supportsTestTemplate 메서드에서 extension이 @TesetTamplate 메서드를 실행해야 하는 경우 실행된다.
      • 해당 메서드에서 반환된 Stream이 연결되고 @TestTamplate 메서드는 모든 active provider의 context를 사용하여 호출된다.
      public class CustomTemplateInvocationContextProvider implements TestTemplateInvocationContextProvider {
    
        private final static List<String> FRUITS = Arrays.asList("apple", "banana", "lemon");
    
        @Override
        public boolean supportsTestTemplate(ExtensionContext context) {
          return context.getTestMethod().isPresent();
        }
    
        @Override
        public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(ExtensionContext context) {
          // 여기서 반환하는 TestTemplateInvocationContext 수 만큼 TestTemplate이 호출된다.
          // 아래 예시로는 총 2번 실행됨
          return Stream.of(
            makeTestTemplateInvocationContext("apple", "lemon"),
            makeTestTemplateInvocationContext("banana")
          );
        }
    
        private TestTemplateInvocationContext makeTestTemplateInvocationContext(String... parameters) {
          return new TestTemplateInvocationContext() {
            // DisplayName 설정
            @Override
            public String getDisplayName(int invocationIndex) {
              return String.format("Invocation index %d. Parameter: %s",invocationIndex, String.join(",", parameters));
            }
    
            // 추가 extension 설정
            @Override
            public List<Extension> getAdditionalExtensions() {
              return Collections.singletonList(new ParameterResolver() {
                @Override
                public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
                  // Test Method의 파라미터 타입 체크
                  return parameterContext.getParameter().getType().equals(String[].class);
                }
    
                @Override
                public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
                  return parameters;
                }
              });
            }
          };
        }
      }

TestTemplate 사용한 테스트 코드 예시

@CustomTestTemplateTest
void testTemplate(String... fruit) {
  // given

  // when
  List<String> fruits = Arrays.asList("apple", "banana", "lemon", "strawberry");

  // then
  for (String item : fruit) {
    assertTrue(fruits.contains(item));
  }
}

@TestMethodOrder

  • 기본적으로 테스트 실행은 명확한 순서를 지키지 않는다.
    • 단위 테스트는 테스트 순서에 의존하면 안되므로 순서를 지키즞 것이 맞다.
  • @TestMethodOrder를 사용하면 순서를 보장하는 테스트 실행이 가능하다.
테스트 코드 예시
class TestExecutionOrder {

	@TestMethodOrder(MethodOrderer.DisplayName.class)
	static class testDisplayName{
		@Test
		@DisplayName("1 test: order 1 test")
		void firstTest() {
			// perform assertions against null values
		}

		@Test
		@DisplayName("2 test: order 2 test")
		void secondTest() {
			// perform assertions against empty values
		}

		@Test
		@DisplayName("3 test: order 3 test")
		void thirdTest() {
			// perform assertions against valid values
		}
	}

	@TestMethodOrder(MethodName.class)
	static class testMethodName{
		@Test
		@DisplayName("cccTest")
		void cccTest() {
			// perform assertions against null values
		}

		@Test
		@DisplayName("bbbTest")
		void bbbTest() {
			// perform assertions against empty values
		}

		@Test
		@DisplayName("aaaTest")
		void aaaTest() {
			// perform assertions against valid values
		}
	}

	@TestMethodOrder(OrderAnnotation.class)
	static class testOrderAnnotation{
		@Test
		@Order(1)
		@DisplayName("first test: order 1 test")
		void firstTest() {
			// perform assertions against null values
		}

		@Test
		@Order(2)
		@DisplayName("second test: order 2 test")
		void secondTest() {
			// perform assertions against empty values
		}

		@Test
		@Order(3)
		@DisplayName("third test: order 3 test")
		void thirdTest() {
			// perform assertions against valid values
		}
	}

	@TestMethodOrder(Random.class)
	static class testRandom{
		@Test
		@Order(1)
		@DisplayName("first test: order 1 test")
		void firstTest() {
			// perform assertions against null values
		}

		@Test
		@Order(2)
		@DisplayName("second test: order 2 test")
		void secondTest() {
			// perform assertions against empty values
		}

		@Test
		@Order(3)
		@DisplayName("third test: order 3 test")
		void thirdTest() {
			// perform assertions against valid values
		}
	}
}

@TestInstance

  • Junit은 각 테스트 실행 시 발생할 수 있는 side effect를 피하기 위해 테스트 메서드 실행 전 각 테스트 클래스의 새 인스턴스를 생성한다.
    • Junit 테스트 생명주기의 기본 값은 메서드 단위 생명주기다.
  • 만약 동일한 테스트 인스턴스에서 테스트 클래스의 모든 테스트 메서드를 실행시키기 위해서는 @TestInstance(Lifecycle.PER_CLASS)로 설정해야 한다.
    • 테스트 클래스 당 1개의 테스트 인스턴스가 생성된다.
    • @TestInstance(Lifecycle.PER_CLASS)로 설정하게 되면 @BeforeAllstatic를 제거해도 된다.
    • DB 연결 또는 테스트 실행을 위해 대용량 파일을 로딩할 때 유용하다.
  • 참조

@Tag

  • @Tag 어노테이션으로 테스트 클래스 또는 메서드를 태깅할 수 있다.

  • Tag할 때는 작성 규칙을 지켜야한다.

    • tag 설정 시 null, blank("") 안됨
    • 공백이 없어야 함
    • IOS control character가 포함되면 안됨
    • 아래 예약 문자를 포함하면 안됨
      • ,
      • (
      • )
      • &
      • |
      • !
    class TagTest {
    
      @Test
      @Tag("dev")
      void devTest() {
        // dev logic
      }
    
      @Test
      @Tag("real")
      void realTest() {
        // real logic
      }
    
    }

Meta-Annotations and Composed Annotation

  • Junit의 어노테이션을 사용하여 메타 어노테이션으로 사용할 수 있다.
  • 아래 예시는 dev라는 태그를 가진 테스트를 표시하기 위한 어노테이션 예제이다.
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Tag("dev")
@Test
public @interface DevTest {
}

// --------------------------------------------

@DevTest
void onlyDevTest() {
  // some logic
}

  • 특정 환경변수를 기준으로 테스트를 실행할 수 있다.
@Test
@EnabledIfEnvironmentVariable(named = "ENV", matches = "dev")
void onlyOnStagingServer() {
    // ...
}

@Test
@DisabledIfEnvironmentVariable(named = "ENV", matches = "release")
void notOnDeveloperWorkstation() {
    // ...
}
  • maven surefire-plugin 을 추가한 뒤 mvn 커맨드 라인에서 환경변수를 설정한다.
    • mvn test -DENV=dev
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>2.19</version>
    <dependencies>
        <dependency>
            <groupId>org.junit.platform</groupId>
            <artifactId>junit-platform-surefire-provider</artifactId>
            <version>1.0.0</version>
        </dependency>
    </dependencies>
    <configuration>
        <includes>**/*.java</includes>
        <environmentVariables>
            <ENV>${ENV}</ENV>
        </environmentVariables>
    </configuration>
</plugin>

Junit 4

  • junit 4에서는 테스트를 실행시키기 위해 Runner 클래스가 필요했다.
    • @RunWith(XXX.class)
  • 테스트 클래스에서 동작 방식을 재정의 하거나 쉽게 추가하기 위해 @Rule을 사용했다.
    • 임시 폴더 관리 : TemporaryFolder
    • 예외 관리 : ExpectedException
    • TestSuite의 클래스마다 적용 : ClassRule
    • ...

Junit 5

  • Junit 5 에서는 Junit 4에서의 Runner, Rule을 하나의 개념인 Extension으로 사용한다.
  • Extension 그 자체로는 단지 Marker interface이다.

5.2 Registering Extensions

  • Extension을 등록하는 방법은 3가지다.
    • 선언적 : @ExtendWith 사용
    • 프로그래밍 : @RegisterExtension
    • java의 ServiceLoader 매커니즘 사용

Extension 등록 예시

// method
@ExtendWith(RandomParametersExtension.class)
@Test
void test(@Random int i) {
    // ...
}

// class
@ExtendWith(RandomParametersExtension.class)
class MyTests {
    // ...
}

Spring boot의 extension

  • @SpringBootTest 어노테이션은 @ExtendWith(SpringExtension.class)를 포함한 메타 어노테이션이다.
  • SpringExtension은 다양한 Extension을 상속한 API를 구현한다.
    • BeforeAllCallback : 모든 테스트가 실행되기 전에 테스트 컨테이너에 추가 동작을 실행
    • AfterAllCallback : 모든 테스트가 실행된 후 테스트 컨테이너에 추가 동작을 실행
    • TestInstancePostProcessor : 테스트 인스턴스의 post-process 동작을 실행
    • BeforeEachCallback : 각 테스트 및 사용자 정의 메서드(@BeforeEach)가 실행되기 전에 추가 동작을 실행s
    • AfterEachCallback : 각 테스트 및 사용자 정의 메서드(@AfterEach)가 실행된 후 추가 동작을 실행
    • BeforeTestExecutionCallback : 각 테스트가 실행되기 전, 사용자 정의 메서드(@BeforeEach)가 실행 된 후 추가 동작을 실행
    • AfterTestExecutionCallback : 각 테스트가 실행된 후, 사용자 정의 메서드(@AfterEach)가 실행되기 전에 추가 동작을 실행
    • ParameterResolver : Runtime에서 동적으로 파라미터를 설정
⚠️ **GitHub.com Fallback** ⚠️