우테코를 진행하면서 테스트 코드를 처음으로 사용해 보았습니다.
테스트 코드의 단점으로개발 속도 지연
이라는 부분을 찾을 수 있었는데 실제로 해보니 처음에는 어느정도 공감이 되었으나 테스트 코드가 구현되고 난 뒤 실제 구현을 할 때, 뭔가 재미있었고 오류가 크게 나오지 않았습니다. 또한 수정 과정 속에서 디버깅도 훨씬 편리했습니다.실제 구현을 위한 개발 공부도 좋지만 테스트 코드에 관심을 한번 가지고 실제로 해보시는 것도 좋은 경험이 될 것 같습니다.
테스트 코드에는 다양한 패턴이 있습니다. 이중 제가 공부한 패턴은 given-when-then
패턴입니다. 이 패턴은 테스트 코드를 세 단계로 구분해 작성하는 방식입니다.
1. given은 테스트 실행을 준비하는 단계
2. when은 테스트를 진행하는 단계
3. then은 테스트 결과를 검증하는 단계
🔽 given-when-then
public class example {
@DisplayName("새로운 메뉴를 저장한다.")
@Test
public void saveMenuTest() {
// given → 테스트 준비
final String name = "사과";
final int price = 4000;
// when → 테스트 검증을 위한 준비
final Menu apple = new Menu(name, price);
// then → 테스트 결과 검증
final long savedId = menuService.findById(savedId).get();
assertThat(saveMenu.getName().isEqualTo(name));
assertThat(saveMenu.getPrice().isEqualTo(price));
}
}
스프링 부트는 애플리케이션 테스트를 위한 도구와 애너테이션을 제공합니다.
🔽 스프링 부트 스타터 테스트 종류
종류 | 설명 |
---|---|
JUnit | 자바 프로그래밍 언어용 단위 테스트 프레임워크 |
Spring Test & SpringBoot Test | 스프링 부트 애플리케이션을 위한 통합 테스트 지원 |
AssertJ | 검증문인 어설션을 제공하는 라이브러리 |
Hamcrest | 표현식을 보다 이해하기 쉽게 만드는데 필요한 Matcher 라이브러리 |
Mockito | 테스트에 사용할 가짜객체(Mock)를 쉽게 생성, 관리, 검증 지원하는 테스트 프레임워크 |
JSonassert | JSON용 어설션 라이브러리 |
JsonPath | Json 데이터에서 특정 데이터를 선택, 검색하기 위한 라이브러리 |
이중
JUnit
과AssertJ
를 가장 많이 사용하고,Mockito
는 제가 생각할 때 유용하고 편리해서 따로 체크해두었습니다.
개발을 할 때 좋은 코드는 클래스(객체)를 분리하고, 함수(또는 메서드)가 한 가지 일만 하도록 최대 작게 만들어야 한다고 합니다.
함수(또는 메서드)는 단위로 볼 수 있고, 이러한 작은 단위를 검증할 때, 유용한 것이 JUnit입니다.
JUnit 특징
1) 테스트 방식을 구분할 수 있는 애너테이션 제공
2) @Test
애너테이션으로 메서드를 호출할 때마다 새 인스턴스를 생성, 독립 테스트 가능 → 테스트 종료 후 실행 객체 삭제
3) 예상 결과를 검증하는 어설션 메서드 제공
4) 사용 방법이 단순, 테스트 코드 작성 시간이 적음
5) 자동 실행, 자체 결과를 확인하고 즉각적인 피드백 제공
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
public class example {
@DisplayName("7 + 7 = 14 입니다.") // 테스트 이름 작성
@Test // 테스트 메서드
public void JUnitTest() {
int a = 7;
int b = 7;
int sum = 14;
Assertions.assertEquals(a+b, sum); // 동일한 값인지 확인
}
}
Assertions.assertEquals();
은 설명하지 않았으나 어떤 느낌인지 아시겠나요 ?
메서드들의 기능을 보려면 Ctrl
을 누른 상태로 해당 메서드를 눌러보면 된다고 했었습니다. 한번 들어가볼게요 : >
assertEquals
메서드의 코드입니다.
Assert that expected and actual are equal.
public static void assertEquals(int expected, int actual) {
AssertEquals.assertEquals(expected, actual);
}
첫 번째 인수는 기댓값(expected),
두 번째 인수에는 실젯값(actual)을 넣어서 동일한 값인지 확인하는 메서드라고 되어 있습니다.
실제로 실행을 해봅시다 : >
@DisplaName
이 왼쪽 하단에 보이면서 테스트가 정상적으로 동작했다고 나오네요. 만약 실패한다면 어떻게 될까요 ? 직접 해봅시다 !
int a = 7;
int b = 6;
int sum = 14;
Assertions.assertEquals(a+b, sum); // 동일한 값인지 확인
실패하면 기댓값과 실젯값이 다르다고 나오면서 저희가 두려워하는 빨간색상 폰트로 상세히 알려줍니다. 그러면서 public class JUnitTest { }
테스트 실패라고 아이콘이 뜨게 됩니다.
즉, JUnit는 단위 테스트 케이스 실패 → 전체 테스트 실패를 의미합니다.
❗ 이제 테스트를 여러개 해봅시다 !
import org.junit.jupiter.api.*;
public class JUnitTest {
@BeforeAll // 전체 테스트 케이스 시작하기 전에 1회 실행됨, 메서드는 static으로 선언
static void beforeAll() {
System.out.println("@BeforeAll - 전체 테스트 케이스 시작하기 전에 1회 실행됨");
}
@BeforeEach // 테스트 케이스를 시작하기 전마다 실행됨
public void beforeEach() {
System.out.println("@BeforeEach - 테스트 케이스 시작 전마다 실행");
}
@Test
public void test1() {
System.out.println("test1");
}
@Test
public void test2() {
System.out.println("test2");
}
@Test
public void test3() {
System.out.println("test3");
}
@AfterAll // 전체 테스트가 끝난 뒤, 종료하기 전에 1회 실행됨, 메서드는 static으로 선언
static void afterAll() {
System.out.println("@AfterAll - 전체 테스트 케이스 종료되기 전에 1회 실행");
}
@AfterEach // 테스트 케이스 종료하기 전마다 실행
public void afterEach(){
System.out.println("@AfterEach - 테스트 케이스 종료되기 전마다 실행");
}
}
🔽실행 결과
관련해서 정리해보았습니다 : >
AssertJ는 JUnit과 함께 사용해 검증문의 가독성을 확 높여주는 라이브러리입니다. 앞에서 작성한 테스트 코드의
Assertions
는 기댓값과 실제 비교값을 명시하지 않으므로 비교 대상이 잘 구분되지 않았습니다.
그래서Ctrl
을 하고 들어갔었죠 ? 이러한 부분은 가독성이 좋지 않다는 것을 의미합니다. 프로젝트의 규모가 점차 증대함에 따라 가독성은 꽤 중요한 문제입니다.
AssertJ
는 검증문인 어설션을 제공한다고 했습니다. 코드로 보겠습니다 :>
🔽 Assertion
Assertions.assertEquals(a+b, sum);
🔽 AssertJ
assertThat(a+b).isEqualTo(sum)
❗ 둘 다 모두 기댓값 a+b는 실젯값 sum과 같아야 한다는 검증문이지만
Assertion
은 어떤 것이 기댓값이고, 어떤 것이 실젯값인지 알아보기 어렵지만,
AssertJ
는 확실히 가독성이 뛰어나는 것을 확인할 수 있네요 !
AssertJ
자주 사용하는 메서드는 아래를 참고하세요 : >
메서드 | 설명 |
---|---|
isEqualTo(A) | A 값과 같은지 검증 |
isNotEqualTo(A) | A 값과 다른지 검증 |
contains(A) | A 값을 포함하는지 검증 |
doesNotContain(A) | A 값을 포함하지 않는지 검증 |
startsWith(A) | 젒두사가 A인지 검증 |
endsWith(A) | 접미사가 A인지 검증 |
isEmpty() | 비어있는 값인지 검증 |
isNotEmpty() | 비어있지 않은 값인지 검증 |
isPositive() | 양수인지 검증 |
isNegative() | 음수인지 검증 |
isGreaterThan(1) | 1보다 큰 값인지 검증 |
isLessThan(1) | 1보다 작은 값인지 검증 |
스프링 부트3 - 구조 → 프레젠테이션 계층 코드(TestController.java code 참고
TestController.java
파일에서 클래스 이름에서Alt+Enter
를 누르면 Test를 만들 수 있습니다.
🔽 TestControllerTest
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
@SpringBootTest
@AutoConfigureMockMvc // MockMvc 생성
class TestControllerTest {
@Autowired
protected MockMvc mockMvc;
@Autowired
private WebApplicationContext context;
@Autowired
private MemberRepository memberRepository;
@BeforeEach
public void mockMvcSetUp() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@AfterEach
public void cleanUp() {
memberRepository.deleteAll();
}
}
🔍 @AutoConfigureMockMvc
: MockMvc를 생성하고 자동으로 구성하는 애너테이션, MockMvc
는 어플리케이션을 서버에 배포하지 않고도 테스트용 MVC
환경을 만들어 요청과 전송, 응답 기능을 제공하는 유틸리티 클래스로 컨트롤러를 테스트할 때 사용되는 클래스
🔍 @BeforeEach
: 테스트를 실행하기 전에 실행하는 메서드에 적용하는 애너테이션으로, MockMvcSetUp( )
메서드를 실행해 MockMvc
를 설정
🔍 @AfterEach
: 테스트를 실행한 이후에 실행하는 메서드에 적용하는 애너테이션으로, clenUp()
메서드를 실행해 member
테이블에 있는 모든 데이터들을 삭제한다.
TestControllerTest
의 로직을 테스트하는 코드를 구현해보겠습니다 : >
🔽 TestControllerTest
@DisplayName("getAllMembers: 아티클 조회에 성공")
@Test
public void getAllMembers() throws Exception {
// given
final String url = "/test";
Member savedMember = memberRepository.save(new Member(1L, "김동헌"));
// when
final ResultActions result = mockMvc.perform(get(url) // (1)
.accept(MediaType.APPLICATION_JSON)); // (2)
// then
result
.andExpect(status().isOk()) // (3)
// (4) 응답의 0번째 값이 DB의 값과 동일한지 검증
.andExpect(jsonPath("$[0].id").value(savedMember.getId()))
.andExpect(jsonPath("$[0].name").value(savedMember.getName()));
}
}
한번 살펴보겠습니다.
먼저 주석처리가 되어있는
given
,when
,then
이 기억나시나요 ?
given
: 테스트 실행을 준비하는 단계when
: 테스트를 진행하는 단계then
: 테스트 결과를 검증하는 단계
TestControllerTest
에서의given-when-then
패턴은 어떤 역할을 할까요 ?
TestControllerTest의 given
→ 멤버를 저장
TestControllerTest의 when
→ 멤버 리스트를 조회하는 API를 호출
TestControllerTest의 then
→ 응답 코드가 200 OK이며, 반환 받은 값 중에 0번째 요소의 id와 name이 저장된 값과 동일한지 확인
어떤 느낌인지 이해는 되셨나요 ?
이번엔TestControllerTest
코드를 살펴보겠습니다 !
(1) perform()
: 요청을 전송하는 역할로 결과를 ResultActions
객체를 통해서 받으며, ResultActions
객체는 반환값을 검증하고 확인하는 andExpect()
메서드를 제공합니다. 추가 설명 → (3)
final ResultActions result = mockMvc.perform(get(url) // (1)
.accept(MediaType.APPLICATION_JSON)); // (2)
🔽 andExpect() 관련 구성 코드
/**
* Perform a request and return a type that allows chaining further
* actions, such as asserting expectations, on the result.
* @param requestBuilder used to prepare the request to execute;
* see static factory methods in
* {@link org.springframework.test.web.servlet.request.MockMvcRequestBuilders}
* @return an instance of {@link ResultActions} (never {@code null})
* @see org.springframework.test.web.servlet.request.MockMvcRequestBuilders
* @see org.springframework.test.web.servlet.result.MockMvcResultMatchers
*/
public ResultActions perform(RequestBuilder requestBuilder) throws Exception {
if (this.defaultRequestBuilder != null && requestBuilder instanceof Mergeable mergeable) {
requestBuilder = (RequestBuilder) mergeable.merge(this.defaultRequestBuilder);
}
}
(2) accept()
: 요청을 보낼 때 무슨 타입으로 응답을 받을지 결정하는 메서드입니다. 함수(또는 메서드) 정의을 할 때 함수(또는 메서드)의 반환값 자료형 선언과 같은 느낌이네요 !
여기서는 JSON, XML
등 다양한 타입이 있지만 JSON
을 받는다고 명시했습니다.
final ResultActions result = mockMvc.perform(get(url) // (1)
.accept(MediaType.APPLICATION_JSON)); // (2)
🔽 accept() 관련 구성 코드
/**
* Set the 'Accept' header to the given media type(s).
* @param mediaTypes one or more media types
*/
public MockHttpServletRequestBuilder accept(MediaType... mediaTypes) {
Assert.notEmpty(mediaTypes, "'mediaTypes' must not be empty");
this.headers.set("Accept", MediaType.toString(Arrays.asList(mediaTypes)));
return this;
}
(3) andExpect()
: 응답을 검증합니다. TestController
에서 만든 API는 응답으로 OK(200)
을 반환하기 때문에, 이에 해당하는 메서드인 isOk
를 사용해 응답 코드가 Ok(200)
인지 확인 합니다.
result
.andExpect(status().isOk()) // (3)
// (4) 응답의 0번째 값이 DB의 값과 동일한지 검증
.andExpect(jsonPath("$[0].id").value(savedMember.getId()))
.andExpect(jsonPath("$[0].name").value(savedMember.getName()));
🔽 andExpect() 관련 구성 코드
ResultActions andExpect(ResultMatcher matcher) throws Exception;
(4) jsonPath("$[0].id")
: JSON
응답값의 값을 가져오는 역할입니다. 0번째 배열에 들어있는 객체의 id, name
값을 가져오고, 저장된 값과 같은지 확인합니다.
HTTP 주용 응답 코드는 알아두면 좋습니다.
참고하기 편하게 표로 작성하였습니다 : >
🔽 HTTP 상태 코드
코드 | 응답 |
---|---|
200 OK | 요청이 성공적으로 완료 |
201 created | 요청이 성공적이었으며, 그 결과로 새로운 리소스 생성 이 응답은 일반적으로 POST 요청 또는 일부 PUT 요청 이후에 따라옵니다. |
400 Bad Request | 이 응답은 잘못된 문법으로 인하여 서버가 요청을 이해할 수 없음을 의미합니다. |
403 Forbidden | 클라이언트는 콘텐츠에 접근할 권리를 가지고 있지 않습니다. 예를들어 그들은 미승인이어서 서버는 거절을 위한 적절한 응답을 보냅니다. 401과 다른 점은 서버가 클라이언트가 누구인지 알고 있습니다. |
404 Not Found | 서버는 요청받은 리소스를 찾을 수 없습니다. 브라우저에서는 알려지지 않은 URL을 의미합니다. API에서 종점은 적절하지만 리소스 자체는 존재하지 않음을 의미할 수도 있습니다. 서버들은 인증받지 않은 클라이언트로부터 리소스를 숨기기 위하여 이 응답을 403 대신에 전송할 수도 있습니다. |
400 번대 응답 코드 | 클라이언트 에러 응답 |
500 Internal Server Error | 서버 에러 응답 |
500 번대 응답 코드 | 서버가 처리 방법을 모르는 상황이 발생했습니다. 서버는 아직 처리 방법을 알 수 없습니다. |
🔽 HTTP 응답 코드
코드 | 매핑 메서드 | 설명 |
---|---|---|
200 OK | isOk() | HTTP 응답 코드가 200 OK인지 검증 |
201 created | isCreated | HTTP 응답 코드가 201 Created인지 검증 |
400 Bad Request | isBadRequest | HTTP 응답 코드가 400 Bad Request인지 검증 |
403 Forbidden | isForbidden() | HTTP 응답 코드가 403 Forbidden인지 검증 |
404 Not Found | isNotFound() | HTTP 응답 코드가 404 Not Found인지 검증 |
400 번대 응답 코드 | is4xxClientError() | HTTP 응답 코드가 400번대 응답 코드인지 검증 |
500 Internal Server Error | isInternalServerError() | HTTP 응답 코드가 500 Internal Server Error인지 검증 |
500 번대 응답 코드 | is5xxServerError() | HTTP 응답 코드가 500 OK인지 검증 |