Mockito 테스트 (2/2)

이전 편: [테스트] 1. Mockito 테스트 기초

이 문서가 시리즈의 마지막 편입니다.

Mockito 테스트 기초에서 Mock을 만들고 주입하는 법을 배웠다. 이제 Mock이 어떤 값을 돌려줄지 세밀하게 제어하는 방법을 다룬다. when().thenReturn()만으로 충분할 때도 있지만, 입력에 따라 반환값이 달라져야 하는 순간이 온다.

thenReturn — 고정값 반환

가장 기본적인 stubbing이다. 미리 만들어둔 객체를 항상 돌려준다.

User user = Instancio.create(User.class);
when(userRepository.findById(any(UUID.class)))
        .thenReturn(Optional.of(user));

findById가 어떤 UUID를 받든, 항상 같은 user를 반환한다. 대부분의 테스트에서는 이걸로 충분하다.

thenAnswer — 호출 시점에 결정

thenAnswer는 Mock 메서드가 호출되는 그 순간에 로직을 실행해서 반환값을 만든다.

when(feedRepository.save(any(Feed.class)))
        .thenAnswer(invocation -> invocation.getArgument(0));

save에 넘어온 인자를 그대로 돌려주라는 뜻이다.

왜 thenReturn으로 안 되는가

feedRepository.save(feed)에서 feed는 Service 내부에서 생성된다.

// Service 내부
Feed feed = Feed.create(author, weather, content);
Feed saved = feedRepository.save(feed);

테스트 코드에서는 이 feed 객체를 미리 알 수 없다. Service가 만들어서 넘기는 거니까.

// ❌ 이건 Service 내부의 feed와 전혀 다른 별개의 객체
Feed fakeFeed = Instancio.create(Feed.class);
when(feedRepository.save(any())).thenReturn(fakeFeed);

thenReturn(fakeFeed)를 쓰면, Service가 만든 feed의 content와 fakeFeed의 content가 다르다. 이후 검증에서 content가 일치하지 않을 수 있다.

thenAnswer로 "들어온 걸 그대로 돌려줘"라고 하면, 실제 JPA의 save 동작과 가장 가깝게 흉내 낼 수 있다.

언제 뭘 쓰는가

  • 반환값이 입력과 무관thenReturn
  • 반환값이 입력에 의존thenAnswer

repository.save()를 stubbing할 때는 thenAnswer가 관례처럼 쓰인다. 실제 JPA도 save에 넘긴 엔티티를 (영속화해서) 돌려주기 때문이다.

InvocationOnMock — 컨텍스트 객체

thenAnswer의 람다에 넘어오는 invocationInvocationOnMock 타입이다. Mock 메서드가 호출된 그 순간의 정보를 담고 있는 객체다.

이런 객체를 컨텍스트 객체라고 부른다.

컨텍스트 객체란

특정 시점의 상황 정보를 묶어서 전달하는 객체를 말한다. "지금 무슨 일이 일어났는지"를 알려주는 봉투 같은 것이다.

Java에서는 자주 등장하는 패턴이다.

  • HttpServletRequest — HTTP 요청이 들어온 순간의 정보 (URL, 헤더, 파라미터)
  • Authentication — 인증이 완료된 시점의 사용자 정보
  • InvocationOnMock — Mock 메서드가 호출된 시점의 호출 정보

공통점이 보인다. 전부 "어떤 이벤트가 발생한 시점"에 만들어져서, 그 시점의 정보를 꺼내 쓸 수 있게 해준다.

주요 메서드

InvocationOnMock에서 실제로 쓰는 건 거의 getArgument()뿐이다.

.thenAnswer(invocation -> {
    Feed feed = invocation.getArgument(0);    // 첫 번째 인자
    String name = invocation.getArgument(1);  // 두 번째 인자 (있다면)
    return feed;
});

인덱스는 0부터 시작한다. save(Feed feed)처럼 인자가 하나면 getArgument(0)이다.

그 외에도 호출 정보를 제공하지만, 테스트에서 쓸 일은 거의 없다.

  • getMethod() — 호출된 메서드의 Method 객체
  • getArguments() — 모든 인자를 Object[]로 반환
  • getMock() — Mock 객체 자체

활용 예시

save 시 ID를 세팅해서 반환하면 실제 DB 동작을 더 가깝게 흉내 낼 수 있다.

when(feedRepository.save(any(Feed.class)))
        .thenAnswer(invocation -> {
            Feed feed = invocation.getArgument(0);
            ReflectionTestUtils.setField(feed, "id", UUID.randomUUID());
            return feed;
        });

ReflectionTestUtils는 Spring Test가 제공하는 유틸이다. 리플렉션으로 private 필드에 값을 넣는 건 테스트에서만 써야 한다.

Instancio로 테스트 데이터 생성

테스트에 필요한 객체를 일일이 new로 만드는 건 번거롭다. Instancio는 클래스 구조를 분석해서 랜덤 값으로 채운 인스턴스를 자동 생성해준다.

FeedCreateRequestDto request = Instancio.create(FeedCreateRequestDto.class);

이 한 줄로 userId(랜덤 UUID), weatherId(랜덤 UUID), clothesIds(랜덤 UUID 0~6개의 List), content(랜덤 문자열)가 전부 채워진 객체가 만들어진다.

컬렉션 크기 제어

기본적으로 List는 0~6개 사이의 랜덤 크기로 생성된다. 고정하고 싶으면 generate를 쓴다.

Instancio.of(FeedCreateRequestDto.class)
        .generate(all(List.class), gen -> gen.collection().size(3))
        .create();

특정 필드만 지정

특정 필드의 값을 직접 정하고 싶을 때는 set을 쓴다.

Instancio.of(FeedCreateRequestDto.class)
        .set(field(FeedCreateRequestDto::content), "오늘 날씨 좋다")
        .create();

Instancio의 핵심 가치는 "테스트에서 중요하지 않은 값은 랜덤으로 채우고, 중요한 값만 명시적으로 지정한다"는 것이다. 테스트의 의도가 더 명확해진다.

자주 하는 실수

thenReturn에 null이 될 수 있는 객체를 넣는다

Mock의 기본 반환값은 이미 null이다. thenReturn(null)은 stubbing을 안 한 것과 같다. 의도적으로 null을 테스트하고 싶다면, 차라리 stubbing을 생략하고 주석으로 의도를 남겨라.

[!DANGER] save() stubbing 없이 반환값을 사용한다

feedRepository.save()의 반환값을 이후 로직에서 쓰는데 stubbing이 없으면 null이 반환된다. NPE가 터지면 "테스트가 맞게 짜인 건가?"를 의심하기 전에, save mock 설정부터 확인하라.