본문 바로가기
Testing

테스트 코드 뜯어보기 - 조연들을 한자리에

by Ramos 2026. 7. 7.

테스트 코드 관련 시리즈 글을 따라오며 계속 곁을 스쳤지만 정면으로 다루지 못한 것들이 있다. 여러 확장을 조합하는 @ExtendWith, 테스트를 계층으로 묶는 @Nested, 목에 넘어간 인자를 사후에 붙잡는 ArgumentCaptor, 가독성 레이어인 BDDMockito, 그리고 죽은 스텁을 실패로 노출시키는 Strict Stubbing. 이번 시리즈 마지막 글에서 이 조연들을 한자리에 모았다.

Intro

  • @ExtendWith는 JUnit4의 @RunWith(하나만)를 여러 개 조합 가능한 Extension 모델로 대체한 것. before는 선언 순서, after는 역순으로 도는 양파 구조다.
  • @Nested는 테스트를 "대상 메서드 > 시나리오" 계층으로 묶는다. non-static inner class여야 하며, 라이프사이클상 매 테스트마다 인스턴스가 새로 만들어져 오염이 없다.
  • ArgumentCaptor는 목의 리턴값이 아니라 목에 넘어간 인자를 붙잡아 검증한다. 응답 JSON만 봐선 알 수 없는 "서비스에 무엇을 넘겼나"를 본다.
  • BDDMockito는 기능 추가 없이 Mockito.when/verify를 Given-When-Then 문장으로 바꾼 얇은 래퍼다.
  • Strict Stubbing은 안 쓰인 스텁을 테스트 실패로 만들어 죽은 코드를 청소하게 만드는 압력이다.

@ExtendWith: 여러 확장을 조합하다

JUnit5는 JUnit4의 @RunWith(러너 하나만 지정 가능)를 여러 개를 조합할 수 있는 Extension 모델로 대체했다. @ExtendWith({MockitoExtension.class, UserConsoleContextExtension.class})처럼 여러 확장을 동시에 등록할 수 있다.

확장은 BeforeEachCallback, AfterEachCallback, ParameterResolver, BeforeAllCallback 등 JUnit5가 정의한 인터페이스를 구현한 평범한 클래스다. MockitoExtensionBeforeEachCallback + AfterEachCallback + ParameterResolver를 구현하고, UserConsoleContextExtension(4편에 나온 그것)은 AfterEachCallback 하나만 구현한다.

여러 확장이 한 클래스에 등록되면 양파 껍질(wrapping) 구조로 실행된다. JUnit5 User Guide에 따르면,

  • before 계열 콜백@ExtendWith에 선언한 순서 그대로 실행되고,
  • after 계열 콜백역순으로 실행된다.

@ExtendWith({A.class, B.class})A.beforeEach → B.beforeEach → [테스트 본문] → B.afterEach → A.afterEach 순서다. 먼저 감싼 것이 가장 나중에 풀린다. "정리가 설정을 정확히 거꾸로 되짚는다"는 성질이다.

참고: https://docs.junit.org/5.10.3/user-guide/

 

JUnit 5 User Guide

Although the JUnit Jupiter programming model and extension model do not support JUnit 4 features such as Rules and Runners natively, it is not expected that source code maintainers will need to update all of their existing tests, test extensions, and custo

docs.junit.org

함정: 확장 여러 개를 등록할 때 순서를 무심코 바꾸면 정리 순서도 바뀐다. 대부분은 무관하지만, 확장 간 의존성이 생기는 상황이라면 순서를 명시적으로 고려해야 한다.

@Nested: 계층적 테스트 그룹화

@Nested는 내부(non-static) 클래스를 별도의 테스트 그룹으로 취급하게 한다. 컨트롤러 테스트의 GetUserList/GetLoginUser, 서비스 테스트의 CreateProject/Deactivate/GetProjectInfoByAppKey/ValidateActivation 같은 것들이다. 메서드 단위로 관련 테스트를 묶고 @DisplayName과 결합하면 리포트가 "대상 메서드 > 시나리오" 계층으로 읽힌다.

동작 원리는 자바 언어의 inner class 규칙 그대로다. @Nested 클래스는 static이 아닌 inner class여야 한다. JUnit5가 외부 클래스 인스턴스를 먼저 만들고, 그 인스턴스를 감싸 내부 클래스 인스턴스를 생성하기 때문이다. 라이프사이클 기본값(PER_METHOD)에서는 @Test가 실행될 때마다 외부 + 중첩 인스턴스가 매번 새로 만들어진다. 이게 @Mock/@InjectMocks 필드가 테스트 간에 오염되지 않는 이유이기도 하다. 각 테스트가 항상 새 인스턴스에서 시작한다.

ArgumentCaptor: 목에 넘어간 인자를 사후 검증

목 메서드의 리턴값이 아니라, 그 메서드에 전달된 인자 자체를 검증하고 싶을 때 쓴다. save()처럼 리턴값이 별 정보가 없거나(void거나 echo), 저장 직전에 조립된 객체의 필드값을 확인하고 싶을 때 유용하다.

// 컨트롤러 테스트: 서비스에 넘긴 검색 조건을 검증
ArgumentCaptor<UserSearchCondition> captor = ArgumentCaptor.forClass(UserSearchCondition.class);
then(userConsoleService).should().getUserList(captor.capture());
assertThat(captor.getValue())
    .extracting(UserSearchCondition::getRequestUserId, UserSearchCondition::getInstitutionId,
        UserSearchCondition::getProjectId, UserSearchCondition::getName)
    .containsExactly(...);

// 서비스 테스트: 저장 직전 조립된 엔티티를 검증
ArgumentCaptor<ProjectEntity> captor = ArgumentCaptor.forClass(ProjectEntity.class);
then(projectRepository).should().save(captor.capture());
ProjectEntity saved = captor.getValue();
assertThat(saved.getProjectId()).isEqualTo(ProjectFixture.PROJECT_ID);

동작 원리를 보면, capture()는 목이 받은 인자를 그대로 반환하는 게 아니라 매처처럼 동작하면서 호출된 인자를 내부 리스트에 누적한다. then(mock).should().method(captor.capture())가 실행되는 순간, Mockito는 그 메서드가 몇 번 호출됐는지와 매칭 여부를 확인하면서 동시에 캡처한다. getValue()가장 마지막 캡처값을 반환하고, 여러 번이면 getAllValues()로 전체를 볼 수 있다.

이게 왜 중요하냐면, 컨트롤러가 인증 컨텍스트의 값들을 조합해 UserSearchCondition을 만드는 로직처럼, 응답 JSON만 봐선 검증 불가능한 "서비스에 무엇을 넘겼는가"를 확인할 때 필수이기 때문이다. 출력이 아니라 경계를 넘어가는 입력을 본다.

BDDMockito: 가독성 레이어

Mockito.when(...).thenReturn(...) / Mockito.verify(...)의 동의어를 BDD(Behavior-Driven Development) 스타일 문장으로 바꾼 것이다. given(mock.method()).willReturn(value), then(mock).should().method() 형태다.

BDDMockito는 기능적으로 새로운 걸 추가하지 않는다. Mockito의 정적 메서드를 그대로 위임 호출하는 얇은 래퍼다. given(x)는 내부적으로 Mockito.when(x)를, then(mock).should()Mockito.verify(mock)을 그대로 부른다. 순전히 가독성을 위한 네이밍 계층이다.

그럼 왜 쓰냐. 우리 팀 AAA(Given-When-Then) 패턴과 이름이 그대로 대응되기 때문이다. given(...)은 Given 섹션에, then(...).should()는 Then 섹션에 자연스럽게 놓여서 "주석으로 구분한 섹션"과 "코드 표현"이 시각적으로 일치한다.

// given
given(backupRepository.existsByName("nightly")).willReturn(false);
// when
BackupResult result = backupService.create(command);
// then
then(backupRepository).should().save(any(Backup.class));

Strict Stubbing: 죽은 스텁을 실패로 노출시킨다

MockitoExtension은 기본 Strictness.STRICT_STUBS로 동작한다. 테스트 클래스 안에서 given(...)/when(...)으로 스텁을 선언해놓고 정작 실행 중 한 번도 호출되지 않으면, 테스트가 끝나는 시점에 UnnecessaryStubbingException을 던져 실패시킨다.

왜 이렇게 만들었나. Mockito 공식 javadoc은 이렇게 설명한다.

Mockito JUnit Runner triggers UnnecessaryStubbingException only when none of the test methods use the stubbings. [...] In order to keep the codebase clean and maintainable it is necessary to remove unnecessary code.

즉 복붙하다 남은 스텁, 리팩토링 후 안 쓰게 된 given(...) 한 줄이 파일에 그대로 남아 있으면 "이 목이 왜 필요한지" 읽는 사람이 오해한다. Strict Stubbing은 이런 죽은 코드를 컴파일 경고가 아니라 테스트 실패로 강제 노출시켜 청소를 유도하는 장치다.

주의할 점은 "테스트 메서드 단위"가 아니라 "클래스 내 어떤 테스트도 그 스텁을 안 쓰면" 실패한다는 것이다. 여러 @Test@BeforeEach에서 공유하는 스텁이 있다면, 그중 단 하나라도 실제로 호출하면 예외가 나지 않는다.

끄고 싶을 때는 세 가지 방법이 있다.

  1. Mockito.lenient().when(...): 특정 스텁 하나만 완화.
  2. mock(Class.class, withSettings().lenient()): 특정 목 전체를 완화.
  3. @MockitoSettings(strictness = Strictness.LENIENT): 클래스/메서드 단위로 완화.

참고: https://github.com/mockito/mockito/blob/v5.11.0/src/main/java/org/mockito/exceptions/misusing/UnnecessaryStubbingException.java

 

mockito/src/main/java/org/mockito/exceptions/misusing/UnnecessaryStubbingException.java at v5.11.0 · mockito/mockito

Most popular Mocking framework for unit tests written in Java - mockito/mockito

github.com

함정: @BeforeEach에서 여러 테스트가 공통으로 쓸 것 같아 스텁을 여러 개 미리 준비해뒀는데 실제론 일부만 쓰는 경우, Strict Stubbing이 "이 스텁은 어떤 테스트에서도 안 쓰였다"며 실패시킬 수 있다. 이건 버그가 아니라 의도된 설계다. 공용 셋업이 비대해지는 걸 막는 압력으로 이해하는 게 맞다. 우리 레포는 @Mock을 필드로 두고 각 @Test/@Nested 안에서 개별적으로 given(...)을 호출하는 패턴을 쓰기 때문에, 공용 @BeforeEach 스텁으로 인한 충돌 위험이 상대적으로 낮다.

Mermaid로 보는 조합 실행 순서

시리즈를 마치며

여섯 편을 관통하는 하나의 태도가 있다. "이 애노테이션이 여기 왜 붙어 있는가"를 백그라운드 동작까지 내려가 이해하는 것. @Import가 우리 레포에서만 필수인 이유(2편), @MockBean@Mock이 같은 Mockito여도 완전히 다른 세계인 이유(3편), addFilters = false가 보안을 생략하는 게 아니라 관심사를 분리하는 것이라는 점(4편), @ParameterizedTest가 for문과 다른 지점이 실패 격리라는 것(5편). 애노테이션을 복붙하지 않고 "왜"를 아는 순간, 남의 코드를 그대로 가져와 500을 내는 일도, 죽은 스텁을 방치하는 일도 줄어든다. 테스트 코드도 결국 읽는 사람을 위한 코드다.

 

테스트 코드 뜯어보기 - @Mock, @MockBean, @InjectMocks는 같은 목이 아니다

두 테스트 파일을 나란히 놓고 보면 겉보기엔 둘 다 "목을 쓰는 단위 테스트"다. 그런데 하나는 @Mock+@InjectMocks, 다른 하나는 @MockBean을 쓴다. 왜 다를까. 목 객체를 만드는 라이브러리는 같은 Mockito

ramos-log.tistory.com

 

 

테스트 코드 뜯어보기 - @WebMvcTest만 붙였더니 500이 났다

컨트롤러 슬라이스 테스트를 짜다가 만난 황당한 상황. @WebMvcTest를 정석대로 붙였는데 404도 아니고 500이 돌아왔다. 범인은 우리 앱의 scanBasePackages 설정이었고, 해결책은 @Import였다. 이게 왜 우리

ramos-log.tistory.com

 

 

테스트 코드 뜯어보기 - MockMvc는 서블릿 컨테이너를 흉내 내는가

@AutoConfigureMockMvc(addFilters = false). 이 한 줄이 무엇을 끄는지, 그리고 인증 필터를 꺼버린 자리를 왜 ThreadLocal을 직접 세팅해서 메우는지를 정리했다. 여기엔 "MockMvc가 진짜 HTTP를 날리는가"라는 오

ramos-log.tistory.com

 

 

테스트 코드 뜯어보기 - @ParameterizedTest는 for문의 문법 설탕이 아니다

"같은 로직을 값만 바꿔 여러 번 돌린다"는 점만 보면 @ParameterizedTest는 for문과 비슷해 보인다. 그런데 실패가 났을 때 완전히 다르게 행동한다. 이 차이(실패 격리)와, 그걸 가능하게 하는 ArgumentsP

ramos-log.tistory.com