본문 바로가기
테스트

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

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

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

 

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

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

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

 

환경 구성


[패키지 구성]

  • constant : 테스트 더블의 성공/실패/예외 동작을 인자값으로 제어하고자 사용되는 인자값 전역 상수값  
  • mock : 테스트 더블이 존재함.  Fake, Stub, Spy로 구분하기에는 너무 복잡하기에 모두 mock이라는 용어로 통일
  • sample : 테스트시 사용할 샘플 데이터로 주로 메서드의 인자값이나 반환 값 객체로 이루어짐

 

constant 파일에 대해 설명을 더하겠다.

상수파일을 별도로 만든 이유는 코드 가독성과 테스트 더블의 동작에 일관성을 주어 쉽게 사용하기 위함이다.

 

상수 파일은 아래와 같이 존재한다.

SUCCESS_ID를 인자로 받으면 성공 동작을 수행해야한다.

FAIL_ID를 받으면 실패 동작, EXCEPTION_ID를 받으면 예외 발생을 수행해야한다.

 

전역 상수 파일로 테스트 더블의 동작을 규칙으로 정한 것이다.

이는 테스트 더블 동작을 일관되게 제어하고 사용하는 곳에서도 이름이 부여된 상수를 통해 코드 파악을 용이하게 할 수 있는 장점을 가져온다. 

자세한 방법은 Stub을 소개하며 설명하겠다.

object MockTestConstant {
    // 성공 ID
    val SUCCESS_ID = 1L

    // 스텁에서 사용하는 예외 메시지
    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 구현


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

stub을 구현하는데 일관된 규칙을 준다면 stub 작성도 빠르게 진행할 수 있으며,

무엇보다 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가 반환되도록 하였다.

 

따라서 아래와 같이 메서드의 인자값에 EXIST_ID와 NOT_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(NOT_EXIST_ID, 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를 대체한 것이다.

 

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

 

영속화 stub에서 위와 같이 FAIL, EXECPTION 인자값을 사용함으로써 성공/실패/예외 케이스 테스트를 모두 분류하고 일관되게 사용할 수 있다.

 

 

예제3 - 특정 Stub에 대한 구체적 제어 

이전의 예제에서 사용된 상수는 SUCCESS_ID, FAIL_ID, EXIST_ID와 같이 어느 클래스에서나 범용적으로 사용될 수 있는 상수들이었다. 그리하여 전역 상수파일에 상수를 선언하여 일관되게 동작시키도록 하였다.

허나, 특정 Stub에서만 사용되는 값으로 구체적인 제어가 필요할 수 있다.

이런 경우는 Stub에 직접 상수를 선언하여 동작을 제어하였다.

class MockMemberVerifyCodeReadOrmPort : MemberVerifyCodeReadOrmPort {
    companion object {
        // 만료된 코드를 가진 회원 이메일
        const val EXPIRE_CODE_EMAIL = "expire@test.com"

        // 코드를 갖고 있지 않은 회원 이메일
        const val CODE_NOT_EXIST_EMAIL = "codeNotExist@test.com"

        // 정상 케이스 시 코드 반환값
        const val VALID_RETURN_CODE = "5c1d1a9a-3e12-488c-be48-88fdb92c2dd0"
        
        // 만료된 코드
        const val EXPIRE_VALID_CODE = "7a4e1a9a-3e12-488c-be48-88fdb92c2dd0"

        // 아래 코드로 테스트 불일치 실패 발생
        const val MIS_MATCH_CODE = "1c1d1a9a-3e12-488c-be48-88fdb92c2dd0"
    }

    override fun readByEmailAndCodeType(email: String, codeType: VerifyCodeType): MemberVerifyCodeVo? {
        return when (email) {
            CODE_NOT_EXIST_EMAIL -> null
            EXPIRE_CODE_EMAIL -> MemberVerifyCodeVo(EXPIRE_VALID_CODE, LocalDateTime.now().minusMinutes(20))
            else -> MemberVerifyCodeVo(VALID_RETURN_CODE, LocalDateTime.now())
        }
    }
}

 

위와 같이 시나리오가 구체적인 경우, 인스턴스에 직접 표현하는 것이 파악하기 쉽고 동작을 제어하는데 편리함을 가져다 줄 수 있다.

@Test
fun `인증 코드 미존재 - 실패`() {
    // given
    val email = CODE_NOT_EXIST_EMAIL
    val signUpDto =
        MemberVerifyCodeDto(email, REQ_VERIFY_CODE, VerifyCodeType.SignUp)

    // when & then
    val exception = assertThrows<BusinessInValidException> {
        memberVerifyCodeReadUseCase.validate(signUpDto)
    }
    assertThat(exception.msg).isEqualTo(NOT_EXIST_CODE)
}

 

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);

 

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

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

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

 

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

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

 

후기


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

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

 

모키토를 제거하고 나는 아래과 같은 특장점을 얻었다.

  • 컴파일 타임에 작성된 테스트 더블 사용으로 성능 향상
    모키토는 런타임에 바이트 코드를 생성하여 테스트 더블을 만들기에 개발자가 직접 작성한 클래스 파일에 비해 성능이 느리다. 

  • 테스트 더블 재사용성 향상
    모키토는 테스트마다 생성하고 제어하므로 재사용성이 떨어진다. 재사용성이 높은 prod 코드일수록 모키토를 사용하는 테스트 코드에서는 떨어지게 되는 것이다. 테스트 더블을 직접 작성하게 되면 매번 새롭게 생성할 필요 없이 생성된 클래스 파일을 가져와 재사용할 수 있다.

  • 가독성 향상
    when과 같은 테스트 더블을 제어하는 모키토 문법이 테스트 코드에서 제거된다.
    테스트 더블 작성시 본인이 규칙을 정할 수 있는데, 나는 인자값에 따라 성공, 실패, 예외를 발생시키도록 하였다.
    또한 인자값 상수에 SUCCESS_ID, FAIL_ID, EXCEPTION_ID와 같은 이름을 주어 테스트 코드에서 어떤 케이스를 테스트하는지 파악하기 쉽게 구현을 하였다.

  • 테스트 코드 작성 시간 단축
    SUCCESS_ID, FAIL_ID, EXCEPTION_ID와 같은 인자값에 따라 성공/실패/예외 동작을 하는 테스트 더블을 구현하게 되면 사용할 때에도 빠르게 작성하는데 도움이 된다. 
    재사용이 높은 prod 코드일수록 한번 작성한 더블을 여러 테스트 코드에서 재사용할 수 있다. 

 

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

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

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

 

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

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

반응형