Mockito 프레임워크의 핵심 원리: 단위 테스트 심층 분석

우수한 단위 테스트는 코드 구조 설계의 타당성을 검증할 뿐만 아니라, 코드 내의 잠재적 문제를 사전에 발견하여 운영 환경에서의 위험을 사전에 방지할 수 있습니다. 본문에서는 주요 단위 테스트 프레임워크를 기반으로 Mockito 프레임워크를 심도 있게 분석하여 모든 개발자에게 도움이 되기를 바랍니다.

단위 테스트 프레임워크

Java에는 다양한 단위 테스트 프레임워크가 존재하며, 각 프레임워크는 고유한 특징을 가지고 있습니다. 다양한 요구사항과 팀 요구사항에 따라 개발자들이 사용하는 프레임워크는 다를 수 있으며, 현재 주류를 이루는 테스트 프레임워크는 다음과 같습니다:

JUnit

JUnit은 Java에서 가장 널리 사용되는 단위 테스트 프레임워크입니다. 이 프레임워크는 assertNull, assertTrue, assertEquals와 같은 풍부한 테스트 및 어설션 메서드를 제공하며, 사용 방법이 비교적 간단합니다. 현재 JUnit5 버전으로 업데이트되었으며, 동적 테스트, 의존성 주입 등 더 많은 새로운 기능을 제공하여 프레임워크를 더욱 강력하게 만들었습니다.

TestNG

TestNG는 Java의 또 다른 테스트 프레임워크로, JUnit의 영감을 받아 설계되었습니다. 이 프레임워크는 단위 테스트부터 통합 테스트까지의 다양한 측면을 더 잘 지원하여 JUnit으로는 수행할 수 없는 일부 테스트 작업을 완성하는 것을 목표로 합니다. JUnit에 비해 기능이 더 강력하며 테스트 스위트, 데이터 기반 테스트, 의존성 테스트, 병렬 테스트 등 더 많은 고급 기능을 제공합니다. 매개변수화 테스트, 의존성 테스트 등과 같은 더 복잡한 테스트 시나리오에서 TestNG의 성능이 더 우수합니다.

Spock

Spock은 Groovy 언어로 작성된 테스트 프레임워크로, Java와 Groovy 코드 프로그램을 테스트하는 데 사용할 수 있습니다. Spock은 테스트 코드를 작성하는 언어가 매우 우아하고 표현력이 높아 테스트 코드의 가독성과 유지보수성을 크게 향상시킵니다. Spock 프레임워크는 JUnit, jMock, RSpec, Groovy, Scala 및 Vulcans 등 다양한 프레임워크와 언어의 장점을 융합하여 강력한 테스트 플랫폼을 제공하도록 설계되었습니다.

Mockito

Mockito는 완전한 단위 테스트 프레임워크가 아니라 mock 객체의 생성 및 검증에 특화되어 있습니다. 일반적으로 JUnit 또는 TestNG와 함께 사용하여 복잡한 의존성 테스트를 단순화합니다. 현재 가장 널리 사용되는 테스트 프레임워크 중 하나이며, 본문에서는 이 프레임워크를 자세히 설명합니다.

EasyMock

EasyMock은 주어진 인터페이스에 대해 간단한 방법으로 mock 객체를 생성하는 클래스 라이브러리 세트로, Java 프록시 메커니즘을 사용하여 동적으로 모의 객체를 생성합니다. 이 프레임워크는 인터페이스의 시뮬레이션을 제공하며, 녹화, 재생, 확인의 세 단계를 통해 대략적인 테스트 과정을 완료할 수 있습니다. 메서드 호출 유형, 횟수, 순서 등을 검증할 수 있으며, mock 객체가 지정된 값을 반환하거나 지정된 예외를 발생시키도록 설정할 수도 있습니다. 개발자는 EasyMock을 통해 실제 비즈니스 로직 뒤에 있는 객체를 무시하고 쉽게 mock 객체를 구성할 수 있습니다. 일반적으로 EasyMock은 JUnit 또는 TestNG와 함께 사용됩니다.

PowerMock

PowerMock는 Java 단위 테스트를 위한 프레임워크로, EasyMock 및 Mockito와 같은 다른 mocking 프레임워크의 기능을 확장합니다. PowerMock의 주요 특징은 정적 메서드, 프라이빗 메서드, 최종 메서드, 생성자, 심지어 System, String과 같은 시스템 클래스를 모의할 수 있다는 점입니다. 이는 전통적인 mocking 프레임워크로는 보통 불가능한 기능입니다. 이러한 기능 덕분에 PowerMock은 복잡한 시나리오에서 단위 테스트를 수행하는 데 더 편리합니다. PowerMock은 클래스 로더와 바이트코드 조작을 수정하므로 일부 테스트 방법이 JVM이나 타사 라이브러리와의 호환성 문제를 일으킬 수 있습니다. 따라서 PowerMock을 사용할 때는 제공되는 기능과 발생할 수 있는 복잡성을 신중하게 고려해야 합니다.

JMock

JMock은 Java 단위 테스트를 위한 프레임워크로, 경량 프레임워크에 속합니다. 이 프레임워크는 행위 주도 개발(BDD) 테일 스타일을 채택합니다. 단위 테스트에서 인터페이스 또는 클래스의 의존성을 모의하여 코드를 격리 테스트하고 시스템의 다른 부분은 신경 쓸 필요가 없도록 합니다. JMock은 선언적 방식을 통해 객체 간의 상호 작용 동작을 지정할 수 있습니다.

대부분의 개발자들은 이러한 테스트 프레임워크를 단독으로 사용하기보다는 결합하여 사용합니다. 예를 들어, JUnit 또는 TestNG를 기반으로 Mockito를 사용하여 mock 객체를 생성하고 사용하여 비교적 복잡한 테스트 작업을 완료할 수 있습니다. 각 프레임워크에는 고유한 장점과 단점이 있으며, 어떤 프레임워크를 선택할지는 개인의 선호도, 프로젝트 요구사항 및 기존 코드베이스의 호환성에 따라 결정됩니다.

물론, 실제 개발에서 사용되는 단위 테스트 프레임워크는 위에서 언급한 것 이상으로 많으며, Selenium, Cucumber, Appium과 같이 본문에서 소개되지 않은 다른 훌륭한 프레임워크도 존재합니다. 관심 있는 독자들은 직접 학습해볼 수 있습니다.

우리가 사용하는 방식

현재 주류를 이루는 단위 테스트 프레임워크는 Mockito입니다. Mockito의 단위 테스트 흐름은 다음과 같습니다:

다음 코드를 예로 들어 단위 테스트의 일반적인 작성 방식을 살펴보겠습니다:

@Service
public class EmployeeServiceImpl implements EmployeeServiceI {
    
    @Autowired
    private EmployeeRepository employeeRepository;
    
    @Override
    public List<EmployeeVO> getEmployeesByDepartment(Long departmentId, String status) {
        Validate.notNull(departmentId, "departmentId cannot be null");
        Validate.notBlank(status, "status cannot be null");
        
        EmployeeQueryParam param = new EmployeeQueryParam();
        param.setDepartmentId(departmentId);
        param.setStatus(status);
        
        List<EmployeeDTO> employeeDTOs = employeeRepository.findEmployeesByCondition(param);
        
        if (CollectionUtils.isEmpty(employeeDTOs)) {
            log.warn("No employees found for department:{}, status:{}", departmentId, status);
            return Collections.emptyList();
        }
        
        return DataObjectUtils.transferDeep(employeeDTOs, EmployeeVO.class);
    }
}

위 코드에 대해 적절한 단위 테스트를 작성하는 것은 모든 프로그래머의 기본 기술입니다. @InjectMocks, @Mock, Mockito.when(), Mockito.verify() 등 일련의 작업을 사용하는 것입니다. 물론, 우리도 이 방식을 사용합니다:

@ExtendWith(MockitoExtension.class)
public class EmployeeServiceImplTest {
    
    @InjectMocks
    private EmployeeServiceImpl employeeService;
    
    @Mock
    private EmployeeRepository employeeRepository;
    
    @Test
    public void testGetEmployeesByDepartmentWhenSuccess() {
        Long departmentId = 123L;
        String status = "active";
        
        List<EmployeeDTO> dtoList = new ArrayList<>();
        EmployeeDTO employeeDTO = new EmployeeDTO();
        employeeDTO.setEmployeeId("emp001");
        employeeDTO.setDepartmentId(departmentId);
        employeeDTO.setStatus(status);
        dtoList.add(employeeDTO);
        
        Mockito.when(employeeRepository.findEmployeesByCondition(Mockito.any()))
            .thenReturn(dtoList);
        
        List<EmployeeVO> result = employeeService.getEmployeesByDepartment(departmentId, status);
        
        Assert.assertEquals(1, result.size());
        EmployeeVO employeeVO = result.get(0);
        Assert.assertNotNull(employeeVO);
        Assert.assertEquals("emp001", employeeVO.getEmployeeId());
        
        Mockito.verify(employeeRepository).findEmployeesByCondition(Mockito.any());
    }
}

클릭하고 실행하면 문제가 없습니다! 좋아, 다음을 계속 작성해보겠습니다. 등등~ 정말 문제가 없을까요? 그렇다면 다음 질문에 대해 답변해보세요:

  • @InjectMocks는 무엇을 하는 것이며, 모의된 employeeService 객체는 어떤 특성을 가지고 있나요?
  • @Mock은 무엇을 하는 것이며, 모의된 employeeRepository 객체는 어떤 특성을 가지고 있나요?
  • Mockito.when().thenReturn()은 무엇을 하는 것이며, 메서드 호출을 어떻게 모의하나요?
  • Mockito.verify()는 무엇을 하는 것이며, 메서드 호출을 어떻게 검증하나요?

위 질문에 명확하게 답변할 수 있다면, 이 글은 복습의 역할을 할 것입니다. 답변하지 못한다면, 이 글을 통해 새로운 지식을 얻으세요!

Mockito의 동작 방식

@ExtendWith(MockitoExtension.class)

JUnit 5에서는 @ExtendWith(MockitoExtension.class)를 사용하여 Mockito 테스트 환경을 설정합니다. 이 주석의 주요 기능은 mock 객체를 자동으로 초기화하는 것입니다.

Mockito 1.9.5 이전에는 mock 객체를 초기화하기 위해 수동으로 명시적으로 호출해야 했습니다:

@Before
public void initMocks() {
    MockitoAnnotations.initMocks(this);
}

Mockito 1.9.5 이후에는 이 초기화 과정이 MockitoJUnitRunner에 통합되어, 주석 하나만으로 mock 객체 주입을 완료할 수 있습니다. MockitoJUnitRunner가 어떻게 실행되는지 함께 살펴보겠습니다:

MockitoAnnotations.initMocks(this)가 MockitoJUnitRunner의 생성자에서 직접 호출되는 것은 아닙니다. 대신, MockitoJUnitRunner은 다른 캡슐화 레이어(예: StrictRunner 또는 RunnerFactory에서 생성된 특정 Runner)에서 mock 초기화 로직을 처리하는 것으로 보입니다:

내부 실행기 인터페이스인 DefaultInternalRunner의 생성자에서 MockitoAnnotations.initMocks(this)를 호출하여 mock 객체를 초기화합니다. 이렇게 하는 데에는 두 가지 주요 이점이 있습니다: 1) 코드가 더 간결해집니다; 2) 테스트 케이스를 실행하기 전에 객체가 모두 모의되었음을 보장합니다.

MockitoAnnotations.initMocks(this)가 mock 객체를 어떻게 생성하는지는 계속해서 확인해보겠습니다.

@InjectMocks

@InjectMocks는 Mockito 테스트 프레임워크가 제공하는 주석으로, mock 객체를 테스트 클래스의 인스턴스에 자동으로 주입하여 생성하고 초기화합니다. 주입된 객체는 일반적으로 단위 테스트를 수행할 클래스입니다.

@InjectMocks 주석은 다음과 같습니다:

공식 문서에 따르면, mock 객체를 주입하는 방식은 3가지가 있습니다:

  • 생성자 주입: Mockito는 주석이 달린 클래스의 생성자를 찾고, 사용 가능한 mock 객체를 매개변수로 사용하여 클래스를 인스턴스화하려고 시도합니다. 먼저 가장 많은 매개변수를 사용하는 생성자를 시도하고, 실패하면 더 적은 매개변수를 사용하는 생성자를 시도합니다.
  • 속성 주입: 생성자 주입이 불가능하거나 실패하면, Mockito는 mock 객체를 주석이 달린 클래스의 속성에 직접 설정합니다. 이는 프라이빗 속성을 포함합니다. 리플렉션을 사용하여 접근 수정자를 무시하고 속성에 직접 값을 할당합니다.
  • 메서드 주입: 앞의 두 가지 방식이 모두 불가능하면, Mockito는 setter 메서드를 호출하여 mock 객체를 주입하려고 시도합니다.

단순히 이 3가지 주입 방식을 아는 것만으로는 Mockito가 어떻게 작동하는지 깊이 이해하는 데 도움이 되지 않습니다. 그렇다면 이 주석의 실행은 어떻게 될까요?

앞서 언급했듯이, Mockito는 주석을 처리할 때 주로 MockitoAnnotations.initMocks(this) 메서드를 통해 진입합니다. 먼저 이 메서드는 주석 엔진인 AnnotationEngine을 생성합니다:

이 엔진은 테스트 클래스를 스캔하여 모든 Mockito 주석 필드(@Mock, @Spy, @Captor 및 @InjectMocks)를 찾고, 각 주석에는 해당하는 프로세서가 있어 해당 작업을 처리합니다. InjectingAnnotationEngine을 예로 들면:

injectMocks 및 injectMocksOnFields 메서드를 통해 Mockito가 테스트 클래스에서 생성자를 찾아서 해당 생성자의 매개변수와 기존의 mock 객체가 일치하는지 확인하고, 이러한 mock 객체를 사용하여 @InjectMocks 주석이 달린 필드의 인스턴스를 생성하는 것을 볼 수 있습니다. 일치하는 생성자를 찾지 못하면, Mockito는 속성 주입 방식을 사용하여 필드에 직접 값을 설정하거나 setter 메서드를 통해 주입합니다.

@InjectMocks 주석의 기능은 Mockito 내부의 여러 클래스가 협력하여 구현되며, 로직 처리가 매우 복잡하며 많은 경계 사례 처리 및 오류 관리를 포함합니다. 예를 들어, 여러 생성자를 처리하고, 순환 의존성을 방지하며, 최적의 주입 전략을 선택해야 합니다. 다음은 위의 흐름을 개념적으로 요약한 의사 코드입니다:

public class MockitoAnnotations {
    public static void initMocks(Object test) {
        AnnotationEngine annotationEngine = ...;
        annotationEngine.process(test.getClass(), test);
    }
}

public class InjectMocksAnnotationEngine implements AnnotationEngine {
    public void process(Class<?> clazz, Object testInstance) {
        // @Mock 주석이 달린 모든 필드 가져오기
        Set<Field> mockFields = ...; 
        // @InjectMocks 주석이 달린 모든 필드 가져오기
        Set<Field> injectMocksFields = ...; 
        for (Field injectMocksField : injectMocksFields) {
            // @InjectMocks 주석이 달린 필드의 인스턴스 생성
            Object fieldInstance = createInstance(injectMocksField.getType()); 
            injectMocksField.setAccessible(true);
            // 인스턴스를 테스트 클래스의 필드에 설정
            injectMocksField.set(testInstance, fieldInstance); 
    
            PropertySetterInjector propertySetterInjector = new PropertySetterInjector(mockFields);
            ConstructorInjector constructorInjector = new ConstructorInjector(mockFields);
            
            if (!constructorInjector.tryConstructorInjection(fieldInstance, injectMocksField)) {
                propertySetterInjector.tryPropertyOrSetterInjection(fieldInstance, injectMocksField);
            }
        }
    }
}

public class ConstructorInjector {
    public boolean tryConstructorInjection(Object fieldInstance, Field injectMocksField) {
        // 리플렉션을 사용하여 생성자 주입으로 mock 객체를 시도
    }
}

public class PropertySetterInjector {
    public void tryPropertyOrSetterInjection(Object fieldInstance, Field injectMocksField) {
        // 리플렉션을 사용하여 속성 또는 setter 메서드 주입을 시도
    }
}

이 주석의 실행 로직은 다음 4단계로 요약할 수 있습니다:

  1. 주석 프로세서 초기화: 먼저 MockitoAnnotations.initMocks(this)를 호출하거나 MockitoJUnitRunner 또는 JUnit의 MockitoExtension을 사용합니다. 이러한 메서드는 테스트 클래스를 스캔하여 Mockito가 제공하는 모든 주석(@Mock, @Spy, @Captor, @InjectMocks)을 찾아 처리합니다.

  2. mock 객체 생성: 각 @Mock 주석이 달린 필드에 대해 Mockito는 해당 mock 객체를 생성합니다. 이는 Mockito.mock() 메서드를 호출하여 수행됩니다(하단에서 어떻게 모의하는지 설명).

  3. 주입 지점 찾기: 각 @InjectMocks 주석이 달린 필드에 대해 Mockito는 변수 주입 지점을 찾습니다. 먼저, Mockito는 매개변수가 가장 많고 완전히 일치하는 생성자를 선택하여 mock 객체를 주입합니다. 적절한 생성자가 없으면 속성 주입을 시도하고, 마지막으로 setter 메서드를 고려합니다.

  4. 주입 프로세스: 주입 지점(생성자, 속성 또는 setter 메서드)을 찾으면, Mockito는 리플렉션 API를 사용하여 주입 프로세스를 완료합니다. 생성자의 경우, 찾은 mock 객체를 사용하여 새 객체를 인스턴스화합니다. 속성 또는 setter 메서드의 경우, mock 객체를 직접 주입합니다.

위의 단계를 통해 주입된 mock 객체는 Spring이 로드하는 객체와 명확한 차이점이 있습니다:

  • 사용 목적

  • @InjectMocks는 주로 단위 테스트에 사용되며, 실제 객체의 동작을 모방하는 mock 객체를 주입합니다.

  • Spring의 의존성 주입은 실제 애플리케이션 실행에 사용되며, Spring 컨테이너에서 생성하고 관리하는 실제 bean 객체를 주입합니다.

  • 실행 환경

  • @InjectMocks는 테스트 환경에서 사용되며 Spring 컨테이너에 의존하지 않습니다.

  • Spring 의존성 주입은 애플리케이션의 프로덕션 환경에서 사용되며 Spring 컨테이너의 생명주기 및 관리에 의존합니다.

  • 객체 유형

  • @InjectMocks로 주입된 객체는 모방을 위한 프록시 객체입니다.

  • Spring에 주입된 객체는 완전한 기능의 인스턴스입니다.

  • 생명주기

  • Mockito는 mock 객체의 생명주기 관리를 담당하지 않으며, 테스트 케이스 실행이 완료되면 mock 객체는 폐기됩니다.

  • Spring 컨테이너는 bean의 전체 생명주기를 담당하며, 생성, 초기화, 주입, 소멸 등을 포함합니다.

  • 객체 생성

  • @Mockito는 동적 프록시 방식으로 mock 객체를 생성합니다.

  • Spring은 클래스 정의를 인스턴스화하여 bean을 생성합니다.

@Mock

@Mock 주석은 테스트 객체를 주입하는 데 사용되며, 이 테스트 객체는 일반적으로 테스트 객체의 의존성 또는 외부 구성 요소입니다. 주로 모의할 클래스 또는 인터페이스를 표시하는 데 사용됩니다. 주입 후 테스트에서 이 객체의 동작과 반환 값을 모의할 수 있으며, 해당 객체의 의존성과 외부 구성 요소의 동작은 무시할 수 있습니다.

@Mock 주석은 다음과 같습니다:

  1. answer: 이 속성은 mock 객체에 기본 동작을 지정합니다. 이 동작은 모든 스텁이 설정되지 않은 메서드 호출에 적용됩니다. Answers 열거 유형의 값을 받습니다. 예를 들어, Answers.RETURNS_DEFAULTS는 스텁이 설정되지 않은 메서드가 해당 유형의 기본값(0, false, null 등)을 반환하도록 합니다. Answers.RETURNS_SMART_NULLS와 같은 다른 옵션은 사용 시 예외를 발생시키는 스마트 null 값을 반환할 수 있으며, 예외 정보에는 어떤 스텁이 설정되지 않은 메서드가 호출되었는지가 포함됩니다.

  2. stubOnly: 스텁만을 위한 mock 객체를 생성할지 여부를 나타냅니다. true로 설정하면 생성된 mock 객체는 메서드 호출을 기록하지 않으므로 메서드가 호출되었는지 검증할 수 없습니다.

  3. name: mock 객체의 이름을 지정합니다. mock 객체에 이름을 지정하면 디버깅에 도움이 됩니다. 검증이 실패하면 예외 정보에 이 이름이 포함됩니다.

  4. extraInterfaces: mock 객체에 다른 인터페이스를 구현할 수 있습니다.

  5. serializable: mock 객체가 직렬화 가능해야 하는지를 나타냅니다. true로 설정하면 생성된 mock 객체는 Serializable 인터페이스를 구현합니다. 이렇게 주입된 mock 객체는 직렬화 및 역직렬화가 필요한 테스트 시나리오에서 사용할 수 있습니다.

@Mock 주석의 로직 진입점과 @InjectMocks는 동일하게 MockitoAnnotations.initMocks(this)에서 시작합니다. 마찬가지로 AnnotationEngine 엔진을 생성합니다:

이 엔진의 실행을 통해 먼저 리플렉션을 사용하여 테스트 클래스의 모든 필드를 가져오고 @Mock 주석이 있는 필드를 확인하며, 이러한 필드에 대한 mock 객체를 생성하고 해당 필드에 설정합니다:

다른 주석에 대한 다른 프로세서를 찾아 mock 객체를 처리합니다:

주석에 구성된 속성을 읽어 MockSettings 객체로 변환하고, 필드 유형과 설정을 전달하여 Mockito.mock() 메서드를 호출하여 필요한 특성을 가진 mock 객체를 생성합니다:

실제 mock 객체를 생성하는 코드는 다음과 같습니다:

위 코드를 분석하면 다음과 같은 작업을 수행합니다:

  1. createMockType 메서드를 호출하여 제공된 MockCreationSettings를 기반으로 mock 객체의 유형을 생성합니다.
  2. Plugins에서 InstantiatorProvider의 인스턴스를 가져와 getInstantiator 메서드를 호출하여 Instantiator를 가져옵니다. Instantiator는 mock 객체를 생성하는 구성 요소입니다.
  3. Instantiator의 newInstance 메서드를 사용하여 첫 번째 단계에서 생성된 유형의 새 객체를 생성합니다.
  4. MockMethodInterceptor 인터셉터 객체를 생성하여 mock 객체에 대한 호출을 가로챌 수 있으며, 설정에 따라 해당 로직을 실행합니다.
  5. mock 객체가 MockAccess 인터페이스를 구현하는지 확인합니다. 구현하는 경우, mockMethodInterceptor를 해당 인터셉터로 설정합니다.
  6. 생성된 mock 객체를 반환합니다.

여기까지 @Mock을 통해 mock 객체를 자동으로 주입하는 주요 과정이 끝납니다. mock 객체 유형의 결정, 인스턴스의 생성, 인터셉터의 설정 및 예외 처리를 포함하여 논리가 매우 복잡함을 알 수 있습니다. 다음은 위의 흐름을 개념적으로 요약한 의사 코드입니다:

// MockitoAnnotations.initMocks 메서드의 단순화된 의사 코드
public static void initMocks(Object testClassInstance) {
    Class<?> testClass = testClassInstance.getClass();
    Field[] fields = testClass.getDeclaredFields();
    for (Field field : fields) {
        if (field.isAnnotationPresent(Mock.class)) {
            Mock mock = field.getAnnotation(Mock.class);
            Object mockObject = createMockObject(field.getType(), mock);
            field.setAccessible(true);
            field.set(testClassInstance, mockObject);
        }
    }
}

private static Object createMockObject(Class<?> fieldType, Mock mockAnnotation) {
    // mock 설정 생성
    MockSettings mockSettings = configureMockSettings(mockAnnotation);
    
    // Mockito API를 사용하여 mock 객체 생성
    return Mockito.mock(fieldType, mockSettings);
}

private static MockSettings configureMockSettings(Mock mockAnnotation) {
    // @Mock 주석 속성에 따라 MockSettings 구성
    MockSettings mockSettings = Mockito.withSettings();
    if (!"".equals(mockAnnotation.name())) {
        mockSettings.name(mockAnnotation.name());
    }
    if (mockAnnotation.stubOnly()) {
        mockSettings.stubOnly();
    }
    // 다른 구성 매개변수
    mockSettings.defaultAnswer(mockAnnotation.answer());
    return mockSettings;
}

// Mockito.mock 메서드의 단순화된 의사 코드
public static <T> T mock(Class<T> classToMock, MockSettings mockSettings) {
    // 모의할 수 있는지 확인
    if (isNotMockable(classToMock)) {
        throw new MockitoException("Cannot mock/spy class: " + classToMock.getName());
    }
    // mock 설정 생성, 제공되지 않은 경우 기본 설정 사용
    if (mockSettings == null) {
        mockSettings = withSettings();
    }
    // mock 객체 생성
    T mockInstance = createMockInstance(classToMock, mockSettings);
    // 생성된 mock 객체 반환
    return mockInstance;
}

private static <T> T createMockInstance(Class<T> classToMock, MockSettings mockSettings) {
    // mock 설정을 기반으로 MockCreationSettings 객체 생성
    MockCreationSettings<T> settings = new MockCreationSettings<>(classToMock, mockSettings);
    // mock 인스턴스 생성을 담당하는 MockMaker 플러그인 가져오기
    MockMaker mockMaker = Plugins.getMockMaker();
    // MockMaker를 사용하여 mock 인스턴스 생성
    T mock = mockMaker.createMock(settings, new MockHandlerImpl<>(settings));
    // 생성된 mock 인스턴스 반환
    return mock;
}

이 주석의 실행 원리는 다음 4단계로 요약할 수 있습니다:

  1. 주석 프로세서 초기화: 먼저 MockitoAnnotations.initMocks(this)를 호출하거나 MockitoJUnitRunner 또는 JUnit의 MockitoExtension을 사용합니다. 이러한 메서드는 테스트 클래스를 스캔하여 @Mock 주석으로 표시된 속성을 찾습니다.

  2. 주석 속성 구성: 주석 매개변수를 기반으로 MockCreationSettings 객체를 생성합니다. 이 객체는 mock 객체를 생성하는 데 필요한 모든 설정을 포함합니다.

  3. Mock 객체 생성: Mockito 내부의 MockMaker 인스턴스와 앞 단계에서 생성한 MockCreationSettings를 사용하여 mock 객체를 생성합니다.

  4. Mock 객체를 필드에 할당: 리플렉션을 통해 생성된 mock 객체를 테스트 클래스의 해당 필드에 할당합니다.

Mockito.when()

Mockito.when()은 Mockito 프레임워크의 핵심 기능 중 하나로, mock 객체의 메서드 호출에 예상 동작을 지정하는 데 사용됩니다. 예를 들어, 특정 값을 반환하거나 예외를 발생시키거나 특정 작업을 실행합니다. 그러나 Mockito.when()은 스텁 상태를 설정하는 데만 사용되며, 예상 반환값을 정의하지는 않습니다. 일반적으로 doReturn(), doThrow(), doAnswer(), doNothing(), doCallRealMethod() 등과 함께 사용하여 함수 호출의 예상 동작을 지정합니다.

Mockito.when()이 호출되면, mock 객체의 스텁 상태를 설정하기 위해 스레드 안전한 MockingProgress 인스턴스를 가져옵니다. 이 과정은 스레드 로컬 변수 작업을 포함하므로 검증 상태가 다른 스레드의 작업과 충돌하지 않도록 합니다.

pullOngoingStubbing() 메서드를 사용하여 현재 진행 중인 스텁 객체를 추출합니다:

먼저 현재 OngoingStubbing을 가져오며, 이 필드는 현재 스레드의 진행 중인 스텁 객체를 저장합니다. 그런 다음 이 객체를 지워서 향후 스텁 작업이 오래된 스텁 객체를 잘못 사용하지 않도록 방지합니다. 마지막으로 이 객체를 반환합니다. OngoingStubbing 객체는 개발자가 설정한 예상 동작을 분석할 수 없으며, thenReturn(), thenThrow() 등과 같은 메서드를 체인 호출해야 합니다. 예를 들어, Mockito.when().thenReturn()과 같습니다.

이제 thenReturn()을 예로 들어 분석해 보겠습니다:

OngoingStubbing 객체가 thenReturn()을 체인 호출한 후, 먼저 new Returns(value)를 인스턴스화하여 지정된 값을 반환하는 객체를 생성하고 thenAnswer() 메서드를 호출합니다:

위 코드를 보면 설정된 예상 동작을 Mockito.when()에서 생성된 OngoingStubbing 객체에 추가하는 것을 볼 수 있습니다. 다음과 같은 흐름으로 나눌 수 있습니다:

  1. 스텁 인스턴스 가져오기: invocationForStubbing에서 스텁 호출을 나타내는 Invocation 객체를 가져옵니다.

  2. 스텁 완료 알림: MockingProgress 객체에 스텁 프로세스가 완료되었음을 알립니다. 이 단계는 내부 상태를 업데이트할 수 있습니다.

  3. 답변 검증: Answer 인스턴스가 ValidableAnswer 인터페이스를 구현하는 경우, 첫 번째 단계에서 가져온 Invocation 객체를 검증합니다.

  4. 스텁 목록 동기화: 동기화된 코드 블록 내에서 스텁 목록 stubbed를 동기화합니다.

  5. 스텁 목록 추가 또는 업데이트: 스텁 객체가 연속 호출로 설정된 경우, Answer 객체를 스텁 목록의 맨 앞에 설정합니다. 그렇지 않으면 새로운 StubbedInvocationMatcher 인스턴스를 생성하여 스텁 목록 맨 앞에 설정합니다.

  6. 스텁 매처 반환: 스텁 목록의 첫 번째 요소를 반환하고 매처 유형으로 캐스팅합니다.

스텁 작업은 일반적으로 "LIFO" 규칙을 따르므로 마지막으로 설정된 Answer가 가장 최근에 호출됩니다. 즉, 후속 스텁 구성은 이전 스텁 구성을 덮어씁니다.

Mockito.verify()

Mockito.verify() 메서드는 Mockito 프레임워크의 핵심 기능 중 하나로, mock 객체의 특정 메서드가 호출되었는지, 호출 횟수와 매개변수가 일치하는지 검증하는 데 사용됩니다.

Mockito.verify()가 호출될 때 대략적인 실행 흐름은 다음과 같습니다:

verify() 메서드는 새로운 VerificationMode 인스턴스를 생성하며, 일반적으로 한 번 호출되는 검증 모드입니다. 따라서 검증 함수가 한 번만 호출되는 경우 verify 함수에서 times 매개변수를 생략할 수 있습니다.

위 코드를 분석하면 다음과 같은 작업을 수행합니다:

  1. mock 객체 확인: mockingDetails(mock)를 통해 전달된 객체가 실제로 mock 객체인지 확인합니다.

  2. 매개변수 검증: mock 객체가 스텁에만 사용되는지, 검증에 사용되지는 않는지 확인합니다. 스텁 전용 mock 객체인 경우 검증을 수행할 수 없습니다.

  3. 검증 시작 알림: 검증 프로세스에 리스너가 포함된 경우 VerificationStartedNotifier.notifyVerificationStarted() 메서드를 호출하여 모든 검증 시작 리스너에 알립니다.

  4. MockingProgress 가져오기: 현재 스레드의 MockingProgress 인스턴스를 가져와 검증 상태를 기록합니다. 이는 스레드 로컬 변수 작업을 포함하므로 검증 상태가 다른 스레드의 작업과 충돌하지 않도록 합니다.

  5. 지연 검증: maybeVerifyLazily() 메서드를 사용하여 이 메서드는 전달된 VerificationMode를 수정하여 지연 검증을 할 수 있습니다. 특정 메서드가 호출될 때 검증을 수행합니다.

  6. 검증 시작: MockAwareVerificationMode 인스턴스를 생성하여 MockingProgress에 설정합니다. 이 인스턴스는 mock 객체, 실제 검증 모드 및 검증 리스너를 포함합니다.

  7. 검증 종료: mock 객체를 반환합니다.

verify()의 실제 호출에는 InOrder 검증, 검증 시간 초과 등 고급 기능에 대한 지원과 예외 처리 및 다양한 경계 사례 처리가 포함됩니다. 본문에서는 검증 프로세스에 대한 분석만 제공합니다. 다음은 위의 흐름을 개념적으로 요약한 의사 코드입니다:

public class Mockito {
    public static <T> T verify(T mock) {
        return verify(mock, times(1));
    }
  
    public static <T> T verify(T mock, VerificationMode mode) {
        MockingProgress progress = MockingProgressImpl.threadSafeMockingProgress();
        progress.verificationStarted(mode);
        return mock;
    }
}

public class MockHandlerImpl<T> implements MockHandler<T> {
    public Object handle(Invocation invocation) throws Throwable {
        MockingProgress progress = MockingProgressImpl.threadSafeMockingProgress();
        VerificationMode verificationMode = progress.pullVerificationMode();
        if (verificationMode != null) {
            // 검증 호출
            verificationMode.verify(invocation);
            return null;
        } else {
            // 실제 mock 동작 실행
            return invocation.callRealMethod();
        }
    }
}

결론

본문에서는 Mockito 프레임워크가 단위 테스트를 실행하는 데 사용하는 일반적인底层 로직을 설명했으며, 일부 고급 기능에 대해서는 깊이 있게 다루지 않았습니다. 관심 있는 독자들은 직접 연구해볼 수 있습니다. 단위 테스트는 개발에서 중요한 부분이며, 한 편의 글로 모두 설명할 수 있는 주제가 아닙니다. 물론, 이 글이 단위 테스트底层 로직에 대한 관심을 불러일으킨다면 이 글의 가장 큰 가치가 될 것입니다.

태그: Mockito 단위 테스트 Java 테스트 프레임워크 테스트 자동화 Mock 객체

7월 12일 21:48에 게시됨