Junhyunny's Devlogs

글 검색

기술 글 제목, 본문, 카테고리에서 검색합니다.

    이전 화면으로

    Java 정적 메서드 스터빙 방법

    • java
    • spring-boot
    • test-driven-development
    이 글의 목차

    1. 문제 현상

    테스트 코드를 작성하다보면 static 메서드를 스터빙(stubbing)할 필요가 생긴다. 먼저 간단한 예시 코드를 통해 문제점을 살펴보겠다.

    1.1. 구현 코드

    • 4월, 8월, 12월인 경우에는 20% 할인된 가격을 반환하는 메서드를 테스트하고 싶습니다.
    • 오늘 날짜가 4월, 8월, 12월인 경우에는 20% 할인된 가격이 반환된다.
    • 기타 다른 월인 경우에는 기본 가격이 그대로 반환된다.
    package action.in.blog.service;
    
    import java.time.LocalDate;
    import java.time.Month;
    
    import static java.time.Month.*;
    
    public class StaticMethodService {
    
        public int getEventPrice(int price) {
            Month month = LocalDate.now().getMonth();
            if (APRIL.equals(month) || AUGUST.equals(month) || DECEMBER.equals(month)) {
                return (int) (price * 0.8);
            }
            return price;
        }
    }

    1.2. 테스트 코드

    • 해당 테스트 코드는 4월, 8월, 12월인 경우에만 통과한다.
    package action.in.blog.service;
    
    import org.junit.jupiter.api.Test;
    
    import static org.hamcrest.MatcherAssert.assertThat;
    import static org.hamcrest.Matchers.equalTo;
    
    public class StaticMethodServiceTests {
    
        @Test
        void given1000_whenGetEventPrice_thenReturn800() {
    
            StaticMethodService service = new StaticMethodService();
    
            assertThat(service.getEventPrice(1000), equalTo(800));
        }
    }

    2. 문제 해결

    위의 테스트 코드는 특정 월에만 통과한다. 이벤트 기간마다 테스트를 통과시키기 위해 테스트 코드를 수정할 수는 없다. 이 경우 LocalDate 클래스의 now 메서드를 특정 날짜로 스터빙할 필요가 있다. now 메서드는 static 메서드이기 때문에 일반적인 방법으로 스터빙이 불가능하다.

    static 메서드 잘못된 스터빙
        Mockito.when(LocalDate.now()).thenReturn(onApril);
    에러 메시지
    • 위 코드를 실행시키면 mock 객체의 메서드를 when 메서드에 전달하라는 에러 메시지를 받습니다.
    • LocalDate 클래스 자체는 mock 객체가 될 수 없다.
    org.mockito.exceptions.misusing.MissingMethodInvocationException: 
    when() requires an argument which has to be 'a method call on a mock'.
    For example:
        when(mock.getArticles()).thenReturn(articles);
    
    Also, this error might show up because:
    1. you stub either of: final/private/equals()/hashCode() methods.
       Those methods *cannot* be stubbed/verified.
       Mocking methods declared on non-public parent classes is not supported.
    2. inside when() you don't call method on mock but on some other object.

    2.1. mockito-inline 의존성 사용하기

    Mockito 클래스를 보면 static 메서드 테스트와 연관있어 보이는 mockStatic 메서드가 존재한다. 해당 메서드를 실행시키면 다음과 같은 에러가 발생한다.

    mockStatic 메서드 호출 시 에러 메시지
    • mockito-inline 아티팩트(artifact)로 변경하면 테스트가 가능할 것 같다.
    org.mockito.exceptions.base.MockitoException: 
    The used MockMaker SubclassByteBuddyMockMaker does not support the creation of static mocks
    
    Mockito's inline mock maker supports static mocks based on the Instrumentation API.
    You can simply enable this mock mode, by placing the 'mockito-inline' artifact where you are currently using 'mockito-core'.
    Note that Mockito's inline mock maker is not supported on Android.
    의존성 추가하기 - pom.xml
    • 아래 의존성을 pom.xml 파일에 추가한다.
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-inline</artifactId>
        <version>4.4.0</version>
        <scope>test</scope>
    </dependency>

    2.2. 테스트 코드

    • mockito-inline 의존성을 추가했다면 다음과 같이 테스트 코드를 변경한다.
    • LocalDate 클래스를 static mock으로 대체하기 전에 4월 1일 날짜를 미리 만든다.
      • Mockito.mockStatic(LocalDate.class) 실행 이후 LocalDate 클래스의 static 메서드 호출 시 에러 발생
    • MockedStatic 인스턴스를 이용해 LocalDate 클래스 now 메서드를 스터빙한다.
    package action.in.blog.service;
    
    import org.junit.jupiter.api.Test;
    import org.mockito.MockedStatic;
    import org.mockito.Mockito;
    
    import java.time.LocalDate;
    
    import static org.hamcrest.MatcherAssert.assertThat;
    import static org.hamcrest.Matchers.equalTo;
    
    public class StaticMethodServiceTests {
    
        @Test
        void given1000AndAprilDate_whenGetEventPrice_thenReturn800() {
    
            LocalDate onApril = LocalDate.of(2022, 4, 1);
            MockedStatic<LocalDate> mockLocalDate = Mockito.mockStatic(LocalDate.class);
            mockLocalDate.when(LocalDate::now).thenReturn(onApril);
    
            StaticMethodService service = new StaticMethodService();
    
            assertThat(service.getEventPrice(1000), equalTo(800));
        }
    }
    테스트 통과

    3. static method stubbing 주의 사항

    static 메서드를 스터빙하면 다른 테스트 코드에서 문제가 발생한다. 인스턴스를 mock으로 만든 것이 아니기 때문에 일회성으로 사용되는 것이 아니라 다음 테스트들까지 영향을 미친다. 8월에도 가격이 정상적으로 할인되는지 테스트 코드를 하나 추가해보았다.

    3.1. 8월 이벤트 할인 금액 확인 테스트 코드 추가

    package action.in.blog.service;
    
    import org.junit.jupiter.api.Test;
    import org.mockito.MockedStatic;
    import org.mockito.Mockito;
    
    import java.time.LocalDate;
    
    import static org.hamcrest.MatcherAssert.assertThat;
    import static org.hamcrest.Matchers.equalTo;
    
    public class StaticMethodServiceTests {
    
        @Test
        void given1000AndAprilDate_whenGetEventPrice_thenReturn800() {
    
            LocalDate onApril = LocalDate.of(2022, 4, 1);
            MockedStatic<LocalDate> mockLocalDate = Mockito.mockStatic(LocalDate.class);
            mockLocalDate.when(LocalDate::now).thenReturn(onApril);
    
            StaticMethodService service = new StaticMethodService();
    
            assertThat(service.getEventPrice(1000), equalTo(800));
        }
    
        @Test
        void given1000AndAugustDate_whenGetEventPrice_thenReturn800() {
    
            LocalDate onAugust = LocalDate.of(2022, 8, 1);
            MockedStatic<LocalDate> mockLocalDate = Mockito.mockStatic(LocalDate.class);
            mockLocalDate.when(LocalDate::now).thenReturn(onAugust);
    
            StaticMethodService service = new StaticMethodService();
    
            assertThat(service.getEventPrice(1000), equalTo(800));
        }
    }
    테스트 실패
    에러 메시지
    org.mockito.exceptions.base.MockitoException: 
    For java.time.LocalDate, static mocking is already registered in the current thread
    
    To create a new mock, the existing static mock registration must be deregistered
    
        at action.in.blog.service.StaticMethodServiceTests.given1000AndAprilDate_whenGetEventPrice_thenReturn800(StaticMethodServiceTests.java:18)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        ...

    3.2. 테스트 충돌 문제 해결

    • 테스트마다 사용한 클래스 static mock을 해제한다.
    package action.in.blog.service;
    
    import org.junit.jupiter.api.Test;
    import org.mockito.MockedStatic;
    import org.mockito.Mockito;
    
    import java.time.LocalDate;
    
    import static org.hamcrest.MatcherAssert.assertThat;
    import static org.hamcrest.Matchers.equalTo;
    
    public class StaticMethodServiceTests {
    
        @Test
        void given1000AndAprilDate_whenGetEventPrice_thenReturn800() {
    
            LocalDate onApril = LocalDate.of(2022, 4, 1);
            MockedStatic<LocalDate> mockLocalDate = Mockito.mockStatic(LocalDate.class);
            mockLocalDate.when(LocalDate::now).thenReturn(onApril);
    
            StaticMethodService service = new StaticMethodService();
    
            assertThat(service.getEventPrice(1000), equalTo(800));
    
            mockLocalDate.close();
        }
    
        @Test
        void given1000AndAugustDate_whenGetEventPrice_thenReturn800() {
    
            LocalDate onAugust = LocalDate.of(2022, 8, 1);
            MockedStatic<LocalDate> mockLocalDate = Mockito.mockStatic(LocalDate.class);
            mockLocalDate.when(LocalDate::now).thenReturn(onAugust);
    
            StaticMethodService service = new StaticMethodService();
    
            assertThat(service.getEventPrice(1000), equalTo(800));
    
            mockLocalDate.close();
        }
    }
    테스트 성공

    3.3. 테스트 코드 정리

    • 테스트 코드를 정리하였다.
    • 필요한 날짜들은 미리 만든다.
    • 테스트 실행 전 LocalDate 클래스를 static mock으로 만든다.
    • 테스트 코드마다 필요한 결과 값을 스터빙한다.
    • 테스트 종료 후 사용한 static mock을 해제한다.
    package action.in.blog.service;
    
    import org.junit.jupiter.api.AfterEach;
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;
    import org.mockito.MockedStatic;
    import org.mockito.Mockito;
    
    import java.time.LocalDate;
    
    import static org.hamcrest.MatcherAssert.assertThat;
    import static org.hamcrest.Matchers.equalTo;
    
    public class StaticMethodServiceTests {
    
        LocalDate onApril = LocalDate.of(2022, 4, 1);
        LocalDate onAugust = LocalDate.of(2022, 8, 1);
    
        MockedStatic<LocalDate> mockLocalDate;
    
        StaticMethodService service;
    
        @BeforeEach
        void setUp() {
            mockLocalDate = Mockito.mockStatic(LocalDate.class);
            service = new StaticMethodService();
        }
    
        @AfterEach
        void afterEach() {
            mockLocalDate.close();
        }
    
        @Test
        void given1000AndAprilDate_whenGetEventPrice_thenReturn800() {
    
            mockLocalDate.when(LocalDate::now).thenReturn(onApril);
    
            assertThat(service.getEventPrice(1000), equalTo(800));
        }
    
        @Test
        void given1000AndAugustDate_whenGetEventPrice_thenReturn800() {
    
            mockLocalDate.when(LocalDate::now).thenReturn(onAugust);
    
            assertThat(service.getEventPrice(1000), equalTo(800));
        }
    }

    KEEP READING

    DISCUSSION

    댓글