[Java/SpringBoot] JUnit5 Mockito로 단위 테스트 작성하기

Java17 / SpringBoot 3.1.2 / JUnit5 / Mockito 5.8.0

회사 프로젝트에서 꾸준히 Mockito를 사용해 코어 비즈니스 클래스에 대해 단위 테스트를 작성하고 있다.

1. Mockito란?

Mockito는 Java 생태계에서 가장 널리 사용되는 모킹(mocking) 프레임워크로, 단위 테스트에서 외부 의존성을 제거하고 테스트 대상 코드에 집중할 수 있게 도와준다. 이 점에서 내 코드가 얼마나 객체지향적인지를 톡톡히 느꼈다. 이런 점 때문에 외부 API 호출, 데이터베이스 접근, 또는 복잡한 비즈니스 로직을 포함하는 클래스를 테스트할 때 유용하다.

2. Mockito 설정 및 어노테이션

테스트 클래스 설정 시 @ExtendWith(MockitoExtension.class) 어노테이션을 사용하여 Mockito 확장을 활성화할 수 있다. 이를 통해 @Mock, @InjectMocks, @Spy와 같은 Mockito의 주요 어노테이션을 사용할 수 있는데, 각 어노테이션의 기능은 다음과 같다:

  • @Mock: 모의 객체를 생성하며, 이 객체의 메소드 호출은 기본적으로 아무 동작도 하지 않거나 null을 반환한다.
  • @InjectMocks: 테스트 대상 클래스의 인스턴스를 생성하고, @Mock으로 생성된 객체들을 자동으로 주입해준다.
  • @Spy: 실제 객체를 부분적으로 모의화하며, 명시적으로 스텁되지 않은 메소드는 실제 메소드를 호출한다.
@ExtendWith(MockitoExtension.class)
class SomeServiceTest {
    @Mock private DependencyService dependencyService;
    @InjectMocks private SomeService someService;
    // ...
}

3. AAA 패턴을 활용한 테스트 작성

GWT(Given-When-Then) 패턴만 알고 있었는데, 동기가 소개해준 AAA를 사용 중이어서 소개한다.

AAA(Arrange-Act-Assert) 패턴은 테스트 코드를 구조화하는 방법 중 하나로, 테스트의 가독성을 높이고 의도를 명확하게 전달할 수 있게 해준다. 각 단계에 주석을 달아 코드의 이해도를 높일 수 있다.

@Test
void testSomeMethod() {
    // Arrange: 테스트에 필요한 데이터와 객체를 준비
    final String input = "testInput";
    final String expectedResult = "expectedOutput";
    when(dependencyService.process(anyString())).thenReturn(expectedResult);

    // Act: 테스트 대상 메소드를 호출
    final String result = someService.someMethod(input);

    // Assert: 결과 검증
    assertAll(
        () -> assertNotNull(result, "결과는 null이 아니어야 한다."),
        () -> assertEquals(expectedResult, result, "예상 결과와 일치해야 한다.")
    );
    verify(dependencyService).process(eq(input));
}

4. Mockito의 주요 메소드와 사용법

Mockito는 다양한 기능을 제공해 복잡한 테스트 시나리오를 처리할 수 있다. 사용했던 몇 가지 주요 메소드를 소개한다:

  • when().thenReturn(): 모의 객체의 메소드 호출에 대한 반환값을 정의한다.
  • when().thenThrow(): 모의 객체의 메소드 호출 시 예외를 발생시킨다.
  • doNothing().when(): void 메소드에 대해 아무 동작도 하지 않도록 설정한다.
  • verify(): 특정 메소드가 호출되었는지, 몇 번 호출되었는지 확인한다.

추가적인 유용한 메소드들은 다음과 같다:

// 메소드가 특정 횟수만큼 호출되었는지 확인
verify(dependencyService, times(2)).someMethod();

// 메소드가 호출되지 않았는지 확인
verify(dependencyService, never()).someMethod();

// 모의 객체의 모든 스터빙(stubbing)을 확인
verifyNoMoreInteractions(dependencyService);

// 인자 캡처를 활용해 메소드 호출 시 전달된 값을 검증
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
verify(dependencyService).someMethod(captor.capture());
assertEquals("expectedArg", captor.getValue());

내 경우에는 컨벤션으로 특정 메서드의 반환값이 없었다. 따라서 이런 경우에는 필드 매치 등을 진행할 수 없어, 메서드들의 호출 횟수를 통해 간접적으로 검증했다.

5. 테스트를 통한 코드 품질 평가

테스트를 작성하다 보면, 코드의 설계가 얼마나 잘 되어 있는지 자연스럽게 드러나는 경우가 많았다. 예를 들어, 의존성 주입(Dependency Injection)이 잘 설계된 클래스는 @InjectMocks 어노테이션을 사용해 쉽게 모의(Mock) 객체를 주입할 수 있다. 즉, 클래스가 외부 의존성을 갖는 객체들을 생성자나 세터를 통해 주입받도록 설계되어 있다는 뜻이다.

아래처럼 의존성 주입이 잘 된 클래스가 있다고 가정해보자:

public class SomeService {
    private final DependencyService dependencyService;

    public SomeService(DependencyService dependencyService) {
        this.dependencyService = dependencyService;
    }

    public String someMethod(String input) {
        return dependencyService.process(input);
    }
}

이 클래스는 DependencyService라는 외부 의존성을 생성자를 통해 주입받는다. 이처럼 의존성을 주입받는 방식으로 설계된 클래스는 단위 테스트에서 매우 쉽게 모의(Mock) 객체를 사용할 수 있다. 예를 들어, 아래와 같이 테스트를 작성할 수 있다:

@ExtendWith(MockitoExtension.class)
class SomeServiceTest {
    @Mock
    private DependencyService dependencyService;

    @InjectMocks
    private SomeService someService;

    @Test
    void testSomeMethod() {
        // Arrange
        final String input = "testInput";
        final String expectedResult = "expectedOutput";
        when(dependencyService.process(input)).thenReturn(expectedResult);

        // Act
        String result = someService.someMethod(input);

        // Assert
        assertEquals(expectedResult, result);
    }
}

이 코드에서 @InjectMocks 어노테이션을 사용해 SomeService 인스턴스를 생성하면서, @Mock으로 생성된 DependencyService 객체가 자동으로 주입된다. 이를 통해 테스트를 쉽게 설정할 수 있는 것이다.

만약 SomeService 클래스가 의존성을 직접 생성하거나 강하게 결합되어 있었다면, 이를 테스트하기 위해 더 복잡한 작업을 해야 했을 것이다. 예를 들어, 리플렉션(reflection)을 사용해야 하거나, 테스트를 위해 코드를 수정해야 했을지도 모른다. (실제로 리플렉션을 적용하려고 하다가 배보다 배꼽이 더 큰 사태가 벌어져서 후딱 정신차리고 테스트 대상이었던 클래스와 의존한 클래스의 결합도를 줄이도록 리팩터링했다.)

따라서, 테스트 작성이 쉽다는 것은 해당 클래스의 설계가 잘 되어 있음을 의미한다고 느꼈다. 즉, 내 코드가 얼마나 “객체지향적인지” 제대로 체감하게 해주는 지표였다.

또한, 기획 수정이 잦아 새로운 기능을 추가하거나 기존 동작을 변경할 일이 많았는데, 기존 테스트 코드를 크게 수정하지 않고도 새로운 테스트 케이스를 쉽게 추가할 수 있는 경우도, 아닌 경우도 있었다. 이를 통해 코드가 변경에 유연하게 대응할 수 있도록 잘 설계되어 있는지, 개선할 수는 없는지 리팩터링 포인트로 삼았다. 추가로 이런 포인트를 협업자의 코드를 리뷰 할 때 캐치할 수 있었다.

@Test
void testNewBusinessRule() {
    // Arrange
    when(dependencyService.getLatestPolicy()).thenReturn(new Policy("NEW_RULE"));

    // Act
    Result result = someService.processWithLatestPolicy(input);

    // Assert
    assertThat(result).satisfiesNewRule();
}

결론

솔직히 테스트 코드를 작성하는게 아직도 마냥 재밌지는 않다. 이유는 테스트를 작성하며 느끼는 내 코드의 무수히 많은 문제점… 때문이지만 실력 상승과 롱런하는 좋은 코드를 만드는데는 최고인 것 같다.


© 2022. All rights reserved.

Powered by Hydejack v9.2.1