4 minute read

목차

  1. 테스트 라이프사이클
  2. 디폴트 테스트 인스턴스 라이프사이클 변경
  3. @Nested 테스트 클래스
  4. 생성자와 메소드 의존성 주입

1. 테스트 라이프사이클 알아보기

테스트 인스턴스 상태의 변경 가능성으로 인해 발생하는 사이드 이펙트를 줄이고 테스트 메서드를 격리된 환경에서 독립적으로 실행 시키기 위해 JUnit은 테스트 메소드를 실행시키기 전에 각각의 테스트 클래스의 새로운 인스턴스를 만들어 낸다. 이렇게 메소드마다 테스트 라이프사이클을 갖는 동작은 이전 버전의 JUnit과 똑같은 동작이며, 디폴트 동작이다.

테스트 메소드 마다 새로운 테스트 클래스의 인스턴스를 만들어 낸다

테스트 메소드에 @Disabled, @DisabledOnOs 를 붙여 테스트 메소드를 비활성화 하여도 해당 테스트에 대해 새로운 인스턴스를 만들어 낸다.

만약 같은 인스턴스 안에서 모든 테스트 메소드를 모두 실행하고 싶다면 테스트 클래스에 @TestInstance(Lifecycle.PER_CLASS) 어노테이션을 사용한다. 이 어노테이션을 사용하면 테스트 클래스 단위로 인스턴스를 생성한다. 그러므로 그 안에 있는 인스턴스 변수를 테스트 메소드들이 공유 하므로 @BeforeEach나 @AfterEach를 사용하여 내부 상태를 리셋을 해야할수 있다.

PER-CLASS는 @BeforeAll과 @AfterAll를 붙인 메소드에 static을 붙여서 사용하지 않아도 되고 인터페이스의 default 메소드에서도 사용하지 않아도 된다.

또한 PER-CLASS는 @Nested 테스트 클래스에서 @BeforeAll과 @AfterAll 메소드를 사용할 수 있게 해준다.

2. 디폴트 테스트 인스턴스 라이프사이클 변경

테스트 클래스에 @TestInstance 어노테이션이 없으면 기본 라이프사이클을 사용한다. 기본 모드는 메소드마다 새로운 인스턴스를 생성하는 PER-METHOD 이다. 그러나 테스트에 디폴트 라이프사이클을 변경할 수 있다. 변경하기 위해선 junit.jupiter.testinstance.lifecycle.default 설정 파라미터에 TestInstnace.LifeCycle enum클래스를 적어주면 된다. (이 외에도 JUnit 설정파일이나, LauncherDiscoveryRequest를 Launcher로 전달한 설정 파라미터를 이용해 JVM 시스템 변수로 제공해줄 수 있다.)

  • JVM 시스템 변수 설정
    • -Djunit.jupiter.testinstance.lifecycle.default=per_class
    • 그러나 위 방법보단 아래 JUnit 설정파일을 통해 라이프사이클 모드를 변경하는것을 권장한다.
  • JUnit 설정 파일을 통해 설정하는 방법
    • junit-platform.properties 이름의 파일을 클래스패스 만들고 다음과 같이 작성한다.
    • junit.jupiter.testinstance.lifecycle.default = per_class
디폴트 테스트 인스턴스 라이플사이클 변경이 일관적으로 적용되지 않으면 예상치 못한 결과를 초래 한다. 예를 들어, 빌드 설정엔 “per-class”를 디폴트로 설정했지만, IDE 설정에서는 “per-method”로 설정되어 실행될 수 있다. 이렇게 되면, 빌드 서버에서 오류가 난다. 이런 현상을 해결하기 위해서는 JVM 시스템 변수 대신, JUnit 설정 파일을 사용하는 걸 추천 한다.

@Nested 테스트 클래스

@Nested 테스트는 테스트 구룹 사이의 관계를 표현할 수 있게 해준다.

package com.example.demo;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.test.context.TestPropertySource;

import java.util.ArrayList;
import java.util.EmptyStackException;
import java.util.List;
import java.util.Stack;

import static junit.framework.TestCase.*;
import static org.junit.Assert.assertThrows;

@TestPropertySource(locations = "classpath:junit-platform.properties")
class DemoApplicationTests {

	Stack<Object> stack;

	@Test
	void 시작인사() {
		System.out.println("안녕하세요");
	}

	@Nested
	@DisplayName("first nested")
	class WhenNew  {
		@BeforeEach
		void init() {
			stack = new Stack<>();
		}

		@Test
		@DisplayName("is empty")
		void isEmpty() {
			assertTrue(stack.isEmpty());
		}

		@Test
		@DisplayName("throws EmptyStackException when popped")
		void throwsExceptionWhenPopped() {
			assertThrows(EmptyStackException.class, stack::pop);
		}

		@Test
		@DisplayName("throws EmptyStackException when peeked")
		void throwsExceptionWhenPeeked() {
			assertThrows(EmptyStackException.class, stack::peek);
		}

		@Nested
		@DisplayName("after pushing an element")
		class AfterPushing {
			String anElement = "an element";

			@BeforeEach
			void pushAnElement() {
				stack.push(anElement);
			}

			@Test
			@DisplayName("it is no longer empty")
			void isNotEmpty() {
				assertFalse(stack.isEmpty());
			}

			@Test
			@DisplayName("returns the element when popped and is empty")
			void returnElementWhenPopped() {
				assertEquals(anElement, stack.pop());
				assertTrue(stack.isEmpty());
			}

            @Test
			@DisplayName("returns the element when popped and is empty")
			void returnElementWhenPopped() {
				assertEquals(anElement, stack.pop());
				assertTrue(stack.isEmpty());
			}

			@Test
			@DisplayName("returns the element when peeked but remains not empty")
			void returnElementWhenPeeked() {
				assertEquals(anElement, stack.peek());
				assertFalse(stack.isEmpty());
			}
		}
	}
}

여기서 WhenNew.createNewStack() 메서드가 새로운 Stack 클래스를 만들어주는 역할을 한다. 이 메서드에 적힌 @BeforeEach 어노테이션은 라이프 사이클 메서드로 이 메소드가 포함된 클래스 아래 레벨에 있는 메소드 까지 영향을 준다.

오직 non-static 인 nested 클래스 (ex inner class)만 @Nested 를 붙이는것이 가능하다. 중첩은 개발자 마음대로 할 수 있으며, 이너 클래스는 한가지만 제외하고 라이프 사이클을 가진다. 바로 @BeforeAll 과 @AfterAll 메서드는 기본적으로 작동하지 않는다.

그 이유는 자바는 이너 클래스 안에 static 멤버 변수를 두는것을 허락하지 않기때문이다. 이러한 제약을 우회하는 방법으로 테스트 클래스에 @Nested를 붙이고 @TestInstance(Lifecycle.PER-CLASS)를 사용하여 우회해야 한다.

4-1. 생성자와 메소드 의존성 주입

이전 JUnit 버전에서는 테스트 클래스에서 생성자나 메소드에 파라미터를 갖는것이 불가능했다 JUnit Jupiter의 주요 변화로 테스트 클래스에 생성자와 메소드가 이제는 파라미터를 갖을수 있도록 변경이 되었다. 이러한 변화는 코드의 유연성과 생성자와 메소드에 의존성 주입을 가능하게 해주었다.

ParameterResolver는 런타임시 동적으로 파라미터를 결정하는 __테스트 익스텐션__에 관한 API가 정의되어 있다. 테스트 클래스의 생성자나, 메서드나, 라이프사이클 메서드가 파라미터를 받고 싶다면, 파라미터는 ParameterResolver를 등록함으로써 런타임시 결정이 된다.

현재 자동적으로 등록되는 3개의 내장 리졸버가 있다.

  • TestInfoParameterResolver
    • 생성자나 메소드 파라미터가 TestInfo의 타입이면 TestInfoParamterResolver가 현재 컨테이너나, 테스트에 일치하는 값을 TestInfo 인스턴스로 제공해준다. 제공받은 TestInfo는 현재 컨테이너 또는 테스트에 관한 displayname, 테스트 클래스, 테스트 메소드 관련된 태그들의 테스트 정보를 가져올때 사용한다.

다음 예제는 테스트 생성자와, @BeforeEach 메서드 @Test 메소드에 어떻게 TestInfo가 주입되는지 보여주는 예제이다.

@DisplayName("TestInfo Demo Test")
class DemoApplicationTests {
	DemoApplicationTests(TestInfo testInfo) {
		assertEquals("TestInfo Demo Test", testInfo.getDisplayName());
	}

	@BeforeEach
	void init(TestInfo testInfo) {
		String displayName = testInfo.getDisplayName();
		System.out.println(displayName); // TEST 1, test2 출력
	}

	@Test
	@DisplayName("TEST 1")
	@Tag("my-tag")
	void test1(TestInfo testInfo) {
		assertEquals("TEST 1", testInfo.getDisplayName());
		assertTrue(testInfo.getTags().contains("my-tag"));
	}

	@Test
	void test2() {
	}

}

어떻게 주입되었는지 확인하였는가?

  • RepetitionInfoParameterResolver
    • @RepeatedTest, @BeforeEach, @AfterEach 의 어노테이션이 붙은 메소드 파라미터는 RepetitionInfo의 타입이며, RepetitionInfoParameterResolver가 RepetitionInfo 인스턴스를 제공한다.
    • RepetitionInfo는 현재 반복하고 있는 정보나, @RepetitionInfo 와 관련된 반복의 총 갯수의 정보를 가져올때 사용한다. 그러나 현재 컨텍스트 외부에 있는 @RepeatedTest를 찾아내진 못한다.
  • TestReporterParameterResolver
    • TestReporter 타입의 파라미터를 사용해야할 때 사용한다. TestReporter는 현재 실행중인 테스트에 관한 추가적인 데이터를 발행해야할 때 사용한다. 데이터는 TestExecutionListener안에 있는 reportingEntryPublished() 메소드를 이용해 컨슘되며, IDE나 리포트에서 볼 수 있다.

__다른 리졸버를 사용하고 싶다면 @ExtendWith__를 통해서 상속을 하면 된다.

4-2. @ExtendWith - 확장 기능 구현하기

@ExtendWith는 메인으로 실행될 Class를 지정할수 있다. @SpringBootTest는 기본적으로 @ExtendWith가 추가되어 있다./

단위 테스트간에 공통적으로 사용할 기능을 구현을 위해 @ExtendWith를 통하여 적용할 수 있는 기능을 제공한다. 확장 기능은 org.junit.jupiter.api.extension.Extension 인터페이스를 상속한 인터페이스로 되어 있으며 JUnit5에서 제공하는 기능의 상당수가 이 기능을 통해서 지원되고 있다.

이전 JUnit4에서 사용하던 @RunWith를 대체하였다. @ExtendWith와 @Runwith 와는 중요한 차이가 있다.

  • 메타 어노테이션을 지원한다.
  • 여러번 중복 사용이 가능하다.

4-3. JUnit에서의 의존성 주입은 @Autowired로 하자

테스트 클래스에서 의존성을 주입받는 방식에는 4가지 방법이 일반적으로 알려져있다.

  1. 생성자를 통한 의존성 주입
  2. 필드를 통한 의존성 주입
  3. setter를 통한 의존성 주입
  4. lombok의 @RequiredArgsConstructor로 final 이나 @NonNull의 필드를 통해 의존성 주입

보통 나는 4번을 이요하지만 JUnit5 에서 단위테스트를 작성하면 lombok 방식으로 DI를 시도하면 의존성 주입에서 에러가 발생한다. 생성자를 통해 DI하는 것도 에러가 발생한다.

이러한 이유는 JUnit5가 DI를 스스로 지원하기 떄문이다. DI를 지원하는 타입이 정해져있다고 한다. JUnit에서는 생성자에 다른 의존성을 주입하려고 먼저 개입을 하기 떄문

그렇기 때문에 단위 테스트를 작성하면서 의존성을 주입받으려면 @Autowired를 사용하도록 하자

Categories:

Updated: