과정을 즐기자

저희 팀에서는 이렇게 테스트 코드를 작성해요 본문

테스트 코드

저희 팀에서는 이렇게 테스트 코드를 작성해요

320Hwany 2024. 5. 11. 16:09

이번 글에서는 프로젝트를 진행하면서 저희 팀이 어떻게 테스트 코드를 작성하고 있는 지에 대해 이야기 해보려고 합니다.

저희 팀은 현재 백엔드 개발자가 3명입니다. 테스트 코드를 작성하는 규칙을 정할 때 많은 이야기가 오고 갔습니다.

주로 이야기 했던 것은 테스트 코드가 중요하다고 관성적으로 작성하지 말고 "왜" 필요한지를 꼭 인지하며

테스트 코드 작성이 서비스 개발에 큰 도움이 되는 것을 느껴보자는 것이었습니다.

 

테스트 코드가 중요하다는 사실은 수도 없이 들었지만 이전에 해왔던 방식에서 크게 2가지 문제점을 느꼈습니다.

 

1. 관성적으로 의미 없는 테스트 코드를 작성하지는 않았는가?

2. 실질적인 이득을 본 것이 무엇인가?

 

이러한 문제를 인식하고 안정적이고 견고한 서비스를 개발하기 위해 테스트 코드를 작성하는 규칙을 정했습니다.

📕 어떤 계층을 어떻게 테스트 해야 하는가?

저희는 가장 보편적인 레이어드 아키텍쳐를 사용했습니다. Repository - Service - Controller 로 이루어진 구조입니다.

하지만 로직이 복잡해질 수록 Service 계층이 테스트하기 어려워진다는 단점이 있어서 중간에 Implement 계층을 

추가하였습니다. 결과적으로 Repository - Implement - Service - Controller로 이루어진 구조가 됩니다.

 

만약 모든 계층을 테스트 한다면 각각 어떤 것을 테스트해야 할까요?

 

1. Repository

Repository는 DB에 접근하는 계층입니다. Repository는 DB에 접근하고 쿼리 결과를 반환합니다.

테스트를 한다면 미리 데이터를 넣어놓고 해당 쿼리를 실행하고 결과가 제대로 반환하는지 테스트하면 될 것 같습니다.

 

2. Implement

Implement 계층은 상세한 로직 구현의 흐름을 나타내는 계층입니다. 상세한 로직은 도메인으로 넘기지만

구현의 흐름은 Implement 안에 있습니다. 테스트를 한다면 비즈니스 로직의 흐름을 테스트하면 될 것 같습니다.

 

3. Service

Service 계층은 전체 비즈니스 로직의 흐름을 나타내는 계층입니다. 상세한 로직의 흐름이 아니고 조금 복잡한

비즈니스 로직의 묶음을 나타냅니다. 테스트를 한다면 전체 로직의 흐름을 테스트하면 될 것 같습니다.

 

4. Controller

Controller 계층은 요청과 응답과 관련된 계층입니다. 테스트를 한다면 어떠한 요청에 대한 응답을 제대로 

반환하는 지를 테스트하면 될 것 같습니다.

 

물론 이렇게 4계층을 모두 테스트하면 견고하고 안정성있는 테스트를 작성할 수 있습니다.

하지만 이렇게 테스트를 작성하다보면 너무 많은 테스트 케이스를 작성하게 됩니다. 위에서 말했듯이

관성적으로 의미 없는 테스트 코드를 작성하지는 않았는지를 계속 고민했습니다.

 

📘 Controller 테스트는 통합 테스트로 전부 테스트 해야 하나...?

이전에는 Controller 테스트가 통합 테스트라고 생각했습니다. 요청이 들어오고 응답이 나갈 때까지

그 사이에 있는 로직이 제대로 동작하는지를 확인하기 위해 미리 데이터를 집어넣고 응답까지 확인하는 

테스트 코드를 작성했습니다. @SpringBootTest로 이 작업을 했습니다.

 

하지만 이 작업이 매우 번거롭다는 생각이 들었습니다. 시간도 굉장히 많이 들고 큰 의미가 있는 

테스트를 작성하는 것 같지도 않았습니다. 

 

따라서 저희는 Controller 테스트를 블랙박스 테스트, API 문서화를 위한 테스트라고 판단했습니다.

Controller 테스트에서는 어떠한 요청과 응답 형식만 체크하고 그 내부 로직은 테스트하지 않았습니다.

아래는 예시 코드입니다.

class MeetingControllerTest extends ControllerTest {

    private final MeetingService meetingService = new FakeMeetingService();

    @Override
    protected Object initController() {
        return new MeetingController(meetingService);
    }

    @DisplayName("단일 미팅을 생성한다.")
    @Test
    void createSingleMeeting() throws Exception {
        MeetingCreateRequest meetingCreateRequest = MeetingCreateRequest.builder()
                .moimId(1)
                .agenda(MEETING_AGENDA.value())
                .startDateTime(LocalDateTime.of(2024, 3, 4, 10, 0, 0))
                .endDateTime(LocalDateTime.of(2024, 3, 4, 12, 0, 0))
                .place(MEETING_PLACE.value())
                .meetingCategory(MeetingCategory.SINGLE)
                .build();

        String json = objectMapper.writeValueAsString(meetingCreateRequest);

        mockMvc.perform(post("/api/meetings")
                        .contentType(APPLICATION_JSON)
                        .content(json)
                )
                .andExpect(status().isOk())
                .andDo(document("단일 미팅 생성 성공",
                        resource(ResourceSnippetParameters.builder()
                                .tag("미팅")
                                .summary("미팅 생성")
                                .requestFields(
                                        fieldWithPath("moimId").type(NUMBER).description("모임 id"),
                                        fieldWithPath("agenda").type(STRING).description("미팅 의제"),
                                        fieldWithPath("startDateTime").type(STRING).description("미팅 시작 시간"),
                                        fieldWithPath("endDateTime").type(STRING).description("미팅 종료 시간"),
                                        fieldWithPath("place").type(STRING).description("미팅 장소"),
                                        fieldWithPath("meetingCategory").type(STRING).description("미팅 카테고리")
                                )
                                .build()
                        )));
    }
}

 

Controller를 @SpringBootTest로 스프링 빈으로 관리하는 테스트가 아니고 객체를 생성하고 FakeService 객체를 주입받습니다.

Service 계층은 인터페이스로 만들었고 FakeService 클래스는 이를 구현합니다.

public class FakeMeetingService implements MeetingService {

    @Override
    public void createMeeting(final MeetingCreateRequest meetingCreateRequest) {

    }
}

void 형식이라면 위 코드처럼 아무것도 작성하지 않았습니다. "그럼 이것이 무슨 의미가 있냐?" 라고 생각하실 수도 있지만

앞에서 이야기한 것처럼 저희는 Controller 테스트를 블랙박스 테스트, API 문서화를 위한 테스트를 위해 작성하였고

충분히 큰 의미가 있다고 판단했습니다.

📗 Service 테스트는 필요한가?

Service는 전체 비즈니스 로직의 묶음이라고 이야기했습니다. 만약 로직이 단순하다면 Implement의 테스트와 거의 동일하고

로직이 복잡해진다면 테스트를 작성하는 것이 의미가 있을 수도 있다고 생각했습니다. 아직까지 이 부분에 대한 테스트 작성은

고민중입니다. Implement 테스트로 커버할 수 있다고 생각하면서도 조금 더 견고한 테스트 작성을 위해서는 필요하다는 생각이

들기 때문입니다. 

 

저희 팀에서는 현재 Implement 테스트로도 충분히 커버할 수 있다고 생각했고 개발 속도가 중요한 단계여서 Service 테스트는

아직은 진행하지 않기로 했습니다.

📙 Implement 테스트는 어떻게 작성하지 (SpringBootTest / Fake / Mocking)

Implement 계층은 서비스를 견고하게 만들기 위해서 가장 테스트를 신경써서 작성해야 할 부분이라고 생각했습니다.

저희는 @SpringBootTest, Fake 객체 사용, Mocking 3가지 방법을 생각했습니다. 이 중에서 직접 스프링 빈으로 띄워서

테스트하는 @SpringBootTest를 선택했습니다. 이유는 아래와 같습니다.

 

1. Mocking으로 단순화/속도를 챙길 수 있으나 느리다고 해도 가장 정확한건 @SpringBootTest

2. Fake 객체를 사용하는 것도 좋아보이지만 Implement 계층도 모두 인터페이스로 생성하는 번거로움이 있음

 

또한 테스트 코드 작성시 테스트 DB 롤백 방식에 대해 논의하였는데 테스트 코드에서는 @Transactional을 사용하지 않기로

결정하였습니다. @Transactional을 사용할 때의 장점보다는 단점이 더 크다고 생각하여 자바 리플렉션을 이용하여 명시적으로

롤백해주는 방식을 적용하였습니다. 자세한 내용은 이전 글을 참고해주세요.

 

테스트 코드에서 @Transactional 사용에 대한 끊임 없는 논쟁

각종 블로그 글, 유튜브, 페이스북, 오픈 카톡방, 인프런 글 등을 보다 보면 테스트 코드 작성시 @Transactional의 사용 여부는 끊임없이 논쟁되는 주제 같습니다. 여러 글들을 많이 보았는데 향로님

320hwany.tistory.com

📒 Repository 테스트는 필요한가?

Repository 테스트는 해당 쿼리의 결과를 반환하는 것을 테스트하면 되는데 Implement 계층에서 비즈니스 로직을 

테스트하면서 충분히 검증할 수 있다고 생각했기 때문에 별도의 Repository 테스트는 작성하지 않았습니다.

📚 정리

저희 팀에서 프로젝트를 하면서 계층 별로 테스트를 작성하는 방법에 대해 알아보았습니다.

Controller는 블랙박스, RestDocs 문서화를 위한 테스트를 작성하고 Service는 아직은 도입하지 않았지만

조금 더 안정성있는 서비스를 만들기 위해서는 작성할 필요가 있다고 생각합니다.

Implement 테스트는 상세한 로직의 흐름이 있기 때문에 테스트에서 중요한 계층이고 @SpringBootTest를 

선택했습니다. Repository는 별도의 테스트를 작성하지 않았습니다.

 

4개의 계층에 대한 테스트에 대해 알아보았는데 여기서 가장 중요한 테스트는 도메인, 엔티티에 대한 테스트라고

생각합니다. Implement도 결국은 구현의 흐름을 나타내는 것이고 상세한 비즈니스 구현 로직은 도메인과 엔티티에

존재하기 때문이며 이 부분에 대한 테스트는 POJO로 작성이 가능하기 때문입니다. 이 부분에 대한 테스트가 많아지면

많아질 수록 더 좋은 설계로 가는 신호라고 생각합니다.