서론
이 글에는 제가 테스트 코드를 작성하면서 겪은 여러 시행착오들이 담겨있습니다. 아직 완벽하게 작성된 글은 아니라고 생각합니다. 이 글은 앞으로 테스트에 대한 깨달음이 생길때마다 계속 업데이트할 예정입니다.
테스트를 작성해야하는 이유
여러분들은 기능을 구현하고 어떻게 테스트하시나요? 테스트 코드를 짜지 않는다면 직접 프로그램을 돌려보며 손으로 테스트 해야합니다. 프로그램과 기능의 규모가 커진다면 테스트에 필요한 시간도 그만큼 증가합니다. 기능을 만들 때 마다, 리펙토링할 때마다 매번 모든 기능을 테스트하기에는 너무 번거롭습니다.
그래서 테스트 코드를 짜야합니다. 좀 더 이유를 설명드리자면…
테스트 코드는 여러분들이 구현한 기능뿐만 아니라 프로그램의 다른 모든 기능까지도 순식간에 검증합니다. 손으로 하면 30분까지도 걸릴 일이 클릭 한번에 5분도 안되어서 끝납니다.
테스트 코드를 짜두면 그만큼 프로그램이 더 안정적이게 됩니다. 테스트를 잘 구성한다면 더 자주, 더 철저하게 기능들을 검증해볼 수 있습니다.
리팩토링할 때도 유용합니다. 리팩토링한 코드가 예상치 못하게 다른 부분에 영향을 줄 수 있는데요. 잘 짜둔 테스트 코드는 이런 것들도 모두 잡아줍니다.
여러분들은 혼자서 개발하지 않습니다. 프로젝트를 하면서 누군가와 함께 개발하실겁니다. 테스트 코드는 내가 짠 기능들을 어떻게 사용해야하는지 보여주는 문서가 될 수도 있습니다.
4번의 경우 좀 더 첨언하자면, Spring에서는 Spring Rest Docs를 지원합니다. 이걸 사용하면 Controller 테스트를 통해 API Docs도 만들 수 있습니다! 이건 Swagger와 달리 코드와 문서가 분리되어있다는 점도 장점이죠.
테스트 코드를 작성하시면 개발할 때의 안정감이 다릅니다. 그 전에는 작은 코드도 방어적으로 수정했다면, 테스트 코드를 작성하는 지금은 편하게 코드를 수정해볼 수 있죠.
Service 테스트
서비스는 보통 Repository에 종속적입니다. 그래서 Mock이 필요합니다. Java에서는 테스트할 때 Mock을 쉽게 할 수 있도록 Mockito라는 라이브러리를 사용합니다. Spring Boot에서는 아래와 같이 spring-boot-starter-test
를 추가했다면 Mockito를 사용할 수 있습니다.
1
testImplementation 'org.springframework.boot:spring-boot-starter-test'
Repository를 Mocking한 Service 테스트
가장 흔한 case입니다. 단위 테스트이므로 Repository를 포함해서 다른 의존성들은 모두 mock하여 테스트합니다.
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
@ExtendWith(MockitoExtension.class)
class ScheduleServiceTest {
@Mock
private ScheduleRepository scheduleRepository;
@InjectMocks
private ScheduleService service;
@Test
@DisplayName("일정을 조회한다.")
void getSchedules() {
// given
LocalDate from = LocalDate.of(2022, 3, 5);
LocalDate to = LocalDate.of(2022, 11, 5);
List<Schedule> schedules = ScheduleMock.createList(5, from, Period.ofDays(5));
when(scheduleRepository.findAllOverlapped(from, to)).thenReturn(schedules);
// when
List<ScheduleResponseDto> actual = service.getSchedules(from, to);
// then
for (int i = 0; i < 5; i++) {
ScheduleResponseDto dto = actual.get(i);
Schedule schedule = schedules.get(i);
assertThat(schedule.getTitle()).isEqualTo(dto.getTitle());
assertThat(schedule.getStartDate()).isEqualTo(dto.getStart());
assertThat(schedule.getEndDate()).isEqualTo(dto.getEnd());
}
}
}
위의 코드는 샘플 테스트 코드입니다. 테스트 클래스에 보시면 @ExtendWith
가 있습니다.
Mockito Extension
@ExtendWith(MockitoExtension.class)
이걸 붙이면 @Mock
이나 @InjectMocks
와 같은 Mockito에서 제공하는 편의성 annotation들을 사용할 수 있습니다.
물론 이걸 붙이지 않아도 mockito를 사용하는데에는 지장이 없지만, Mock 객체를 직접 만들어야하고 서비스에 직접 주입해야합니다.
@Mock
은 Mocking할 대상이 되는 객체에 지정합니다. 이 annotation을 지정하면 그 변수에 mocking된 mockito객체가 들어갑니다.
@InjectMocks
는 자동으로 서비스 객체를 생성하면서 @Mock
이 붙은 mock객체들을 주입해줍니다. 그런데 종종 @Value가 붙은 생성자 매개변수와 같이 @Mock이 아닌 객체도 주입해야하는 경우가 생기는데요. 이때는 직접 초기화 해야합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@ExtendWith(MockitoExtension.class)
class ScheduleServiceTest {
@Mock
private ScheduleRepository scheduleRepository;
private ScheduleService service;
@BeforeEach
void setUp() {
// 이런식으로 그냥 직접 초기화
service = new ScheduleService(scheduleRepository, "id", "password");
}
}
테스트 방식
실제 테스트하는 함수를 볼게요. 저는 given, when, then방식으로 테스트를 구성합니다. 개인적으로 이 방식이 직관적이고, 테스트 로직 구성이 편하고, 읽기 편하다고 생각해요. 이 부분은 개인 취향에 따라 작성하시면 되겠습니다.
Mockito.when
테스트 코드 중간에 mock 객체가 어떤 값을 return해야하는지 정의하는 부분이 있습니다.
1
when(scheduleRepository.findAllOverlapped(from, to)).thenReturn(schedules);
scheduleRepository.findAllOverlapped
를 호출하면서 from과 to로 인자가 들어오면, schedules를 반환한다는 뜻입니다.
저는 위의 코드에서 Mockito.when
을 사용했지만, 스타일에 따라서 given으로 바꿔서 쓰고 싶을 수도 있습니다. 그럴 때는 아래와 같이 바꿔 써주시면 됩니다.
1
2
// Mockito.when과 완전히 동일. 스타일 차이만 있을 뿐입니다.
BDDMockito.given(scheduleRepository.findAllOverlapped(from, to)).willReturn(schedules);
단, given을 사용하시려면 Mockito가 아니라 BDDMockito를 사용하셔야합니다. Mockito.when
-> BDDMockito.given
이 두 함수는 사용법이 거의 동일합니다. 스타일에 맞춰 사용하시면 되겠습니다.
any()대신 구체적인 매개변수
그런데 사실 from과 to말고 any()를 사용해도 테스트는 통과합니다. 이렇게 굳이 구체적인 parameter를 지정한 이유는 테스트를 더 구체적으로 하기 위해서입니다.
만약 any를 지정해두면 parameter에 어떤 이상한 값이 들어와도 테스트는 통과합니다. 그래서 최대한 any를 사용하지 않고, 예상되는 값을 넣어서 테스트를 더 정확하게 하려는 의도입니다. 이렇게 테스트를 구성하면 then절에서 따로 Mockito.verify
를 하지 않아도 된다는 장점이 있습니다.
물론 예측하기 어려운 값이거나 equals & hashcode가 없는 객체는 any를 사용하는 편이 좋을 것 같습니다. 아니면 객체 자체의 동일성이 아니라 특정 field만 검사하고 싶다면 argThat 사용을 고려해보세요.
그리고 특정 매개변수만 any나 argThat과 같은 ArgumentMatcher를 사용하고자 한다면 아래와 같이 사용해야합니다.
1
2
3
4
5
// 이렇게 쓰면 오류!
when(scheduleRepository.findAllOverlapped(Mockito.any(), to)).thenReturn(schedules);
// 반드시 모든 인자에 ArgumentMatcher를 적용해야 함!
when(scheduleRepository.findAllOverlapped(Mockito.any(), Mockito.eq(to))).thenReturn(schedules);
이렇게 모든 인자에 ArgumentMatcher를 사용하거나, 사용하지 않거나 해야합니다.
MockWebServer를 사용해서 외부 API 호출 테스트
몇몇 서비스들은 외부 API를 call합니다. MockWebServer를 통해서 외부 API Call까지도 mock할 수 있습니다.
우선은 이런 서비스들이 여러 개 있다고 가정하고, MockWebServer를 사용하기 편하게 따로 클래스를 만듭니다. 아래는 MockWebServer 테스트에 제가 주로 사용하는 클래스입니다.
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
public abstract class AbstractMockServerTest {
private static final Logger log = getLogger(AbstractMockServerTest.class);
protected static MockWebServer mockServer;
@BeforeAll
static void beforeAll() throws IOException {
mockServer = new MockWebServer();
mockServer.start();
}
@AfterAll
static void afterAll() throws IOException {
mockServer.shutdown();
}
/**
* MockWebServer에 json타입의 body 응답을 추가합니다.
*
* @param bodyDataFileName json body 파일 위치. /test/resources/mockdata/부터 시작합니다. 확장자 .json은 생략합니다.
*/
public void mockJson(String bodyDataFileName) {
enqueueResponse(HttpStatus.OK, bodyDataFileName + ".json", MediaType.APPLICATION_JSON);
}
/**
* MockWebServer에 json타입의 body 응답을 추가합니다.
*
* @param responseCode 응답 코드
* @param bodyDataFileName json body 파일 위치. /test/resources/mockdata/부터 시작합니다. 확장자 .json은 생략합니다.
*/
public void mockJson(HttpStatus responseCode, String bodyDataFileName) {
enqueueResponse(responseCode, bodyDataFileName + ".json", MediaType.APPLICATION_JSON);
}
private void enqueueResponse(HttpStatus responseCode, String bodyDataFileName, MediaType type) {
String body = readMockData(bodyDataFileName);
mockServer.enqueue(new MockResponse().setResponseCode(responseCode.value()).setBody(body)
.addHeader("Content-Type", type.toString()));
}
/**
* MockWebServer에 statusCode만 있는 빈 응답을 추가합니다.
*
* @param responseCode 응답 코드
*/
public void mockWithStatus(HttpStatus responseCode) {
mockServer.enqueue(new MockResponse().setResponseCode(responseCode.value()));
}
/**
* /test/resources/mockdata/에 존재하는 파일을 읽습니다.
*
* @param path 파일 위치. /test/resources/mockdata/부터 시작합니다.
* @return 파일 내용
*/
public static String readMockData(String path) {
String name = "/mockdata/" + path;
log.debug("Load mocking data: {}", name);
return ResourceUtil.readResource(name);
}
}
MockWebServer
MockWebServer의 사용 방법 자체는 간단합니다. 테스트를 시작하기 전에 start, 끝나면 shutdown하면 됩니다.
그리고 반환 결과를 mock해서 넣고 싶다면 enqueue메서드를 이용하면 됩니다.
1
2
// 어떤 요청을 하든 404응답 반환
mockServer.enqueue(new MockResponse().setResponseCode(404));
이렇게 하면 mockServer로 어떤 요청이 들어오든 404를 반환하게 됩니다.
mockJson
그 외에 mockJson 편의 메서드가 있습니다. 이건 앞으로 서비스에서 매번 enqueue하고, MockResponse를 직접 만들고 하지 않도록 미리 만들어 두었습니다.
이렇게 API Call을 mock하는 경우에는 응답 결과들을 파일로 관리하는 편이 좋습니다. 하드코딩된 텍스트는 조금만 길어지면 보기 불편하고, 파일로 응답을 관리하면 실제로 api call을 받는 것 처럼 테스트를 할 수 있습니다.
서비스 테스트
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
class SMSServiceTest extends AbstractMockServerTest {
private SMSService service;
@BeforeEach
public void beforeEach() {
WebClient webClient = WebClient.create();
String apiPath = "http://localhost:" + mockServer.getPort();
this.service = new SMSServiceImpl(webClient, apiPath, "secretKey", "senderPhone");
}
@Test
@DisplayName("성공 응답 - 정상 처리 여부")
public void sendSmsSuccess() {
// given
mockJson("sms/response-success");
// when & then(no error)
service.sendSMS("01011111111", "Test body");
}
@Test
@DisplayName("실패 응답 - body가 일부 누락된 경우")
public void failedByInvalidBody() {
// given
mockJson("sms/response-fail5");
// when & then
Assertions.assertThrows(CannotSendSMSException.class, () ->
service.sendSMS("01011111111", "Test body"));
}
}
위와 같이 비교적 간결하게 서비스 테스트를 구현할 수 있습니다. MockWebServer로 외부 call mocking이 필요하다면, 그냥 AbstractMockServerTest
를 상속하고 편의 메서드들을 이용하면 됩니다.
서비스에서 call하는 API URL은 주입받도록 설계
서비스에서 Call할 API URL은 하드코딩하지 말고 직접 주입받도록 설계해야합니다. 그래야 MockWebServer를 통해서 테스트할 수 있습니다. 하드코딩 해버리면 테스트할 때 실제로 그 주소로 요청을 보냅니다. 이렇게 MockWebServer로 mocking한 의미가 없습니다.
서비스에서 외부 api call할 때 반드시 MockWebServer로 요청을 보내야합니다. 요청을 보내야할 MockWebServer주소는 "http://localhost:" + mockServer.getPort();
입니다.
응답 mocking
1
mockJson("sms/response-success");
이렇게 응답을 mocking하면 mockServer에 어떤 요청을 보내든 /test/resources/mockdata/sms/response-success.json
의 응답을 줍니다.
만약 요청을 여러 번 보낸다면 mockJson을 여러 번 호출해서 큐에 여러 요청을 넣어두면 됩니다. 서비스가 요청보낼 때 마다 큐에서 응답을 하나씩 꺼내서 보내주는 방식으로 동작합니다.
주의할 점은 mockJson을 호출한 횟수와 실제 서비스가 api call하는 횟수가 동일해야합니다. api call횟수가 더 많다면, call보내고 응답이 올 때 까지 기다립니다. 물론 응답이 올 일이 없으니 계속 멈춰있게 됩니다.
mockJson을 더 많이 호출했다면 다른 테스트에 영향을 줄 수 있습니다. 이 문제를 해결하기 위해 매 테스트마다 MockWebServer를 만든다면? 매번 만들고 파괴하니까 테스트 시간이 오래걸릴 겁니다. 차라리 신경써서 횟수를 맞춰주는 편이 훨씬 낫습니다.