Spring Framework에서 JUnit5 사용하기

권혁진·2022년 8월 2일
1

1. JUnit 라이브러리 추가

Spring Framework에서 JUnit을 이용하기 위해서는 Maven Dependency 추가를 해야한다.

pom.xml에 dependency를 추가한다.

<dependency>
	<groupId>org.junit.jupiter</groupId>
	<artifactId>junit-jupiter-api</artifactId>
	<version>5.8.2</version>
	<scope>test</scope>
</dependency> 
<dependency>
	<groupId>org.springframework</groupId>
	<artifactId>spring-test</artifactId>
	<version>${org.springframework-version}</version>
</dependency>

2개의 dependency를 추가해주면 된다.

HomeControllerTest 를 src/test/java 폴더 아래 생성한다.

Test를 생성하면

class HomeControllerTest {
	
	
    
}

이렇게 나온다. 그 다음으로 해야 할 일은

1. WebApplicationContext를 불러올 수 있게 해주는 Annotation인 @WebAppConfiguration을 추가해야한다.

2. Servlet.Xml파일과 ApplicationContext를 생성해주는 root-context.xml 설정파일들의 위치를 설정 해야한다.

3. ExtendWith(SpringExtension.class)를 추가해준다.

여기까지 하게 되면 코드는

@WebAppConfiguration
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/spring/appServlet/*.xml","file:src/main/webapp/WEB-INF/spring/root-context.xml"})
class HomeControllerTest {
	
	
    
}

이렇게 완성된다.

Annotation 설정은 끝났으니 WebApplicationContext와 MockMvc를 정의하고 setup을 한다.

@WebAppConfiguration
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/spring/appServlet/*.xml","file:src/main/webapp/WEB-INF/spring/root-context.xml"})
class HomeControllerTest {
	
	@Autowired
	private WebApplicationContext context;
	
    private MockMvc mockMvc;

	@BeforeEach
	public void setup() {
		this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context).build();
	}
    
}

setup이 완성됐으니 @Test 어노테이션을 이용해 Test 메서드를 생성한다.

@WebAppConfiguration
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/spring/appServlet/*.xml","file:src/main/webapp/WEB-INF/spring/root-context.xml"})
class HomeControllerTest {
	
	@Autowired
	private WebApplicationContext context;
	
	private MockMvc mockMvc;

	@BeforeEach
	public void setup() {
		this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context).build();
	}
	
	
	@Test
	void HomeTest() throws Exception {
    
    this.mockMvc.perform(get("/"))
    .andDo(print())
    .andExpect(status().isOk())
    .andExpect(model().attributeExists("serverTime"));
        
	}

}

get방식으로 요청을 보내고 andDo 메서드로 print()를 호출하고 andExpect(status().isOk())를 통해 정상적으로 통신이 되었는지 확인한 후 serverTime Model이 존재하는지 확인하는 메서드를 작성한다.

@Controller
public class HomeController {
	
	private static final Logger logger = LoggerFactory.getLogger(HomeController.class);
	
	@Autowired
	private HomeService homeService;
	
	/**
	 * Simply selects the home view to render by returning its name.
	 */
	@RequestMapping(value = "/", method = RequestMethod.GET)
	public String home(Locale locale, Model model) {
		logger.info("Welcome home! The client locale is {}.", locale);
		
		Date date = new Date();
		DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG, locale);
		
		String formattedDate = dateFormat.format(date);
		
		model.addAttribute("serverTime", formattedDate );
		
		
		List<UserVO> users = homeService.getUser();
		
		model.addAttribute("HomeUsersList", users);
		
		return "home";
	}
		
}

HomeController의 코드이다.

작성 결과는

HomeTest는 정상적으로 통과하였다. print()메서드를 호출하여 나온 결과물을 살펴보면

Request, Handler, Async,Exception, ModelAndView, FlashMap, Response에 대한 정보들이 출력되어 있다.

얼핏 보게되면 정보들이 너무 상세하게 나열되어 있어서 실제로 서버와 통신을 한게 아닌가라는 생각이 들 수 있지만

실제로는 서버와 통신을 한 데이터 값을 반환하는것이 아니다.

MockMvc를 이용하여 모의 객체를 만들어 Test를 진행하여 반환된 결과 값이다.

MockMvc 객체는 무엇인가?

  • 서블릿 컨테이너의 구동 없이, 시뮬레이션된 MVC 환경에 모의 HTTP 서블릿 요청을 전송하는 기능을 제공하는 유틸리티 클래스이다.

여기서 서블릿의 역할을 잠깐 짚고 넘어가면 클라이언트로부터 Request가 들어오면 절차에 맞게 처리하여 Response해주는 컨테이너다. 웹 애플리케이션을 사용하기 위해서는 서블릿 컨테이너는 필수라는 의미이다.

따라서 WAS를 Test를 하기 위해서는 서블릿의 구동이 필수적인데 이것을 MockMVC로 대체하여 구동을 하지 않은 상태에서도 Test를 할 수 있게 만들어 주는 객체라고 생각하면된다.

MockMVC 활용

1. 설정

@ContextHierarchy

테스트용 DI 컨테이너를 만들 때 Bean 파일을 지정한다.

@ContextHierarchy({
	@ContextConfiguration(classes = AppConfig.class),
    @ContextConfiguration(classes = WebMvcConfig.class)
})

@WebAppConfiguration

Controller 및 web 환경에 사용되는 빈을 자동으로 생성하여 등록

@단독 실행

public class WelcomeControllerTest {
    
    private MockMvc mockMvc;

    @Beforeeach
    public void setUpMockMvc() {
        this.mockMvc = MockMvcBuilders.standaloneSetup(new WelcomeController()).build();
    }
    
}

@MockMvc 필터 추가

class testt(){
	
    MockMvc mockMvc;
    
	@Before
	public void setUpMockMvc(){
		
  	  this.mockMvc = MockMvcBuilders
      					.standaloneSetup(TestController.class)
                        .addFilter(new SessionFilter()) //필터 추가
                        .build();
	}
    
}

2. 실행

perform() 메서드를 이용해 MockMVC를 실행 할 수 있다.


3. 요청 설정 메서드

  • param / params : 쿼리 스트링 설정

  • requestAttr : 요청 스코프 객체 설정

  • sessionAttr : 세션 스코프 객체 설정

  • content : 요청 본문 설정

  • header / headers : 요청 헤더 설정

  • contentType : 본문 타입 설정




4. 검증 메서드 (andExpect)

  • status : 상태 코드 검증

  • header : 응답 Header 검증

  • content : 응답 본문 검증

  • view : 컨트롤러가 반환한 뷰 이름 검증

  • redirectedUrl(Pattern) : 리다이렉트 대상의 경로 검증

  • model : 스프링 MVC 모델 상태 검증

  • request : 세션 스코프, 비동기 처리, 요청 스코프 상태 검증

  • forwardedUrl : 이동대상의 경로 검증




5. 기타 메서드

  • andDo() : print, log를 사용할 수 있는 메서드

  • log() : 실행결과를 디버깅 레벨로 출력한다.




Test Life Cycle

Test Life Cycle을 알기 위해서는 TestInstance에 대해 알아야합니다.

public class TestforTestInstance {
	
	Integer number = 0;
	
	@Test
	public void add1() {
		number++;
		System.out.println("number = " + number);
        System.out.println(this);
	}
	
	@Test
	public void add2() {
		number = number + 2;
		System.out.println("number = " + number);
        System.out.println(this);
	}
	
}

TestInstance의 생성과 소멸이 어떤 방식으로 이루어지는지 확인하기 위해 간략한 코드를 준비했습니다.

Test 순서를 정의 하지 않았기 때문에 add1()이 먼저 실행될지 add2()가 먼저 실행될지는 JUnit 내부구조에 따라 결정되기 때문에 단정지어 말할 수 없습니다. 다만 TestInstance의 생성주기에 따라 마지막의 실행된 Test 메서드의 값은 "3" 이 되거나 "2"이하의 값이 되어야 할 것 입니다.

조금 더 확실하게 System.out.println(this);를 호출함으로써 인스턴스가 하나인지 여러개인지 알 수 있습니다.

그리고 생성된 인스턴스 결과를 한번 살펴보겠습니다.

테스트 마지막 값이 "2" 이하의 값이 나왔습니다. 그리고 인스턴스의 해시코드도 다르게 나왔습니다. add1()의 테스트메서드가 실행될 때 add2()의 테스트메서드가 실행될 때마다 각각 다른 인스턴스들이 생성되어 테스트가 진행됐음을 알 수 있습니다.

@TestInstance 어노테이션으로 생성되는 인스턴스들의 LifeCycle을 제어 할 수 있는데 설정 하지 않는다면 기본적으로 PER_METHOD로 작동하기 때문에 1개의 메서드 실행당 1개의 인스턴스가 생성됩니다.

그럼 여기서 하나의 의문이 더 생기게 되는데 @Test 어노테이션이 달린 메서드가 수만개 있다면 인스턴스들도 마찬가지로 수만개가 생성될텐데 테스트가 끝나기 전까지 인스턴스들이 계속해서 생성되지 않나? 라는 의문이 생깁니다. 하지만 인스턴스의 LifeCycle은 하나의 @Test 메서드가 끝이나면 인스턴스도 동시에 Destroy 합니다. 수만개의 테스트 케이스를 돌리더라도 인스턴스의 생성과 파괴를 반복함으로써 리소스 관리가 가능합니다.

Test Life Cycle Customizing

기본값이 PER_METHOD다 보니 테스트를 진행하다보면 불편한 점이 발생하기 마련이다.

예를 들어 1번 테스트 메서드에서 진행한 결과를 2번 테스트 메서드에서 이어 받아 테스트를 진행해야 하는 상황이 온다면 서로 다른 인스턴스 때문에 제대로 된 테스트 진행이 안될 것이다.

@TestInstance(TestInstance.Lifecycle.PER_CLASS) 어노테이션을 사용하여 인스턴스 Life Cycle을 바꾼다면 해결될 수 있다.

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class TestforTestInstance {
	
	Integer number = 0;
	
	@Test
	public void add1() {
		number++;
		System.out.println("number = " + number);
		System.out.println(this);
	}
	
	@Test
	public void add2() {
		number = number + 2;
		System.out.println("number = " + number);
		System.out.println(this);
	}
	
}

위에 사용했던 코드에 Annotation을 추가하여 테스트를 진행하여 보겠습니다.

Annotation 붙이기 전과의 차이를 알아차릴 수 있습니다. 하나의 인스턴스 이기 때문에 마지막 테스트 메서드를 통과한 후 number 의 값이 3이 되었습니다.

기존에는 테스트 메서드를 실행할 때마다 인스턴스를 생성하고 파괴하고를 반복했지만 LifeCycle을 메서드 단위가 아닌 클래스 단위로 만들어서 클래스 안에 있는 Test Method가 전부 작동할 때 까지 인스턴스는 생성 파괴가 반복되지 않고 클래스 안에 있는 첫번째 Test 메서드를 실행할 때 생성되고 마지막 Test 메서드 실행을 완료 했을때 인스턴스가 Destroy 된다.


클래스단위의 Life Cycle을 사용하면 좋은 상황

1. 테스트 인스턴스를 생성하는데 리소스 비용이 많이 드는 경우

2. 원칙적으로 테스트 메서드끼리는 상태를 공유하지 않고 격리가 되어야 좋지만, 순서대로 상태값을 다음 테스트로 전달해야 하는 경우



Test Order Customizing

Life Cycle을 클래스 단위로 설정한다면 Test Order에 대해 필수적으로 학습을 해야한다. 사칙연산만 하더라도 곱하기와 더하기 순서를 실수로 잘못 계산한다면 전혀 다른 계산이 되듯이 하나의 인스턴스를 이용해 여러개의 테스트를 무작위로 이용한다면 테스트를 할 때마다 값이 다르게 나오는 기이한 현상이 일어날 수 있다.

따라서 테스트 순서를 올바르게 커스터마이징 해서 어떤 순서대로 테스트를 진행해야 하는지 설정하는 방법에 대해 배워보도록 하겠다.

Junit5 Docs

JUnit5 Docs 를 발췌한 내용이다.

살펴보면 5가지 종류의 순서 정렬방법이 있지만 여기서는 3번째에 적혀있는 orderAnnotation을 사용할 예정이다.

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(org.junit.jupiter.api.MethodOrderer.OrderAnnotation.class)
public class TestforTestInstance {
	
	Integer number = 3;
	
	@Test
	@Order(2)
	public void add1() {
		number++;
		System.out.println("number = " + number);
		System.out.println(this);
	}
	
	@Test
	@Order(1)
	public void add2() {
		number = number * 2;
		System.out.println("number = " + number);
		System.out.println(this);
	}
	
}


add2()를 먼저 실행하고 add1()을 나중에 실행한 결과창이다.


@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(org.junit.jupiter.api.MethodOrderer.OrderAnnotation.class)
public class TestforTestInstance {
	
	Integer number = 3;
	
	@Test
	@Order(1)
	public void add1() {
		number++;
		System.out.println("number = " + number);
		System.out.println(this);
	}
	
	@Test
	@Order(2)
	public void add2() {
		number = number * 2;
		System.out.println("number = " + number);
		System.out.println(this);
	}
	
}


add1()을 먼저 실행하고 add2()를 나중에 실행한 결과이다.

테스트 순서에 따라 결과가 달라질 수 있으므로 순서 정의가 꼭 필요한 상황이라면 orderAnnotation을 이용해서 테스트 하면 된다.

profile
성장하는 개발자

0개의 댓글