[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.1.6