본문 바로가기
테스트

테스트 더블을 직접 구현하여 테스트 환경 구축하기(feat. 동시성 제어)

by 코딩공장공장장 2025. 2. 21.
용어 정리 

테스트 더블 : 실제 객체들 대신해서 테스팅에서 사용하는 모든 방법
Dummy : 객체는 전달 되지만 사용되지 않는 객체
Fake : 실제 동작은 하나 production과는 달리 단순화된 동작을 제공
Stub : 미리 준비해둔 결과를 제공
Spy : Stub의 역할을 하며 호출 내용에 대해 기록
Mock : 호출 기록을 저장함, 주로 return type이 없는 더블에 많이 적용함

 

테스트 코드를 작성할 때, 테스트 대상이 아닌 객체를 테스트 더블로 대체하여 사용하는 경우 모키토 프레임워크를 많이 이용한다.

나 역시도 모키토 프레임워크를 이용하여 테스트 더블들을 조작하며 여러 시나리오에 대해 테스트를 했었다.

허나, 모키토 프레임워크의 리플렉션 사용으로 인한 테스트 실행시간 부하와 스레드 세이프 하지 못한 문제로 병렬 테스트가 진행되지 않는 이슈들로 모키토 프레임워크를 사용하지 않고 직접 테스트 더블을 구현하기로 하였다.

 

[참고] 테스트 더블은 테스트 대상의 의존객체라고 생각하자

앞으로의 글은 모두 단위테스트 기준이고, 단위 테스트는 클래스(또는 메서드) 단위로 진행하였기에
의존객체는 모두 테스트 더블로 대체하였다.
(ex. 컨트롤러 테스트에서 서비스는 모두 더블로 대체, 서비스 테스트에서 리포지토리 모두 대체)

 

환경 구성


testFixture 폴더 하위에 테스트 더블과 샘플 데이터를 위치시켜 테스트 코드에서 사용할 수 있도록 하였다.

테스트 더블을 목적에 따라 Fake, Stub, Spy로 구분하여 이름을 짓기에는 너무 복잡해지기에 모두 mock이라는 용어로 통일 하였다.

테스트시 사용되는 메서드의 인자값이나 리턴값에 해당하는 dto와 vo는 모두 sample 패키지에 위치시켰다.

 

constant 하위에는 상수 파일을 위치시켰다.

Stub에서 반환값을 미리 결정하기 위해 인자로 전달되는 값에 따라 분기 처리될 수 있도록 아래와 같이 공통 상수 파일을 만들었다.

 

예를 들어 영속화 메서드에서 인자값으로 FAIL_ID가 전달되면 0이 반환되고 그렇지 않으면 1이 반환되는 구조이다.

EXCEPTION_ID가 전달되면 예외를 터트리도록 테스트 더블을 설계하여 테스트 더블 동작에 일관성을 주고

가독성과 편리한 사용을 위한 목적이다.  

object MockTestConstant {
    // 스텁에서 사용하는 예외 메시지
    val STUB_EXCEPTION_MSG = "실패 케이스 예외 발생"

    // 실패 id
    val FAIL_ID = 2L

    // exist 쿼리에서 true 반환할 ID
    val EXIST_ID = 1L

    // 예외 터트리는 ID
    val EXCEPTION_ID = 3L

    // 실패 email
    val FAIL_EMAIL = "fail@test.com"

    // exist 쿼리에서 true 반환할 email
    val EXIST_EMAIL = "exist@test.com"

    // 예외 터트리는 email
    val EXCEPTION_EMAIL = "exception@test.com"

    // 실피 userName
    val FAIL_USER_NAME = "실패자"

    // 실패 휴대폰 번호
    val FAIL_PHONE_NUMBER = "01099999999"

    // 실패 문자열 파라미터
    val FAIL_STRING = "실패"
}

 

 

Fake 구현


 

Fake는 위와 같이 실제 동작을 하지만 production과 달리 단순한 동작을 제공하는 것을 말한다.

 

나 역시 select 쿼리를 수행하는 테스트 더블의 동작을 제어할 필요가 있었기에 아래와 같이 구현했다.

 

[테스트 더블]

class MockMathCategoryUnitReadCase : MathCategoryUnitReadCase {
    override fun readAll(): List<MathCategoryUnitVo> {
        return getMathCategoryUnitVo()
    }
}

 

[Fake의 반환 결과]

object MathContentsSampleData {
     fun getMathCategoryUnitVo(): List<MathCategoryUnitVo> {
             return listOf(
                 MathCategoryUnitVo(21001, "중1", "수와 연산", "소인수분해", "소인수분해"),
                 MathCategoryUnitVo(21002, "중1", "수와 연산", "소인수분해", "최대공약수와 최소공배수"),
                 MathCategoryUnitVo(21003, "중1", "수와 연산", "정수와 유리수", "정수와 유리수의 뜻"),
                 MathCategoryUnitVo(21004, "중1", "수와 연산", "정수와 유리수", "정수와 유리수의 대소 관계"),
             )
         }
       ...
}

 

 

mock 패키지 하위에 Fake 객체를 구현하고 반환하는 리턴 객체를 SampleData에서 가져오도록 하였다.

이렇게 사용하면 SampleData의 재사용성이 높아진다.

 

TestConfiguration을 통해 위의 테스트 더블을 Bean으로 등록하면 컨트롤러 테스트에서 실제 DB에 접근하지 않고 Fake 객체의 반환 결과를 리턴한다. 컨트롤러 테스트에서 테스트 더블을 통해 DB의 의존성을 끊고 컨트롤러 로직에 집중하여 테스트가 가능하게 된다.

class MockMathContentsBeanConfig {
    @Bean
    fun mathCategoryTypeReadCase(): MathCategoryTypeReadCase = MockMathCategoryTypeReadCase()

    @Bean
    fun mathCategoryUnitReadCase(): MathCategoryUnitReadCase = MockMathCategoryUnitReadCase()
    ...
 }

 

Stub 구현


테스트 더블 중 가장 사용성이 높은 객체이다.

의존 객체가 반환해주는 결과에 따라 테스트 대상 객체의 로직이 달라지는 경우, 이에대한 모든 테스트를 진행해야하기에 의존객체의 반환값에 대한 조작이 필요하다.

 

예제1 - existBy

    override fun save(modifyDto: MathContentsLikeModifyDto) {
        // 존재여부 체크
        val isExist = mathContentsLikeReadCase.existByContentsIdAndMemberId(modifyDto.contentsId, modifyDto.memberId)
        if (isExist) throw BusinessInValidException(ALREADY_EXIST)

        // 좋아요
        mathConLikeModifyPort.save(modifyDto)
    }

 

위의 프로덕션 코드는 데이터를 저장하기전에 해당 데이터가 존재하는지 검사하는지 여부를 포함한다.

존재하면 저장되면 안되며, 존재하지 않으면 저장 가능하다.

 

따라서 두가지 케이스를 모두 테스트 해야하기에 의존객체인 mathContentsLikeReadCase의 반환값에 대한 조작이 필요하다.

 

따라서 나는 아래와 같이 Stub을 구현하였다.

class MockMathContentsLikeReadCase : MathContentsLikeReadCase {
    override fun existByContentsIdAndMemberId(contentsId: Long, memberId: UUID): Boolean {
        return contentsId == EXIST_ID
    }
}

 

메서드 인자값인  contentsId가 미리 정의한 EXIST_ID라는 상수와 같으면 true가 반환되고,

같지 않으면 false가 반환되도록 하였다.

 

따라서 위와 같이 Stub을 테스트 대상에 주입하여 주고 메서드의 인자값이 EXIST_ID가 들어갈 때와, 

그렇지 않을 때를 테스트하면 prod 코드의 두가지 시나리오에 대해 모두 테스트가 가능하다.

class MathContentsLikeWriteServiceTest {
    private val mathConLikeReadCase = MockMathContentsLikeReadCase()
    private val mathConLikeModifyPort = MockMathContentsLikeWriteOrmPort()

    private val mathContentsLikeWriteService =
        MathContentsLikeWriteService(mathConLikeReadCase, mathConLikeModifyPort)


    @Test
    fun `좋아요 - 성공`() {
        // given
        val modifyDto = MathContentsLikeModifyDto(EXIST_ID + 1L, UUID.randomUUID())

        // when & then
        assertDoesNotThrow {
            mathContentsLikeWriteService.save(modifyDto)
        }
    }

    @Test
    fun `좋아요 - 실패(이미 좋아요 누름)`() {
        // given
        val modifyDto = MathContentsLikeModifyDto(EXIST_ID, UUID.randomUUID())

        // when & then
        val exception = assertThrows<BusinessInValidException> {
            mathContentsLikeWriteService.save(modifyDto)
        }
        assertThat(exception.msg).isEqualTo(MathContentsLikeWriteService.ALREADY_EXIST)
    }
    ...
 }

 

 

예제2 - create, update, delete

class MockHwpConvertContentsWriteCase : HwpConvertContentsWriteCase {
    override fun create(createDto: HwpConvertContentsCreateDto): Long {
        return if (createDto.fileName != FAIL_STRING) 0L else 1L
    }

    override fun update(updateDto: HwpConvertContentsUpdateDto): Long {
        return when {
            updateDto.id == FAIL_ID -> 0L
            updateDto.id == EXCEPTION_ID -> throw RuntimeException(STUB_EXCEPTION_MSG)
            else -> 1L
        }
    }

    override fun delete(contentsId: Long, memberId: UUID): Long {
        return when {
            contentsId == FAIL_ID -> 0L
            contentsId == EXCEPTION_ID -> throw RuntimeException(STUB_EXCEPTION_MSG)
            else -> 1L
        }
    }
}

 

코드 로직들을 찬찬히 살펴보라.

메서드의 인자값에 따라 0, 1을 반환하거나 예외를 터트린다.

위 Stub은 Repository를 대체한 것이다.

 

성공 케이스, 실패케이스, 예외 케이스를 모두 테스트하기 위해 각각에 알맞는 인자값이 스텁에 전달되도록 테스트 데이터를 넣어주면 모든 케이스에 대해 테스트가 가능하다.

 

Mock 구현


    /**
     * 인증 정보 추출 및 전달
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws
            AuthenticationException {
        try {
            // 사용자 요청 authentication(인증) 객체 추출
            final Authentication authRequest = obtainAuthenticationRequest(request);
            
            // manager에게 전달
            return authenticationManager.authenticate(authRequest);
        } catch(AuthenticationException ex) {
            throw ex;
        } catch(MismatchedInputException | JsonParseException ex) {
            throw new BadInputRequestException();
        } catch(Exception ex) {
            throw new AuthInternalException();
        }
    }
    
    /**
     * requestBody로 부터 Authentication(인증 정보) 추출
     */
    private Authentication obtainAuthenticationRequest(HttpServletRequest request) throws IOException {
        final AuthRequest authRequest = objectMapper.readValue(request.getInputStream(), AuthRequest.class);
        final String username = authRequest.username();
        final String password = authRequest.password();

        if(username == null || password == null) {
            throw new BadInputRequestException();
        }

        request.setAttribute("username", username);
        return new UsernamePasswordAuthenticationToken(username, password);
    }

 

위의 prod 코드는 사용자 입력 인증 정보를 추출하여 Manager에게 전달하는 역할은 한다.

prod 코드의 수행결과는 의존 객체인 authenticationManager에 의해 결정된다.

의존객체가 테스트 결과를 결정하니 성공 케이스에서 마땅히 검증할 수 있는게 없어 보인다.

 

이런 경우 단순히 메서드가 호출되었는지 여부를 테스트할 수 있다.

로직이 예외 없이 모두 정상적으로 수행되어야 authenticationManager가 authenticate 메서드를 호출할 것이다.

 

이런 경우 메서드 호출 기록을 측정하기 위해 인스턴스 변수를 설정할 수 있다.

메서드가 호출되었을 때 값을 1만큼 증가시켜 몇번 호출됬는지 파악할 수 있다.

public class MockAuthenticationManager implements AuthenticationManager {
    // 실행 여부
    public int executeCnt = 0;

    @Override
    public Authentication authenticate(Authentication authentication) throws RuntimeException {
        executeCnt++;
        return authentication;
    }
}

 

따라서 우리는 아래와 같은 테스트 코드를 작성할 수 있다.

 

    @BeforeEach
    void 테스트_대상_초기화() {
        authenticationManager = new MockAuthenticationManager();
        authenticationSuccessHandler = new MockAuthenticationSuccessHandler();
        authenticationFailureHandler = new MockAuthenticationFailureHandler();
        // 테스트 대상
        loginRequestAuthFilter = new LoginRequestAuthFilter(authLoginUrlProperty.process(), authenticationManager,
                authenticationSuccessHandler, authenticationFailureHandler);
    }
    
    @Test
    void 로그인_요청_성공() {
        // given
        request.setContent("{\"username\":\"username\", \"password\":\"13\"}".getBytes());

        // when
        loginRequestAuthFilter.attemptAuthentication(request, response);

        // then
        assertThat(authenticationManager.executeCnt).isOne();
    }

 

 

 

추가 개념 - 동시성 제어


테스트 더블을 직접 구현하기로 결정했다면 병렬 테스트에서 실패하지 않도록 동시성을 고려하는 것이 좋다.

이왕 고생해서 더블을 직접 작성하는데 프레임워크를 사용하는 것보다는 이익이 있어야 하지 않겠는가

 

동시성을 제어하는 방법은 간단하다.

 

 

테스트 메서드 마다 테스트 더블 및 테스트 대상 인스턴스 생성

이전의 예제처럼 BeforeEach를 통해 테스트시마다 새롭게 인스턴스화 하는 것은 인스턴스를 메서드마다 독립적으로 사용할 수 있게 한다.

메서드 안에서 객체를 인스턴스화 해도 된다.

    @BeforeEach
    void 테스트_대상_초기화() {
        authenticationManager = new MockAuthenticationManager();
        authenticationSuccessHandler = new MockAuthenticationSuccessHandler();
        authenticationFailureHandler = new MockAuthenticationFailureHandler();
        // 테스트 대상
        loginRequestAuthFilter = new LoginRequestAuthFilter(authLoginUrlProperty.process(), authenticationManager,
                authenticationSuccessHandler, authenticationFailureHandler);
    }

 

만약 클래스 레벨에서 아래와 같이 인스턴스화한다면 메서드 수준에서 독립적인 테스트를 보장하기 어렵다.

class LoginRequestAuthFilterTest {
    private final AuthLoginUrlProperty authLoginUrlProperty = getAuthLoginUrlProperty();
    private MockAuthenticationManager authenticationManager = new MockAuthenticationManager();
    private MockAuthenticationSuccessHandler authenticationSuccessHandler = new MockAuthenticationSuccessHandler();;
    private MockAuthenticationFailureHandler authenticationFailureHandler= new MockAuthenticationFailureHandler();
    // 테스트 대상
    private LoginRequestAuthFilter loginRequestAuthFilter= new LoginRequestAuthFilter(authLoginUrlProperty.process(), authenticationManager,
            authenticationSuccessHandler, authenticationFailureHandler);

 

허나, 만약 동시성 제어가 필요 없다면 위와 같이 클래스 레벨에서 인스턴스화하는 것이 성능에 오히려 좋다.

따라서 테스트 더블과 테스트 대상이 동시성 제어가 필요하다면 반드시 메서드 레벨에서 인스턴스화를 하고,

동시성 제어가 필요없다면 클래스 레벨에서 인스턴스화 하더라도 큰 문제는 되지 않는다.

 

나의 경우에도 동시성 제어가 필요없는 것들은 클래스 레벨에서 인스턴스화 한다.

메서드 내에서 인스턴스화하는 것은 메모리나 성능에 악영향을 끼치는 것 뿐더러 코드 중복도 심하여 가독성이 많이 떨어진다.

 

 

후기


모키토에 굉장히 편리함을 느끼고 테스트 코드 작성에 탄력을 얻었는데 성능 문제로 모키토를 제거해야하는 상황이 오니 이게 과연 맞나 싶었다. 테스트 더블을 직접 구현하여 테스트 성능에 향상을 준다고 하더라도 개발자의 생산성이 낮아지면 옳은일인가 싶었지만

작업을 끝내보니 헛된 고민이었다.

 

테스트 더블을 일관된 방식으로 동작하게 만드니 모키토를 사용하는 것보다 훨씬 편리하였다.

인텔리제이에서는 구현체의 상위타입의 추상 메서드를 작성해주는 기능까지 제공하니 타자치는 시간도 줄어든다.

(물론 구현 로직은 todo implement로 작성된다.)

 

패키지 관리 규칙과 일관된 동작이 될 수 있도록 자신만의 규칙을 정한다면 모키토를 사용하는 것보다 훨씬 편리하게 사용할 수 있을 것이다.

테스트 코드 실행시간 개선은 당연히 따라오는 결과이다.

반응형