테스트 코드를 작성하는 이유?
- 개발 과정에서 문제를 미리 발견할 수 있다.
- 리팩토링의 리스크가 줄어든다.
- 애플리케이션을 가동해서 직접 테스트하는 것보다 테스트를 빠르게 진행할 수 있다.
- 하나의 명세 문서로의 기능을 수행한다.
- 몇 가지 프레임워크에 맞춰 테스트 코드를 작성하면 좋은 코드를 생산할 수 있다.
- 코드가 작성된 목적을 명확하게 표현할 수 있으며, 불필요한 내용이 추가되는 것을 방지한다.
단위 테스트와 통합 테스트
- 단위 테스트 : 애플리케이션의 개별 모듈을 독립적으로 테스트하는 방식
- 테스트 대상의 범위를 기준으로 가장 작은 단위의 테스트 방식.
- 일반적으로 메서드 단위로 테스트를 수행하게 되며, 메서드 호출을 통해 의도한 결과가 나오는지 확인하는 수준으로 테스트를 진행.
- 단위 테스트는 비용이 적게 들기 때문에 피드백을 빠르게 받을 수 있음.
- 통합 테스트 : 애플리케이션을 구성하는 다양한 모듈을 결합해 전체적인 로직이 의도한 대로 동작하는지 테스트하는 방식
- 모듈을 통합하는 과정에서 호환성 들을 포함해 애플리케이션이 정상적으로 동작하는지 확인하기 위해 수행하는 테스트 방식.
- 단위 테스트는 모듈을 독립적으로 테스트하는 반번 통합 테스트는 여러 모듈을 함께 테스트해서 정상적인 로직 수행이 가능한지를 확인.
- 통합 테스트는 외부 요인들을 포함하고 테스트를 진행하므로 애플리케이션이 온전히 동작하는지를 테스트 함.
- 테스트를 수행할 때마다 모든 컴포넌트가 동작해야 하기 때문에 테스트 비용이 커지는 단점이 존재.
테스트 코드 작성 방법
Given-When-Then 패턴
- Given
- 테스트를 수행하기 전에 테스트에 필요한 환경을 설정하는 단계. 테스트에 필요한 변수를 정의하거나 Mock 객체를 통해 특정 상황에 대한 행동을 정의
- When
- 테스트의 목적을 보여주는 단계. 실제 테스트 코드가 포함되며, 테스트를 통한 결과값를 가져오게 됨.
- Then
- 테스트의 결과를 검증하는 단계. 일반적으로 When 단계에서 나온 결과값을 검증하는 작업을 수행. 결과값이 아니더라도 이 테스트를 통해 나온 결과에서 검증해야 하는 부분이 있다면 이 단계에 포함.
Given-When-Then 패턴은 테스트 주도 개발(TDD)
에서 파생된 행위 주도 개발 (BDD)
을 통해 탄생한 테스트 접근 방식이다.
Given-When-Then 패턴은 불필요하게 코드가 길어져 간단한 테스트로 여겨지는 단위 테스트에서는 잘 사용하지 않는다. 하지만 이 패턴을 통해 테스트 코드를 작성하면 명세 문서의 역할
을 수행한다는 측면에서 도움이 된다.
F.I.R.S.T
F.I.R.S.T 는 테스트 코드를 작성하는 데 도움이 될 수 있는 5가지 규칙을 의미한다.
- Fast (빠르게)
- 테스트는 빠르게 수행되어야 한다. 테스트가 느리면 코드를 개선하는 작업이 느려져 코드 품질이 떨어질 수 있다. 테스트 속도에 절대적인 기준은 없지만 목적을 단순하게 설정해서 작성하거나 외부 환경을 사용하지 않는 단위 테스트를 작성하는 것 등을 빠른 테스트라고 할 수 있다.
- Isolated (고립된, 독립적)
- 하나의 테스트 코드는 목적으로 여기는 하나의 대상에 대해서만 수행되어야 한다. 만약 하나의 테스트가 다른 테스트 코드와 상호작용하거나 관리할 수 없는 외부 소스를 사용하게 되면 외부 요인으로 인해 테스트가 수행되지 않을 수 있다.
- Repeatable (반복 가능한)
- 테스트는 어떤 환경에서도 반복 가능하도록 작성해야 한다. 이 의미는 앞선 Isolated 규칙과 비슷한 의미를 가지고 있다. 테스트는 개발 환경의 변화나 네트워크의 연결 여부와 상관없이 수행되어야 한다.
- Self-Validating (자가 검증)
- 테스트는 그 자체만으로 테스트의 검증이 완료되어야 한다. 테스트가 성공했는지 실패했는지 확인할 수 있는 코드를 함께 작성해야 한다. 만약 결과값과 기대값을 비교하는 작업을 코드가 아니라 개발자가 직접 확인하고 있다면 좋지 못한 테스트 코드이다.
- Timely (적시에)
- 테스트 코드는 테스트하려는 애플리케이션 코드를 구현하기 전에 완성되어야 한다. 너무 늦게 작성된 테스트 코드는 정상적인 역할을 수행하기 어려울 수 있다. 또한 테스트 코드로 인해 발견된 문제를 해결하기 위해 소모되는 개발 비용도 커지기 쉽다. 다만 이 개념은 TDD 를 따르는 테스트 작성 규칙으로, TDD 가 아니라면 이 규칙은 제외하고 진행하기도 한다.
JUnit을 활용한 테스트 코드 작성
- @WebMvcTest(테스트 대상 클래스.class)
- 웹에서 사용되는 요청과 응답에 대한 테스트를 수행할 수 있음. 대상 클래스만 로드해 테스트를 수행하며, 만약 대상 클래스를 추가하지 않으면
@Controller
,@RestController
,@ControllerAdvice
등의 컨트롤러 관련 빈 객체가 모두 로드된다.@SpringBootTest
보다 가볍게 테스트하기 위해 사용한다.
- 웹에서 사용되는 요청과 응답에 대한 테스트를 수행할 수 있음. 대상 클래스만 로드해 테스트를 수행하며, 만약 대상 클래스를 추가하지 않으면
- @MockBean
@MockBean
은 실제 빈 객체가 아닌 Mock(가짜) 객체를 생성해서 주입하는 역할을 수행한다.@MockBean
이 선언된 객체는 실제 객체가 아니기 때문에 실제 행위를 수행하지 않는다. 따라서 해당 객체는 개발자가Mockito
의given()
메서드를 통해 동작을 정의해야 한다.- 해당 어노테이션을 통해 의존성을 가지고 있는 객체에 Mock 객체를 주입할 수 있다.
일반적으로 @WebMvcTest
어노테이션을 사용한 테스트는 슬라이스(Slice) 테스트라고 부른다. 슬라이스 테스트는 단위 테스트와 통합 테스트의 중간 개념으로 이해하면 되는데, 레이어드 아키텍처를 기준으로 각 레이어별로 나누어 테스트를 진행한다는 의미이다. 단위 테스트를 수행하기 위해서는 모든 외부 요인을 차단하고 테스트를 진행해야 하지만 컨트롤러는 개념상 웹과 맞닿은 레이어로서 외부 요인을 차단하고 테스트하면 의미가 없기 때문에 슬라이스 테스트를 진행하는 경우가 많다.
@WebMvcTest 를 이용한 테스트 예시 (Controller)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
package com.springboot.jpa.controller;
import static org.mockito.BDDMockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import com.google.gson.Gson;
import com.springboot.jpa.data.dto.ProductDto;
import com.springboot.jpa.data.dto.ProductResponseDto;
import com.springboot.jpa.service.impl.ProductServiceImpl;
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
ProductServiceImpl productService;
@Test
@DisplayName("MockMvc를 통한 Product 데이터 가져오기 테스트")
void getProductTest() throws Exception {
// given : Mock 객체가 특정 상황에서 해야하는 행위를 정의하는 메소드
given(productService.getProduct(123L)).willReturn(
new ProductResponseDto(123L, "pen", 5000, 2000));
String productId = "123";
// andExpect : 기대하는 값이 나왔는지 체크해볼 수 있는 메소드
mockMvc.perform(
get("/product?number=" + productId))
.andExpect(status().isOk())
.andExpect(jsonPath(
"$.number").exists()) // json path의 depth가 깊어지면 .을 추가하여 탐색할 수 있음 (ex : $.productId.productIdName)
.andExpect(jsonPath("$.name").exists())
.andExpect(jsonPath("$.price").exists())
.andExpect(jsonPath("$.stock").exists())
.andDo(print());
// verify : 해당 객체의 메소드가 실행되었는지 체크해줌
verify(productService).getProduct(123L);
}
@Test
@DisplayName("Product 데이터 생성 테스트")
void createProductTest() throws Exception {
// Given
ProductDto productDto = ProductDto.builder()
.name("pen")
.price(5000)
.stock(2000)
.build();
given(productService.saveProduct(productDto))
.willReturn(new ProductResponseDto(123L, "pen", 5000, 2000));
Gson gson = new Gson();
String content = gson.toJson(productDto);
// When
mockMvc.perform(
post("/product")
.content(content)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.number").exists())
.andExpect(jsonPath("$.name").exists())
.andExpect(jsonPath("$.price").exists())
.andExpect(jsonPath("$.stock").exists())
.andDo(print());
// Then
verify(productService).saveProduct(new ProductDto("pen", 5000, 2000));
}
}
given() 메서드로 Mock 객체가 반환할 값을 미리 설정해주고 테스트를 진행한다. 또한 verity() 메서드로 해당 함수가 정상적으로 호출되었는지 확인할 수 있다.
일반적인 단위테스트 (Service)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package com.springboot.jpa.service.impl;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import com.springboot.jpa.data.dto.ProductDto;
import com.springboot.jpa.data.dto.ProductResponseDto;
import com.springboot.jpa.data.entity.Product;
import com.springboot.jpa.data.repository.ProductRepository;
class ProductServiceTest {
private final ProductRepository productRepository = Mockito.mock(ProductRepository.class);
private ProductServiceImpl productService;
@BeforeEach
public void setUp() {
productService = new ProductServiceImpl(productRepository);
}
@Test
void getProductTest() {
Product givenProduct = new Product();
givenProduct.setNumber(123L);
givenProduct.setName("펜");
givenProduct.setPrice(1000);
givenProduct.setStock(1234);
// 아래 두가지 방법 모두 사용 가능
// Mockito.when(productRepository.findById(123L))
// .thenReturn(Optional.of(givenProduct));
when(productRepository.findById(123L))
.thenReturn(Optional.of(givenProduct));
ProductResponseDto productResponseDto = productService.getProduct(123L);
assertThat(productResponseDto.getNumber()).isEqualTo(givenProduct.getNumber());
assertThat(productResponseDto.getName()).isEqualTo(givenProduct.getName());
assertThat(productResponseDto.getPrice()).isEqualTo(givenProduct.getPrice());
assertThat(productResponseDto.getStock()).isEqualTo(givenProduct.getStock());
}
@Test
void saveProductTest() {
Product savedProduct = new Product();
savedProduct.setNumber(123L);
savedProduct.setName("펜");
savedProduct.setPrice(1000);
savedProduct.setStock(1234);
when(productRepository.save(any(Product.class)))
.thenReturn(savedProduct);
ProductResponseDto productResponseDto = productService.saveProduct(new ProductDto("펜", 1000, 1234));
assertThat(productResponseDto.getName()).isEqualTo(savedProduct.getName());
verify(productRepository).save(any());
}
}
Mockito.mock() 메서드로 Mock 객체를 생성할 수 있다. 또한 when() 메서드로 Mock 객체가 호출된 경우에 어떤 값을 리턴할지 설정할 수 있다.