JUnit에 대해 알아보기 전에 우선 TDD에 대해 알아보자.
TDD (Teset-driven Development)
테스트 주도 개발에서 사용하지만, 코드의 유지보수 및 운영 환경에서의 에러를 미리 방지하기 위해서 단위별로 검증하는 테스트 프레임워크이다.
단위 테스트
작성한 코드가 기대하는 대로 동작을 하는지 검증하는 절차이다.
JUnit
Java 기반의 단위 테스트를 위한 프레임워크이다. 어노테이션 기반으로 테스트를 지원하며,
Assert를 통하여, (예상, 실제)를 통해 검증한다.
스프링에서의 테스트에 앞서, 순수 자바에서 JUnit 테스트를 진행해보도록 하자.
새로운 인텔리제이의 Gradle 프로젝트를 자바를 기반으로 하여 생성하자. 생성하고 나면 자동으로 org.junit.jupiter와 같이 주피터 엔진이 들어가 있다.
JUnit으로 테스트하기 위해 다음과 같이 dependencies에 junit과 test에 useJUnitPlatform()이 들어가 있어야 한다. 우선 JUnit 테스트를 위한 간단한 계산기 코드를 작성한다.
클래스와 인터페이스는 다음과 같이 생성해주면 된다. 코드에 대한 자세한 설명은 따로 붙이지 않도록 하겠다.
// Calculator.java
public class Calculator {
private ICalculator iCalculator; // 외부에서 주입
public Calculator(ICalculator iCalculator) {
this.iCalculator = iCalculator;
}
public int sum(int x, int y) {
return this.iCalculator.sum(x, y);
}
public int minus(int x, int y) {
return this.iCalculator.minus(x, y);
}
}
// KrwCalculator.java
public class KrwCalculator implements ICalculator {
private int price = 1;
@Override
public int sum(int x, int y) {
x *= price;
y *= price;
return x + y;
}
@Override
public int minus(int x, int y) {
x *= price;
y *= price;
return x - y;
}
}
// DollarCalculator.java
public class DollarCalculator implements ICalculator {
private int price = 1;
private MarketApi marketApi;
public DollarCalculator(MarketApi marketApi) { // 외부에서 주입
this.marketApi = marketApi;
}
public void init() {
this.price = marketApi.connect();
}
@Override
public int sum(int x, int y) {
return 0;
}
@Override
public int minus(int x, int y) {
return 0;
}
}
// MarketApi.java
public class MarketApi {
public int connect() {
return 1100;
}
}
public interface ICalculator {
int sum(int x, int y);
int minus(int x, int y);
}
// Main.java
public class Main {
public static void main(String args[]) {
System.out.println("hello junit");
MarketApi marketApi = new MarketApi();
DollarCalculator dollarCalculator = new DollarCalculator(marketApi);
dollarCalculator.init();
// Calculator calculator = new Calculator(new KrwCalculator());
Calculator calculator = new Calculator(dollarCalculator); // 달러 계산기로 작동
System.out.println(calculator.sum(10, 10));
}
}
이렇게 작성하고 실행을 하게 되면, 달러 계산기로 작동이 되는데 달러 계산기에서 작동이 되는 sum, minus 로직을 작성하지 않아 0을 리턴하게 된다. main 로직이 복잡해지며 System.out으로 하는 번거로운 디버깅을 반복하게 되는 단점이 발생한다. 이제 테스트 코드를 따로 작성하여 해당 클래스에서만 테스트를 담당하도록 해보자.
test package 하위의 java에 DollarCalculatorTest 클래스를 만든다. 그리고 DollarCalculator 클래스의 sum(), minus()도 코드를 작성해준다.
public class DollarCalculator implements ICalculator {
private int price = 1;
private MarketApi marketApi;
public DollarCalculator(MarketApi marketApi) { // 외부에서 주입
this.marketApi = marketApi;
}
public void init() {
this.price = marketApi.connect();
}
@Override
public int sum(int x, int y) {
x *= price;
y *= price;
return x - y;
}
@Override
public int minus(int x, int y) {
x *= price;
y *= price;
return x + y;
}
}
// DollarCalculatorTest.java
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
public class DollarCalculatorTest {
@Test
public void testHello() {
System.out.println("hello");
}
@Test
public void dollarTest() {
MarketApi marketApi = new MarketApi();
DollarCalculator dollarCalculator = new DollarCalculator(marketApi);
dollarCalculator.init();
Calculator calculator = new Calculator(dollarCalculator);
Assertions.assertEquals(22000, calculator.sum(10, 10));
Assertions.assertEquals(0, calculator.minus(10, 10));
}
}
@Test는 해당 어노테이션이 붙은 method가 테스트 대상 method임을 나타낸다.
JUnit의 단언문인 Assertion은 method를 사용해서 테스트에서 검증하고자 하는 내용을 확인하는 기능이다.
위에 사용된 assertEquals는 실제값이 기댓값과 같은지 사용할 때 사용된다.
@Test가 적힌 테스트 method를 실행해서 테스트 method가 정상적으로 통과하면 녹색 체크표시와 함께 아무런 문제 없이 잘 실행됨을 알 수 있다. 이렇게 테스트를 하게 되면 에러가 발생했을 때 직관적으로 확인을 할 수가 있다. 이 코드를 보자.
public class DollarCalculator implements ICalculator {
private int price = 1;
private MarketApi marketApi;
public DollarCalculator(MarketApi marketApi) { // 외부에서 주입
this.marketApi = marketApi;
}
public void init() {
this.price = marketApi.connect();
}
@Override
public int sum(int x, int y) {
x *= price;
y *= price;
return x + y;
}
@Override
public int minus(int x, int y) {
x *= price;
y *= price;
return x + y;
}
}
실수로 개발자가 minus() method에 리턴 값으로 x - y가 아닌, x + y로 작성했다고 하자. 그리고 다시 테스트를 진행하면
이처럼 에러가 발생한 테스트 method를 보여주고 기댓값이 0인데 22000이 실제값으로 도출되어서 해당 부분을 찾아가 수정해주면 되는 것을 알 만큼 직관적으로 보여주고 있다.
지금은 connect()가 1100으로 고정이 되어 있지만 변경될 수도 있다. 특정한 객체가 어떤 method를 호출했을 때, 원하는 결괏값을 리턴 시켜줄 수 있도록 한다. 이럴 때 Mock을 사용할 수 있다.
Mockito는 개발자가 동작을 직접 제어할 수 있는 가짜(Mock) 객체를 지원하는 테스트 프레임워크이다.
일반적으로 Spring과 같은 웹 애플리케이션을 개발한다고 하면, 여러 객체들 간의 의존성이 존재한다. 이러한 의존성은 단위 테스트를 작성하는 것을 어렵게 하는데, 이를 해결하기 위해 가짜 객체를 주입시켜주는 Mockito 라이브러리를 활용할 수 있다. Mockito를 활용함으로써 가짜 객체에 원하는 결과를 Stub 하여 단위 테스트를 진행할 수 있다.
(물론 Mock을 하지 않아도 된다면 하지 않는 것이 더욱 좋다.)
Mockito도 테스팅 프레임워크이기 때문에 JUnit과 결합되기 위해서는 별도의 작업이 필요하다.
@ExtendWith(MockitoExtension.class)를 사용해야 결합이 가능하다.
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
public class DollarCalculatorTest {
// MarketApi를 Mocking 처리
@Mock
public MarketApi marketApi;
@BeforeEach
public void init() {
// marketApi의 connect() 호출 시, 1100이 아닌 3000 리턴
Mockito.lenient().when(marketApi.connect()).thenReturn(3000);
}
@Test
public void testHello() {
System.out.println("hello");
}
@Test
public void dollarTest() {
MarketApi marketApi = new MarketApi();
DollarCalculator dollarCalculator = new DollarCalculator(marketApi);
dollarCalculator.init();
Calculator calculator = new Calculator(dollarCalculator);
Assertions.assertEquals(22000, calculator.sum(10, 10));
Assertions.assertEquals(0, calculator.minus(10, 10));
}
@Test
public void mockTest() {
// Mocking 처리한 marketApi를 DollarCalculator에 넣음
DollarCalculator dollarCalculator = new DollarCalculator(marketApi);
dollarCalculator.init();
Calculator calculator = new Calculator(dollarCalculator);
Assertions.assertEquals(60000, calculator.sum(10, 10));
Assertions.assertEquals(0, calculator.minus(10, 10));
}
}
@BeforeEach가 붙은 method는 테스트 method 실행 이전에 수행됨을 의미한다. Mocking 처리한 marketApi를 주입함으로써 해당 테스트 method가 수행될 때 init()에 설정한 3000으로 리턴되도록 한다.
이번에는 순수 자바가 아닌 스프링 프로젝트에서 JUnit 테스트가 어떻게 동작하는지 살펴보자. 자바로 쓴 코드를 옮겨와서 스프링에서 동작하도록 변경을 주었다. (롬복 사용)
패키지와 하위 클래스의 구조는 다음과 같다. 변경한 코드는 특별한 설명 없이 아래에 덧붙인다.
// ICalculator.java
package com.example.springcalculator.component;
public interface ICalculator {
int sum(int x, int y);
int minus(int x, int y);
void init();
}
// Calculator.java
package com.example.springcalculator.component;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class Calculator {
private final ICalculator iCalculator;
public int sum(int x, int y) {
this.iCalculator.init(); // 계산하기 전에 정보를 얻어와서 계산 (시세 계산)
return this.iCalculator.sum(x, y);
}
public int minus(int x, int y) {
this.iCalculator.init();
return this.iCalculator.minus(x, y);
}
}
// DollarCalculator.java
package com.example.springcalculator.component;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor // 생성자에서 주입받던 것을 삭제함
public class DollarCalculator implements ICalculator {
private int price = 1;
private final MarketApi marketApi;
@Override
public void init() {
this.price = marketApi.connect();
}
@Override
public int sum(int x, int y) {
x *= price;
y *= price;
return x + y;
}
@Override
public int minus(int x, int y) {
x *= price;
y *= price;
return x - y;
}
}
// MarketApi.java
package com.example.springcalculator.component;
import org.springframework.stereotype.Component;
@Component
public class MarketApi {
public int connect() {
return 1100;
}
}
// CalculatorApiController.java
package com.example.springcalculator.controller;
import com.example.springcalculator.component.Calculator;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class CalculatorApiController {
private final Calculator calculator;
@GetMapping("/sum")
public int sum(@RequestParam int x, @RequestParam int y) {
return calculator.sum(x, y);
}
@GetMapping("/minus")
public int minus(@RequestParam int x, @RequestParam int y) {
return calculator.minus(x, y);
}
}
원래대로라면 코드를 작성하고 서버를 실행시켜서 결과를 확인해야 한다. 하지만 서버를 실행시키지 않고 본인이 작성한 controller에서도 테스트가 가능하다. 우선 테스트에 앞서 test 패키지가 main 패키지의 실제 코드 패키지가 동일해야 하고 SpringCalculatorApplicationTests라고 붙은 것이 SpringCalculatorApplication과 같은 위치에 존재해야 한다.
// SpringCalculatorApplicationTests.java
package com.example.springcalculator;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class SpringCalculatorApplicationTests {
@Test
void contextLoads() {
}
}
@SpringBootTest 어노테이션은 실질적으로 스프링 컨테이너가 올라가면서 전체적인 테스트가 가능해지게 된다. contextLoads가 잘 실행되는지 보면 실제 스프링과 똑같이 작동을 한다.
이제 동일한 위치에 테스트할 클래스를 생성하고 테스트를 진행해보도록 한다.
// DollarCalculatorTest.java
package com.example.springcalculator.component;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
// MarketApi를 주입받고 있으므로
@Import({MarketApi.class, DollarCalculator.class})
@SpringBootTest
public class DollarCalculatorTest {
@MockBean // 스프링에서는 Bean으로 관리되므로 Mocking 처리를 위해 @MockBean을 사용
private MarketApi marketApi;
@Autowired // 스프링이 관리하는 Bean을 받음
private DollarCalculator dollarCalculator;
@Test
public void dollarCalculatorTeset() {
Mockito.when(marketApi.connect()).thenReturn(3000);
int sum = dollarCalculator.sum(10, 10);
int minus = dollarCalculator.minus(10, 10);
Assertions.assertEquals(60000, sum);
Assertions.assertEquals(0, minus);
}
}
Component 패키지를 생성하고 테스트를 위해 DollarCalculatorTest 클래스를 작성했다. 자바에서 작성한 것과 똑같은 구조인데 스프링에서 관리하므로 Bean으로 설정해준 것만 차이가 있음을 확인할 수 있다. 그리고 테스트를 진행한다.
기댓값은 60,000인데 실제값 20으로 단순히 x, y에 대한 덧셈만 이루어졌음을 확인할 수 있다. init()이 제대로 호출되지 않은 것이기 때문에 코드를 살짝 변경해보자.
// DollarCalculatorTest.java
package com.example.springcalculator.component;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
@SpringBootTest
public class DollarCalculatorTest {
@MockBean // 스프링에서는 Bean으로 관리되므로 Mocking 처리를 위해 @MockBean을 사용
private MarketApi marketApi;
@Autowired // 스프링이 관리하는 Bean을 받음
private DollarCalculator dollarCalculator;
@Test
public void dollarCalculatorTeset() {
Mockito.when(marketApi.connect()).thenReturn(3000);
dollarCalculator.init(); // Calculator를 받으면 init() 필요 X
int sum = dollarCalculator.sum(10, 10);
int minus = dollarCalculator.minus(10, 10);
Assertions.assertEquals(60000, sum);
Assertions.assertEquals(0, minus);
}
}
다음과 같이 정상적으로 실행이 되었으며 통합 테스트가 통과한 것을 알 수 있다. controller 단위 테스트는 잘 동작하는가에 대해 알아보자. 우선 GET 방식에 대해 먼저 알아보도록 한다. controller 패키지를 생성하고 CalculatorApiControllerTest 클래스를 작성한다.
package com.example.springcalculator.controller;
import com.example.springcalculator.component.Calculator;
import com.example.springcalculator.component.DollarCalculator;
import com.example.springcalculator.component.MarketApi;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
// 웹에 필요한 것들만 로딩하므로 자원을 아낄 수 있다.
@WebMvcTest(CalculatorApiController.class)
@AutoConfigureWebMvc
// 주입을 받고 있으므로 import 필요
@Import({Calculator.class, DollarCalculator.class})
public class CalculatorApiControllerTest {
// DollarCalculator의 MarketApi는 mocking 처리
@MockBean
private MarketApi marketApi;
@Autowired
// MVC를 mocking으로 테스트
private MockMvc mockMvc;
@BeforeEach
public void init() {
Mockito.when(marketApi.connect()).thenReturn(3000);
}
@Test
public void sumTest() throws Exception {
// http://localhost:9090/api/sum
mockMvc.perform(
MockMvcRequestBuilders.get("http://localhost:9090/api/sum")
.queryParam("x", "10")
.queryParam("y", "10")
).andExpect(
MockMvcResultMatchers.status().isOk()
).andExpect(
MockMvcResultMatchers.content().string("60000")
).andDo(MockMvcResultHandlers.print());
}
}
mockMvc의 메소드에 대해 설명을 덧붙인다. 글은 https://shinsunyoung.tistory.com/52님의 의 글을 참고했다.
mockMvc의 메소드
1) perform()
- 요청을 전송하는 역할을 합니다. 결과로 ResultActions 객체를 받으며, ResultActions 객체는 리턴 값을 검증하고 확인할 수 있는 andExcpect() 메소드를 제공해줍니다.
2) get("/mock/blog")
- HTTP 메소드를 결정할 수 있습니다. ( get(), post(), put(), delete() )
- 인자로는 경로를 보내줍니다.
3) params(info)
- 키=값의 파라미터를 전달할 수 있습니다.
- 여러 개일 때는 params()를, 하나일 때에는 param()을 사용합니다.
4) andExpect()
- 응답을 검증하는 역할을 합니다.
- 상태 코드 ( status() )
- 메소드 이름 : 상태 코드
- isOk() : 200
- isNotFound() : 404
- isMethodNotAllowed() : 405
- isInternalServerError() : 500
- is(int status) : status 상태 코드
- 뷰 ( view() )
- 리턴하는 뷰 이름을 검증합니다.
- ex. view().name("blog") : 리턴하는 뷰 이름이 blog인가?
- 리다이렉트 ( redirect() )
- 리다이렉트 응답을 검증합니다.
- ex. redirectUrl("/blog") : '/blog'로 리다이렉트 되었는가?
- 모델 정보 ( model() )
- 컨트롤러에서 저장한 모델들의 정보 검증
- 응답 정보 검증 ( content() )
- 응답에 대한 정보를 검증해줍니다.
제대로 동작함을 확인할 수 있었다. 다음으로는 POST 방식의 테스트를 진행해보자.
dto 패키지를 만들고 Req, Res 클래스를 작성한다. POST 방식을 사용할 minus에 대한 method 수정도 하여 다음과 같이 작성한다.
// Req.java
package com.example.springcalculator.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Req {
private int x;
private int y;
}
// Res
package com.example.springcalculator.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Res {
private int result;
private Body response;
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Body {
private String resultCode = "OK";
}
}
// CalculatorApiController.java
package com.example.springcalculator.controller;
import com.example.springcalculator.component.Calculator;
import com.example.springcalculator.dto.Req;
import com.example.springcalculator.dto.Res;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class CalculatorApiController {
private final Calculator calculator; // 주입
@GetMapping("/sum")
public int sum(@RequestParam int x, @RequestParam int y) {
return calculator.sum(x, y);
}
@PostMapping("/minus")
public Res minus(@RequestBody Req req) {
int result = calculator.minus(req.getX(), req.getY());
Res res = new Res();
res.setResult(result);
res.setResponse(new Res.Body());
return res;
}
}
그리고 서버를 실행시켜 확인하는 것이 아닌 테스트 코드를 작성하여 테스트를 진행한다.
// CalculatorApiControllerTest.java
package com.example.springcalculator.controller;
import com.example.springcalculator.component.Calculator;
import com.example.springcalculator.component.DollarCalculator;
import com.example.springcalculator.component.MarketApi;
import com.example.springcalculator.dto.Req;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
// 웹에 필요한 것들만 로딩하므로 자원을 아낄 수 있다.
@WebMvcTest(CalculatorApiController.class)
@AutoConfigureWebMvc
// 주입을 받고 있으므로 import 필요
@Import({Calculator.class, DollarCalculator.class})
public class CalculatorApiControllerTest {
// DollarCalculator의 MarketApi는 mocking 처리
@MockBean
private MarketApi marketApi;
@Autowired
// MVC를 mocking으로 테스트
private MockMvc mockMvc;
@BeforeEach
public void init() {
Mockito.when(marketApi.connect()).thenReturn(3000);
}
@Test
public void sumTest() throws Exception {
// http://localhost:9090/api/sum
mockMvc.perform(
MockMvcRequestBuilders.get("http://localhost:9090/api/sum")
.queryParam("x", "10")
.queryParam("y", "10")
).andExpect(
MockMvcResultMatchers.status().isOk()
).andExpect(
MockMvcResultMatchers.content().string("60000")
).andDo(MockMvcResultHandlers.print());
}
@Test
public void minusTest() throws Exception {
Req req = new Req();
req.setX(10);
req.setY(10);
// String -> json
String json = new ObjectMapper().writeValueAsString(req);
mockMvc.perform(
MockMvcRequestBuilders.post("http://localhost:9090/api/minus")
.contentType(MediaType.APPLICATION_JSON)
.content(json)
).andExpect(
MockMvcResultMatchers.status().isOk()
).andExpect(
MockMvcResultMatchers.jsonPath("$.result").value("0")
).andExpect(
MockMvcResultMatchers.jsonPath("$.response.resultCode").value("OK")
)
.andDo(MockMvcResultHandlers.print());
}
}
GET 방식으로 받는 것과 크게 다르지 않다. json 형태로 받는다. 결과를 확인해보면 해당 테스트에 대해서도 제대로 동작하고 있음을 확인할 수 있다.
'공부 > Spring boot' 카테고리의 다른 글
스프링부트 Swagger (SpringFox Boot Starter 3.0.0) 설정 관련 에러 (0) | 2021.12.16 |
---|---|
AOP (0) | 2021.12.06 |
IoC / DI (0) | 2021.12.05 |
Interceptor (0) | 2021.12.02 |
Spring Boot Filter (0) | 2021.12.01 |