유닛 테스트 가이드
계층마다 검증 방식을 다르게 가져간다 — 순수 로직은 JUnit5, Service는 Mockito, Repository는 슬라이스 테스트, Controller는 RestAssured 통합테스트로 커버한다.
Service — Mockito로 리포지토리를 목(mock) 처리
Section titled “Service — Mockito로 리포지토리를 목(mock) 처리”@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;@BeforeEachvoid setUp() {boardService = new BoardService(boardRepository);}@DisplayName("find - 존재하지 않는 id면 NOT_FOUND BizException을 던진다")@Testvoid 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으로 래핑한다")@Testvoid 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);}}- 의존성이 아예 없는 서비스(클래스패스 리소스나 enum 상수를 그대로 반환할 뿐 리포지토리를 쓰지 않는 경우)는 Mockito 없이 순수 JUnit5 단위테스트로 검증한다.
Repository — @DataJpaTest 슬라이스 테스트
Section titled “Repository — @DataJpaTest 슬라이스 테스트”- 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] 검색됨");}} - QueryDsl 동적 조건/정렬/페이징 검증은
*QueryDslRepositoryImplTest, Spring Data 파생 메소드(findById,findByEmail등) 검증은*RepositoryTest로 클래스를 나눈다.
Controller — RestAssured 통합테스트
Section titled “Controller — RestAssured 통합테스트”@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;@BeforeEachvoid setup() {RestAssured.port = port; // 빈 오버라이드 테스트마다 서버가 새로 뜨므로 매번 갱신jsonRequestSpecification =new RequestSpecBuilder().setBasePath("/v1").setContentType(ContentType.JSON).addFilter(new SessionFilter()).build();}protected void login() {// 고유 이메일의 테스트 전용 사용자를 만들어 로그인 — 세션 쿠키는 SessionFilter가 자동 캡처}}- 로그인이 필요한 컨트롤러는
@BeforeEach에서login()을 호출해 로그인 상태를 만들어야 통과한다.api/board/controller/BoardControllerTest.java @DisplayName("BoardController Test")class BoardControllerTest extends AbstractRestAssured {@BeforeEachvoid loginTestUser() {login();}@DisplayName("create - 신규 게시글 등록 성공(201 Created, Location 헤더 포함)")@Testvoid 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 반환")@Testvoid 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"));}} - 게스트 접근 제한(
@GuestForbidden)을 검증할 때는loginAsGuest()로 게스트 세션을 만들어 “조회는 되고 쓰기는 403”을 확인한다. - 외부 API를 호출하는 컨트롤러는
StdictClient/KrdictClient등을@MockitoBean으로 대체해 실제 네트워크 호출 없이 라우팅/응답 조립만 검증한다.
외부 API 클라이언트 — MockRestServiceServer + 통합테스트 분리
Section titled “외부 API 클라이언트 — MockRestServiceServer + 통합테스트 분리”RestClient기반 외부 API 클라이언트는 순수 단위테스트로 검증한다. Spring 컨텍스트 없이MockRestServiceServer.bindTo(RestClient.Builder)로 요청 파라미터와 응답 파싱만 확인하고, 실제 네트워크 호출은 하지 않는다.- 실제 API를 진짜로 호출해 연동을 확인하는 테스트는
@Tag("integration")으로 분리한다 — 하루 호출 제한·네트워크 의존성 때문에./gradlew test/build에는 포함되지 않고,./gradlew integrationTest로만 수동 실행한다.build.gradle tasks.named('test') {useJUnitPlatform {excludeTags 'integration'}}tasks.register('integrationTest', Test) {useJUnitPlatform {includeTags 'integration'}}
코드 커버리지 (jacoco)
Section titled “코드 커버리지 (jacoco)”- 테스트 실행과 함께 리포트가 생성된다.
Terminal window ./gradlew test - 리포트 경로
build/reports/jacoco/test/html/index.html
- 엔티티(
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)}))} - 모든 클래스를 대상으로 커버리지 80% 정도를 유지하도록 한다.
실행 명령어 정리
Section titled “실행 명령어 정리”- 빌드(포맷 자동 적용 + 유닛테스트 + 커버리지 리포트 포함)
Terminal window ./gradlew build - 테스트만 실행(mock 기반, 실제 네트워크 호출 없음)
Terminal window ./gradlew test - 실제 외부 API 연동 확인(수동, 네트워크 호출 발생)
Terminal window ./gradlew integrationTest