프론트에서 테스트라고 하면 이런 코드를 떠올린다.

describe('formatPrice', () => {
  it('천 단위에 콤마를 붙인다', () => {
    expect(formatPrice(12000)).toBe('12,000');
  });
});

백엔드 테스트도 생각은 같다. 다만 백엔드에서는 테스트가 훨씬 더 중요하다는 걸 이번에 알았다.

  • 눌러 볼 화면이 없다. 프론트는 브라우저에서 버튼을 눌러 보면 된다. 하지만 “취소된 출고는 출하 불가” 같은 규칙을 직접 확인하려면 DB에 데이터를 넣고, 서버를 띄우고, API를 호출해야 한다.
  • 서버 재시작이 느리다. 서버를 한 번 띄우는 데 수십 초가 걸린다. 테스트는 0.1초면 끝난다.
  • 틀리면 창고에서 물건이 잘못 움직인다. 지난 글들에서 본 중복 재고 차감 같은 사고가 난다.

글에 나오는 코드는 이해를 돕기 위해 일부 단순화했다.


① 테스트는 세 층으로 나뉜다

          ▲  느림, 적게
         ╱ ╲
        ╱통합╲      FeatureFlagIntegrationTest  ← 진짜 Spring + DB + HTTP
       ╱─────╲
      ╱UseCase╲     DeleteFeatureFlagTest       ← Port를 가짜(Mock)로
     ╱─────────╲
    ╱  도메인   ╲    FeatureFlagTest, OutboundTest ← 순수 Java, 아무것도 없이
   ╱─────────────╲
          ▼  빠름, 많이
층 무엇을 확인하나 필요한 것 프론트 비유
도메인 Entity 규칙 (delete, endPicking) 없음 순수 함수 테스트
UseCase 순서와 조립 (조회 → 검증 → 저장) Mockito MSW로 API를 가짜로 바꾸고 훅 테스트
통합 HTTP 요청이 DB까지 실제로 가나 Spring, DB Playwright E2E

1편에서 배운 Port가 여기서 쓸모를 보여 준다. UseCase는 DB를 직접 모르고 인터페이스만 안다. 그래서 테스트에서는 그 자리에 가짜를 끼우면 되고, DB 없이도 UseCase를 테스트할 수 있다.


② 도메인 테스트: 가장 쉽고 가장 많다

@DisplayName("FeatureFlag 도메인")                // describe
class FeatureFlagTest {

    @Nested
    @DisplayName("delete")                        // 중첩 describe
    class Delete {

        @Test
        @DisplayName("삭제하면 deletedAt과 updatedBy가 설정된다")   // it
        void deletesFeatureFlag() {
            // given
            FeatureFlag featureFlag = offFeatureFlag();

            // when
            featureFlag.delete(DELETED_BY);

            // then
            assertThat(featureFlag.getDeletedAt()).isNotNull();
            assertThat(featureFlag.getUpdatedBy()).isEqualTo(DELETED_BY);
        }
    }
}

생김새만 다르고 Jest와 거의 일대일로 대응된다.

JUnit + AssertJ Jest
@DisplayName("...") + 클래스 describe('...')
@Nested 중첩 describe
@Test + @DisplayName it('...')
assertThat(x).isEqualTo(y) expect(x).toBe(y)
assertThat(x).isNotNull() expect(x).not.toBeNull()

// given / when / then 주석은 준비, 실행, 확인 단계를 나눠 보여 준다. 팀 컨벤션으로 아예 필수로 정해 둔 곳도 있다. 테스트가 길어져도 어디까지가 준비 단계인지 바로 보인다.

예외가 나는지는 이렇게 확인한다.

@Test
@DisplayName("ON 상태면 ActiveFeatureFlagException을 던진다")
void throwsWhenOn() {
    FeatureFlag featureFlag = onFeatureFlag();

    assertThatThrownBy(() -> featureFlag.validateModifiable(flag -> List.of()))
        .isInstanceOf(ActiveFeatureFlagException.class);
}
expect(() => flag.validateModifiable()).toThrow(ActiveFeatureFlagError);

지난 글에서 예외를 클래스별로 나눠 만든 이유가 여기서 하나 더 보였다. 어떤 예외가 나야 하는지를 클래스로 정확히 검증할 수 있다.

도메인 테스트에는 Spring도, DB도, Mock도 없다. 객체를 만들고 메서드를 부르고 결과를 본다. 이렇게 단순할 수 있는 건 규칙을 Entity 안에 뒀기 때문이다. 규칙이 Service와 SQL에 흩어져 있었다면 하나를 확인하는 데도 DB가 필요했을 것이다.


③ 여러 경우를 한 번에: @ParameterizedTest

출고의 endPicking은 PICK과 EXCP일 때만 PACK으로 바뀌고, 나머지 6개 상태에서는 아무 일도 하지 않는다. 6개를 테스트로 하나씩 쓰면 지루하다.

@ParameterizedTest
@EnumSource(value = OutboundStepType.class, names = {"PICK", "EXCP"}, mode = EnumSource.Mode.EXCLUDE)
@DisplayName("PICK과 EXCP가 아니면 상태가 변경되지 않는다")
void endPickingFromOtherSteps(OutboundStepType step) {
    Outbound outbound = createOutboundWithStep(step);

    outbound.endPicking(2L);

    assertEquals(step, outbound.getOutboundStepType());
}

“OutboundStepType 중에서 PICK과 EXCP를 뺀 나머지 전부로 이 테스트를 돌려라”라는 뜻이다. WAIT, PACK, SHIP, DONE, CNCL, EXPA로 6번 실행된다.

test.each(['WAIT', 'PACK', 'SHIP', 'DONE', 'CNCL', 'EXPA'])('%s면 상태가 안 바뀐다', (step) => { ... });

test.each와 다른 점은 목록을 직접 쓰지 않는다는 것이다. 나중에 enum에 상태가 추가되면 자동으로 테스트 대상에 들어간다.

테스트 이름도 다시 보게 됐다. “상태가 변경되지 않는다.” 6편에서 고민했던 “조용히 무시하는 동작”을 이 테스트가 명세로 못 박아 두고 있다. 테스트는 확인 도구이면서 “이 코드는 이렇게 동작해야 한다”고 적어 둔 문서이기도 하다.


④ UseCase 테스트: Mockito로 가짜 끼우기

DeleteFeatureFlag는 생성자로 Port 4개를 주입받는다(3편 DI). 테스트에서는 이 4개를 모두 가짜로 바꾼다.

@ExtendWith(MockitoExtension.class)
class DeleteFeatureFlagTest {

    @InjectMocks                                    // 테스트 대상. 아래 가짜들을 생성자에 넣어서 만들어 준다
    private DeleteFeatureFlag deleteFeatureFlag;

    @Mock private UserApplicationService userService;
    @Mock private FindFeatureFlag findFeatureFlag;
    @Mock private FindUserFeatureFlagRelation findUserFeatureFlagRelation;
    @Mock private LoadFeatureFlagHistory loadFeatureFlagHistory;

3편에서 배운 DI와 같은 원리다. 서버에서는 Spring이 진짜 Repository를 넣어 주고, 테스트에서는 Mockito가 가짜를 넣어 준다. DeleteFeatureFlag는 생성자로 받기만 하니까 받은 게 진짜인지 가짜인지 모른다.

가짜에게는 대답을 미리 정해 준다.

@Test
@DisplayName("OFF 상태이고 연결된 사용자가 없으면 삭제되고 이력이 저장된다")
void deletesFeatureFlagAndSavesHistory() {
    // given
    FeatureFlag featureFlag = offFeatureFlag();
    given(findFeatureFlag.findFeatureFlag(10L)).willReturn(Optional.of(featureFlag));        // "10번 찾으면 이걸 줘"
    given(findUserFeatureFlagRelation.findByFeatureFlag(featureFlag)).willReturn(List.of()); // "연결된 사용자는 없어"

    // when
    deleteFeatureFlag.execute(new DeleteFeatureFlagCommand(1L, 10L));

    // then
    assertThat(featureFlag.getDeletedAt()).isNotNull();
    verify(loadFeatureFlagHistory).loadFeatureFlagHistory(any(FeatureFlagHistory.class));   // "이력 저장이 불렸나?"
}
Mockito Vitest / MSW
@Mock vi.fn(), vi.mock()
given(x.find(10L)).willReturn(flag) find.mockReturnValue(flag), MSW 핸들러
verify(x).save(any()) expect(save).toHaveBeenCalled()
verifyNoInteractions(x) expect(x).not.toHaveBeenCalled()
any(FeatureFlagHistory.class) expect.any(FeatureFlagHistory)

“안 불렸다”를 확인하는 게 더 중요할 때

@Test
@DisplayName("ON 상태면 사용자 조회 없이 예외를 던지고 삭제되지 않는다")
void throwsWhenActiveWithoutCheckingUsers() {
    // given
    FeatureFlag featureFlag = onFeatureFlag();
    given(findFeatureFlag.findFeatureFlag(10L)).willReturn(Optional.of(featureFlag));

    // when & then
    assertThatThrownBy(() -> deleteFeatureFlag.execute(new DeleteFeatureFlagCommand(1L, 10L)))
        .isInstanceOf(ActiveFeatureFlagException.class);

    assertThat(featureFlag.getDeletedAt()).isNull();
    verifyNoInteractions(findUserFeatureFlagRelation, loadFeatureFlagHistory);
}

처음엔 예외만 확인하면 충분하지 않나 싶었다. 그런데 누군가 순서를 바꿔서 이력을 먼저 저장하고 그다음에 검증하도록 고쳤다고 해 보자. 예외는 똑같이 나니까 예외만 보는 테스트는 통과한다. 하지만 실패한 삭제의 이력이 DB에 남는다. verifyNoInteractions가 이런 경우를 잡는다.

UseCase 테스트로는 확인할 수 없는 것

여기엔 진짜 DB도 @Transactional도 없다. 그래서 객체의 deletedAt이 채워졌는지는 확인할 수 있지만, 그 값이 DB에 UPDATE로 실제로 반영되는지는 알 수 없다(5편 변경 감지). 그건 다음 층에서 확인한다.


⑤ 통합 테스트: 진짜로 끝까지

@Test
@DisplayName("피처플래그 전체 조회 시 저장된 목록을 반환한다")
void returnsAllSavedFeatureFlags() throws Exception {
    // given: 진짜 DB에 저장
    featureFlagJpaRepository.save(featureFlag("FF-TEST-001", "테스트 플래그 A"));
    featureFlagJpaRepository.save(featureFlag("FF-TEST-002", "테스트 플래그 B"));

    // when & then: HTTP 요청처럼 호출하고 응답 JSON 확인
    mockMvc.perform(get("/api/v2/feature-flag"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.data.details.length()").value(2));
}

Spring을 실제로 띄우고 DB에 데이터를 넣은 다음, Controller부터 Repository까지 전부 실제로 거쳐 간다. jsonPath("$.data.details")는 응답 JSON에서 값을 꺼내는 문법이다. 프론트에서 response.data.data.details를 읽는 것과 같다.

가장 확실하지만 가장 느리다. Spring을 띄우는 데만 몇 초가 걸린다. 그래서 핵심 흐름만 통합 테스트로 쓰고, 규칙 하나하나는 도메인 테스트로 확인한다. 테스트 구조가 피라미드 모양이 되는 이유다.


퀴즈 풀어 보기

이번 퀴즈는 직접 풀지 않고 바로 답을 봤다. 대신 답을 보면서 왜 그런지 하나씩 따라가 봤다.

문제 1: 규칙을 바꾸면 테스트는 어떻게 될까?

6편에서 endPicking을 “이미 PACK 이후면 무시하고, 그 밖의 잘못된 상태면 예외를 던진다”로 바꾸자는 이야기가 있었다. 그렇게 바꾸면 ③의 테스트는 어떻게 될까?

6개 상태가 둘로 갈린다.

  • PACK, SHIP, DONE: 계속 무시된다. 테스트를 통과한다.
  • WAIT, CNCL, EXPA: 이제 예외가 난다. 테스트가 실패한다.

처음엔 테스트가 깨지면 나쁜 일이라고 생각했다. 하지만 이 테스트에는 “이 상태들에서는 조용히 무시한다”는 기존 명세가 담겨 있다. 동작을 일부러 바꿨다면 명세도 함께 바꾸는 게 맞고, 테스트가 그걸 알려 준 것이다. 실수로 바꾼 거라면 배포 전에 버그를 잡아 준 것이다. 어느 쪽이든 아무 일 없이 통과하는 것보다 낫다.

규칙을 바꾼 뒤에는 테스트도 두 개로 나눈다.

@EnumSource(names = {"PACK", "SHIP", "DONE"})   // 무시되는지
@EnumSource(names = {"WAIT", "CNCL", "EXPA"})   // 예외가 나는지

문제 2: 택배 출하 검증 테스트 빈칸 채우기

@Test
@DisplayName("택배 출고가 PACK 상태여도 송장이 없으면 NotIssuedTrackingNumberException을 던진다")
void throwsWhenNoTrackingNumber() {
    // given
    OutboundStatusChangeTarget outbound = new OutboundStatusChangeTarget(
        1L, OutboundStepType.PACK, TransportType.CARRIER, List.of()
    );
    CarrierChangeOutboundStatusValidator validator = new CarrierChangeOutboundStatusValidator();

    // when & then
    assertThatThrownBy(() -> validator.validate(outbound))
        .isInstanceOf(NotIssuedTrackingNumberException.class);
}

빈칸에 들어갈 답은 PACK, List.of(), NotIssuedTrackingNumberException이다. 포장까지 끝난 택배 출고인데 송장 목록이 비어 있으니 “송장 미발번” 예외가 나야 한다. 테스트 이름이 곧 시나리오가 되고, given에는 그 시나리오를 그대로 옮겨 적으면 된다는 게 보였다.

문제 3: @Transactional을 실수로 지우면?

UseCase 위의 @Transactional을 누가 실수로 지웠다면, UseCase 테스트는 통과할까?

통과한다. @Transactional은 Spring이 붙여 주는 기능인데, Mockito 테스트에는 Spring이 없다. 그러니 어노테이션이 있든 없든 테스트 결과는 같다. 이 테스트는 “메서드가 불렸나”, “필드가 바뀌었나”만 확인한다.

실제 서버에서는 트랜잭션이 없으니 변경 감지가 동작하지 않는다. deletedAt을 바꿔도 UPDATE가 나가지 않아서 삭제가 저장되지 않는다.

이 실수는 Spring과 DB를 실제로 띄우는 통합 테스트만 잡을 수 있다. 삭제 API를 호출한 다음 DB에서 다시 조회해서 deletedAt을 확인하는 식이다. 아래층 테스트가 아무리 많아도 위층 테스트를 남겨 둬야 하는 이유를 이 문제로 납득했다.


정리

개념 한 줄 요약 프론트 비유
given / when / then 준비, 실행, 확인을 나눠 쓴다 Arrange / Act / Assert
assertThat, assertThatThrownBy 값과 예외를 확인한다 expect().toBe(), toThrow()
@ParameterizedTest + @EnumSource enum 전체로 반복한다 test.each
@Mock, @InjectMocks Port 자리에 가짜를 주입한다 vi.fn(), MSW
verify, verifyNoInteractions 불렸는지, 안 불렸는지 확인한다 toHaveBeenCalled
통합 테스트 MockMvc와 진짜 DB로 끝까지 확인한다 Playwright

테스트 문법 자체는 Jest와 거의 같아서 금방 읽혔다. 오히려 새로 알게 된 건 지금까지 배운 설계가 테스트를 쉽게 만들어 준다는 점이었다. 규칙을 Entity에 둬서 도메인 테스트가 순수 Java로 끝나고, Port로 DB를 감춰 둬서 UseCase 테스트에 가짜를 끼울 수 있다. 반대로 말하면 테스트 쓰기가 어려운 코드는 설계부터 의심해 볼 만하다.

다음 글에서는 신규 코드와 다르게 SQL을 직접 쓰는 레거시 MyBatis를 공부할 예정이다.