Skip to content

유닛 테스트 가이드

계층마다 검증 방식을 다르게 가져간다 — 순수 로직은 JUnit5, Service는 Mockito, Repository는 슬라이스 테스트, Controller는 RestAssured 통합테스트로 커버한다.

Service — Mockito로 리포지토리를 목(mock) 처리

Section titled “Service — Mockito로 리포지토리를 목(mock) 처리”
  1. @ExtendWith(MockitoExtension.class)로 리포지토리를 목 처리해 정상 흐름의 위임 결과와, 예외 발생 시 BizException으로 감싸지는지(codeApiResult 값)를 검증한다.
    api/board/service/BoardServiceTest.java
    @ExtendWith(MockitoExtension.class)
    @DisplayName("BoardService Test")
    class BoardServiceTest {
    @Mock private BoardRepository boardRepository;
    private BoardService boardService;
    @BeforeEach
    void setUp() {
    boardService = new BoardService(boardRepository);
    }
    @DisplayName("find - 존재하지 않는 id면 NOT_FOUND BizException을 던진다")
    @Test
    void findNotFoundTest() {
    given(boardRepository.findById(999L)).willReturn(Optional.empty());
    assertThatThrownBy(() -> boardService.find(999L))
    .isInstanceOf(BizException.class)
    .extracting(e -> ((BizException) e).getCodeApiResult())
    .isEqualTo(CodeApiResult.NOT_FOUND);
    }
    @DisplayName("create - repository에서 예외가 발생하면 BizException으로 래핑한다")
    @Test
    void createExceptionWrappingTest() {
    final RequestDto<BoardCreateRequestDto> request =
    new RequestDto<>(new BoardCreateRequestDto("title", "content"));
    given(boardRepository.save(any(BoardEntity.class)))
    .willThrow(new RuntimeException("db error"));
    assertThatThrownBy(() -> boardService.create(request))
    .isInstanceOf(BizException.class)
    .extracting(e -> ((BizException) e).getCodeApiResult())
    .isEqualTo(CodeApiResult.SERVER_ERROR);
    }
    }
  2. 의존성이 아예 없는 서비스(클래스패스 리소스나 enum 상수를 그대로 반환할 뿐 리포지토리를 쓰지 않는 경우)는 Mockito 없이 순수 JUnit5 단위테스트로 검증한다.

Repository — @DataJpaTest 슬라이스 테스트

Section titled “Repository — @DataJpaTest 슬라이스 테스트”
  1. QueryDsl 커스텀 리포지토리가 필요로 하는 JPAQueryFactory 빈을 자동으로 못 띄우므로 QuerydslConfig를 직접 임포트해야 한다. replace = NONE으로 내장 DB 자동 치환을 막고 Flyway로 세팅된 실제 H2를 그대로 쓴다.
    api/board/repository/BoardQueryDslRepositoryImplTest.java
    @DataJpaTest
    @ActiveProfiles("test")
    @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
    @Import(QuerydslConfig.class)
    @DisplayName("BoardQueryDslRepositoryImpl Test")
    class BoardQueryDslRepositoryImplTest {
    @Autowired private BoardRepository boardRepository;
    @Autowired private TestEntityManager em;
    @Test
    @DisplayName("제목 조건으로 검색하면 포함된 게시글만 반환한다")
    void findByPageWithTitleConditionTest() {
    em.persistAndFlush(BoardEntity.builder().title("[QDSL] 검색됨").content("본문").build());
    em.persistAndFlush(BoardEntity.builder().title("다른 글").content("본문").build());
    final Page<BoardEntity> result =
    boardRepository.findByPage(
    new PaginationRequestDto<>(
    new BoardFindByPageRequestDto("[QDSL]", null, null, null, null),
    Pagination.builder().pageNumber(0).pageSize(10).build()));
    assertThat(result.getContent()).extracting("title").containsExactly("[QDSL] 검색됨");
    }
    }
  2. QueryDsl 동적 조건/정렬/페이징 검증은 *QueryDslRepositoryImplTest, Spring Data 파생 메소드(findById, findByEmail 등) 검증은 *RepositoryTest로 클래스를 나눈다.

Controller — RestAssured 통합테스트

Section titled “Controller — RestAssured 통합테스트”
  1. @SpringBootTest(RANDOM_PORT) + test 프로파일(H2)을 상속하는 공통 베이스(AbstractRestAssured)로 실제 HTTP 호출을 검증한다.
    AbstractRestAssured.java
    @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
    @ActiveProfiles("test")
    public class AbstractRestAssured {
    @LocalServerPort private int port;
    public RequestSpecification jsonRequestSpecification;
    @BeforeEach
    void setup() {
    RestAssured.port = port; // 빈 오버라이드 테스트마다 서버가 새로 뜨므로 매번 갱신
    jsonRequestSpecification =
    new RequestSpecBuilder()
    .setBasePath("/v1")
    .setContentType(ContentType.JSON)
    .addFilter(new SessionFilter())
    .build();
    }
    protected void login() {
    // 고유 이메일의 테스트 전용 사용자를 만들어 로그인 — 세션 쿠키는 SessionFilter가 자동 캡처
    }
    }
  2. 로그인이 필요한 컨트롤러는 @BeforeEach에서 login()을 호출해 로그인 상태를 만들어야 통과한다.
    api/board/controller/BoardControllerTest.java
    @DisplayName("BoardController Test")
    class BoardControllerTest extends AbstractRestAssured {
    @BeforeEach
    void loginTestUser() {
    login();
    }
    @DisplayName("create - 신규 게시글 등록 성공(201 Created, Location 헤더 포함)")
    @Test
    void createBoardTest() {
    final RequestDto<BoardCreateRequestDto> request =
    new RequestDto<>(new BoardCreateRequestDto("title", "본문"));
    given().spec(jsonRequestSpecification)
    .body(request)
    .when()
    .post("/board")
    .then()
    .statusCode(201)
    .header("Location", containsString("/v1/board/"))
    .body("successYn", equalTo("Y"))
    .body("payload.title", equalTo("title"));
    }
    @DisplayName("create - 필수값 누락 시 400 반환")
    @Test
    void createValidationFailTest() {
    final RequestDto<BoardCreateRequestDto> request =
    new RequestDto<>(new BoardCreateRequestDto("", "본문"));
    given().spec(jsonRequestSpecification)
    .body(request)
    .when()
    .post("/board")
    .then()
    .statusCode(400)
    .body("messageCd", equalTo("INVALID_PARAMETER"));
    }
    }
  3. 게스트 접근 제한(@GuestForbidden)을 검증할 때는 loginAsGuest()로 게스트 세션을 만들어 “조회는 되고 쓰기는 403”을 확인한다.
  4. 외부 API를 호출하는 컨트롤러는 StdictClient/KrdictClient 등을 @MockitoBean으로 대체해 실제 네트워크 호출 없이 라우팅/응답 조립만 검증한다.

외부 API 클라이언트 — MockRestServiceServer + 통합테스트 분리

Section titled “외부 API 클라이언트 — MockRestServiceServer + 통합테스트 분리”
  1. RestClient 기반 외부 API 클라이언트는 순수 단위테스트로 검증한다. Spring 컨텍스트 없이 MockRestServiceServer.bindTo(RestClient.Builder)로 요청 파라미터와 응답 파싱만 확인하고, 실제 네트워크 호출은 하지 않는다.
  2. 실제 API를 진짜로 호출해 연동을 확인하는 테스트는 @Tag("integration")으로 분리한다 — 하루 호출 제한·네트워크 의존성 때문에 ./gradlew test/build에는 포함되지 않고, ./gradlew integrationTest로만 수동 실행한다.
    build.gradle
    tasks.named('test') {
    useJUnitPlatform {
    excludeTags 'integration'
    }
    }
    tasks.register('integrationTest', Test) {
    useJUnitPlatform {
    includeTags 'integration'
    }
    }
  1. 테스트 실행과 함께 리포트가 생성된다.
    Terminal window
    ./gradlew test
  2. 리포트 경로
    build/reports/jacoco/test/html/index.html
  3. 엔티티(entity/**)처럼 게터/세터만 있는 클래스는 커버리지 측정 대상에서 제외한다.
    build.gradle
    def coverageExcludes = ['biz/mintchoco/dictionary/entity/**']
    tasks.named('jacocoTestReport') {
    dependsOn('test')
    classDirectories.setFrom(
    files(classDirectories.files.collect {
    fileTree(dir: it, excludes: coverageExcludes)
    })
    )
    }
  4. 모든 클래스를 대상으로 커버리지 80% 정도를 유지하도록 한다.
  1. 빌드(포맷 자동 적용 + 유닛테스트 + 커버리지 리포트 포함)
    Terminal window
    ./gradlew build
  2. 테스트만 실행(mock 기반, 실제 네트워크 호출 없음)
    Terminal window
    ./gradlew test
  3. 실제 외부 API 연동 확인(수동, 네트워크 호출 발생)
    Terminal window
    ./gradlew integrationTest