(JAVA) Spring 에서 서비스 내에서 static 메소드 Mocking 하기

(JAVA) Spring 에서 서비스 내에서 static 메소드 Mocking 하기

June 15, 2024

일반적으로 Spring은 Layered Architecture 를 따르고 있으며, 서비스에서 주로 비즈니스 로직을 처리하게 된다.

이 때 종종 static 메소드를 사용하는 경우가 있는데, 일반적인 컴포넌트의 경우 런타임 시점에 동적으로 바인딩되기 때문에 Mocking 시점에 해당 인터페이스를 구현한 객체를 Mocking 객체로 대체하여 테스트를 진행할 수 있다. static 메서드의 경우, 컴파일 시점에 메서드가 정적으로 바인딩되기 때문에 Mocking 이 어렵다.

그럼 어떻게 해야할까?

1. Wrapper 클래스

전통적인 방식으로는 Static 메서드를 컴포넌트화 할 수 있다.

static 메서드를 일반 메서드 내에서 활용해서 한번 Wrapper 클래스로 감싸는 것이다.

예시

예를 들어 다음과 같은 코드가 있다고 가정해보자.

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;
    private final EventNumberPicker eventNumberPicker;

    public List<ProductDto> listProducts() {
        return productRepository
            .findAll()
            .stream()
            .map(product -> new ProductDto(product, eventNumberPicker.pick(1, 1000)))
            .toList();
    }
}

Product Entity는 다음과 같이 구성된다.

/**
 * 상품 DTO 상품 정보를 전달하기 위한 DTO
 */
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@EqualsAndHashCode
@ToString
public class ProductDto {

    /**
     * 상품 ID
     */
    private Long id;

    /**
     * 상품 이름
     */
    private String name;

    /**
     * 상품 설명
     */
    private String description;

    /**
     * 상품 가격
     */
    private double price;

    /**
     * 추첨 번호
     */
    private int eventNumber;

    public ProductDto(Product product, int eventNumber) {
        this.id = product.getId();
        this.name = product.getName();
        this.description = product.getDescription();
        this.eventNumber = eventNumber;
        this.price = product.getPrice();
    }
}

생성자에서는 추첨번호인 eventNumber를 인자로 받아 상품 엔티티에 eventNumber를 설정해주는데, 이 때 eventNumberPicker.pick(1, 1000) 메서드를 사용하고 있다.

이 때 EventNumberPicker는 Math 클래스를 Wrapping 한 클래스로 다음과 같이 구성되어 있다.

package com.example.demo.product.util;

@Component
public class EventNumberPicker {
    private EventNumberPicker() {
    }

    public int pick(int from, int to) {
        return from + (int) (Math.random() * ((to - from) + 1));
    }
}

이렇게 구성하면 Math 클래스의 random 메서드를 사용한다 하더라도 비즈니스 로직에서 사용하는 것은 EventNumberPicker 클래스이기 때문에 테스트 코드에서 EventNumberPicker를 Mocking 하여 테스트를 진행할 수 있다.

단점

하지만 이 방법은 static 메서드를 사용하는 모든 클래스에 Wrapper 클래스를 만들어야 하기 때문에 번거롭다.

사실상 단순히 LocalDateTime.now()를 사용하는 경우에도 Wrapper 클래스를 만들어야 하기 때문이다.

Python 의 경우 Monkey Patching 을 통해 static 메서드의 Mocking 을 해결하는 경우가 있었고, Golang 의 경우는 arm 아키텍처에서 Monkey Patching 이 잘 안되는 이슈가 있어서, 위의 경우처럼 Wrapper 클래스를 만들어서 사용하는 경우가 좀 더 흔했다. (사담: 물론 틀렸을 수도 있다. 다만 내가 Golang 을 활용해서 일할 때는 어떤 Mocking 라이브러리도 static 메서드를 Mocking 하는 것을 지원하지 않았다.)

2. Mockito

Mockito 는 Java 에서 가장 많이 사용되는 Mocking 라이브러리 중 하나이다.

이전에는 되지 않았지만 버전 3.4.0 부터 static 메서드 Mocking 을 지원한다.

예시

예시에서는 먼저 앞선 EventNumberPicker클래스의 pick 메서드가 static 인 것으로 가정하고 진행한다.

package com.example.demo.product.util;

import java.util.Random;

public class EventNumberPicker {

    private static final Random rand = new Random();

    private EventNumberPicker() {
        throw new IllegalStateException("Utility class");
    }

    public static int pick(int from, int to) {

        rand.setSeed(System.currentTimeMillis());

        return rand.nextInt(to - from + 1) + from;
    }
}

그리고 사용하는 ProductService 클래스에서도 역시 EventNumberPicker를 주입받지 않고, pick 메서드를 사용한다.

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    public List<ProductDto> listProducts() {
        return productRepository
            .findAll()
            .stream()
            .map(product -> new ProductDto(product, EventNumberPicker.pick(1, 1000)))
            .toList();
    }
}

테스트 코드 작성

먼저 gradle 에 Mockito 의존성을 추가한다.

    // https://mvnrepository.com/artifact/org.mockito/mockito-core
    testImplementation 'org.mockito:mockito-core:5.12.0'

이후 테스트 코드를 다음과 같이 작성할 수 있다.

package com.example.demo.product.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mockStatic;
import com.example.demo.product.domain.dto.ProductDto;
import com.example.demo.product.domain.model.Product;
import com.example.demo.product.repository.ProductRepository;
import com.example.demo.product.util.EventNumberPicker;
import java.util.List;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class ProductServiceTest {


    @Mock
    ProductRepository productRepository;

    @InjectMocks
    ProductService productService;

    @Nested
    class ListProducts {


        @Test
        void 성공() {
            // given
            var products = List.of(
                Product.builder()
                    .id(1L)
                    .name("test_name_1")
                    .description("test_description_1")
                    .build(),
                Product.builder()
                    .id(2L)
                    .name("test_name_2")
                    .description("test_description_2")
                    .build()
            );
            given(
                productRepository.findAll()
            ).willReturn(products);

            var eventNumberPicker = mockStatic(EventNumberPicker.class);

            eventNumberPicker.when(() -> EventNumberPicker.pick(1, 1000))
                .thenReturn(1);

            // when
            var result = productService.listProducts();

            // then
            assertThat(result).isEqualTo(List.of(
                    ProductDto.builder()
                        .id(1L)
                        .name("test_name_1")
                        .description("test_description_1")
                        .eventNumber(1)
                        .build(),
                    ProductDto.builder()
                        .id(2L)
                        .name("test_name_2")
                        .description("test_description_2")
                        .eventNumber(1)
                        .build()
                )
            );

            eventNumberPicker.close();
        }
    }
}

먼저 다른 테스트 코드는 동일하게 멤버변수로 Mock 인스턴스를 주입받고, @InjectMocks 어노테이션을 통해 테스트 대상 클래스의 인스턴스를 주입받는다. MockStatic 역시 그런식으로 설정할 수는 있으나, 필자의 생각에는 자주 일어나는 일이 아니라서 혼동을 줄 수 있으므로, 테스트 코드 내에서만 활용하였다.

먼저 EventNumberPickerMockStatic 인스턴스를 생성한다.

eventNumberPicker = mockStatic(EventNumberPicker.class);

그리고 when 메서드를 통해 pick 메서드가 호출될 때 반환할 값을 지정한다.

eventNumberPicker.when(() -> EventNumberPicker.pick(1, 1000))
                .thenReturn(1);

이렇게 하면 EventNumberPicker.pick(1, 1000) 메서드가 호출될 때 1을 반환하게 된다.

두 번째 호출, 세 번째 호출도 각각 다른 값을 반환하게 하고 싶다면 thenReturn() 의 파라미터로 각각 다른 값을 넣어주면 된다.

eventNumberPicker.when(() -> EventNumberPicker.pick(1, 1000))
                .thenReturn(1,2)

마지막으로 테스트가 끝나면 close 메서드를 호출하여 MockStatic 인스턴스를 닫아준다.

eventNumberPicker.close();

이렇게 하면 static 메서드라도 Mocking 할 수 있으며, 테스트 코드를 작성할 수 있다.

image

void의 경우는?

만약 void의 경우에 파일에 쓴다거나, 외부 API를 호출하는 경우가 있으면 해당 행동을 하지 않고 아무것도 하지 않도록 설정이 필요할 수 있다.

이 경우 단순히 MockStatic 인스턴스를 만들어 주는 과정 만으로도 실제 행동을 하지 않게 된다.

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    public List<ProductDto> listProducts() {
        EventNumberPicker.doSomething();

        return productRepository
            .findAll()
            .stream()
            .map(product -> new ProductDto(product, EventNumberPicker.pick(1, 1000)))
            .toList();
    }
}
public class EventNumberPicker {

    private static final Random rand = new Random();

    private EventNumberPicker() {
        throw new IllegalStateException("Utility class");
    }

    public static int pick(int from, int to) {

        rand.setSeed(System.currentTimeMillis());

        return rand.nextInt(to - from + 1) + from;
    }

    public static void doSomething() {
        System.out.println("doSomething");
    }
}

테스트 코드는 이전과 같이 doSomething() 메서드를 굳이 when()을 통해 지정하지 않아도 실제 테스트 시에는 “doSomething” 이 출력되지 않는다.

image

doSomething 이 출력되지 않는 것을 확인할 수 있다.

파라미터 값까지 검증하기

이것 외에도 흔히 알려진 verify() 메서드를 이용하면 호출 여부 뿐 아니라 파라미터 값까지 검증할 수 있다.

EventNumberPicker.java
public static void doSomething(int from, int to) {
    System.out.println("doSomething" + from + to);
}

우선 위와 같이 파라미터를 받도록 메서드를 수정하고, 테스트 코드에서는 아래와 같이 작성한다.

// ...
eventNumberPicker.verify(
() -> EventNumberPicker.doSomething(1, 900));

일반적인 verify() 메서드와 다른 점은 Mockito.verify()는 Mockito 의 static 메서드이나, 해당 메서드는 mockStatic() 메서드를 통해 생성한 MockStatic 인스턴스에서 사용하여야 한다는 점이다.

image

파라미터가 기대한 경우와 다른 경우 에러가 나는 모습을 확인할 수 있다.

특정 메서드를 Mocking을, 다른 메서드는 실제 메서드를 사용하고 싶다면?

이 경우 when()을 이용해 Mocking 하되, thenCallRealMethod()를 사용하면 된다.

eventNumberPicker.when(() -> EventNumberPicker.doSomething())
                .thenCallRealMethod();

image

doSomething 이 출력되는 것을 확인할 수 있다.