본문 바로가기
카테고리 없음

MockK 오픈소스 프로젝트 컨트리뷰션

by taebong2 2025. 3. 21.

Pull Request

https://github.com/mockk/mockk/pull/1340

 

[#1304] feat: Restrict mocking of certain classes and add configuration option by devtaebong · Pull Request #1340 · mockk/moc

Summary This PR introduces a mechanism to prevent mocking of certain classes that are generally considered bad practice. By default, attempting to mock restricted classes will log a warning. Additi...

github.com

 

문제정의

MockK는 Kotlin에서 사용되는 대표적인 Mocking 라이브러리입니다. 하지만 일부 클래스를 모킹하면 테스트의 신뢰성이 떨어지거나 예상치 못한 문제가 발생할 수 있습니다. 예를 들어, 다음과 같은 클래스들은 일반적으로 모킹을 피해야 합니다.

  • 시스템 클래스 (System)
  • 컬렉션 (HashMap, Collection)
  • 입출력 처리 클래스 (File, Path)

하지만 MockK는 현재 이러한 클래스 모킹을 막거나 사용자에게 경고하지 않아, 잘못된 사용이 발생할 가능성이 있었습니다.

초기 접근 방법 및 피드백

처음에는 가장 직관적으로 테스트 코드에서 어노테이션을 사용해 모킹 제한을 명시적으로 지정할 수 있는 방식을 생각했습니다. 각 테스트마다 커스텀 제한 클래스제한 모드(경고 혹은 예외)를 설정할 수 있도록 @MockingRestrictedExtension이라는 어노테이션을 제안했습니다.

 

@Test
@MockkRestricted(mode = MockkRestrictedMode.EXCEPTION, restricted = [Foo::class])
fun testCustomRestrictedClass() {
    assertThrows<IllegalArgumentException> {
        mockk<Foo>()
    }
}

@Test
@MockkRestricted(mode = MockkRestrictedMode.WARN, restricted = [Foo::class])
fun testCustomRestrictedClass() {
    assertThrows<IllegalArgumentException> {
        mockk<Foo>()
    }
}

이 방법이 처음엔 직관적이라 생각했고, 각 테스트에 금지클래스를 커스텀하게 설정할 수 있다는 것이 장점이라고 생각했습니다.

하지만 리뷰어의 피드백을 통해 모든 테스트에 금지 클래스를 반복적으로 설정해야 하는 점, 그리고 외부 라이브러리 클래스나 프로젝트 전체에 전역적으로 제한을 걸기 어렵다는 한계를 인지하게 되었습니다.

 

리뷰어의 제안과 나의 질문

리뷰어는 어노테이션 방식 대신 전역적인 설정 파일(mockk.properties)을 통해 관리하는 방식을 제안했습니다.

어노테이션을 통한 제한 클래스 관리는 테스트마다 설정이 필요하고, 외부 라이브러리 클래스의 제한이 어렵다는 한계를 설명하며, 전역 설정 파일을 통해 금지 클래스를 관리하는 것이 더 유연한 방식이라고 설명

 

 

각 테스트마다 어노테이션을 통해 금지 클래스를 독립적으로 설정하는 방식이 더 유연하다고 생각했기 때문에 리뷰어가 제안한 ‘파일을 통한 전역 설정 방식’이 어색하게 느껴졌습니다. 하지만 리뷰어의 의견과 저의 의견을 좁히기 위해 다음과 같은 질문을 남겼습니다.

현재 어노테이션을 사용하지 않으면 기본적으로 금지 클래스는 경고 로그가 출력됩니다. 어노테이션은 커스텀 금지 클래스를 추가하거나 예외를 던지는 옵션을 추가할 때 사용하면 됩니다.

이전에 리뷰해 준 내용을 정확히 이해한게 맞는지 아래와 같은 질문을 하고싶어요.
1. mockk.properties 파일에 사용자가 명시적으로 금지 클래스를 등록하는게 맞나요?
2. mockk.properties의 설정이 전체 테스트에 전역적으로 설정되는 건가요?

 

리뷰어의 답변을 통한 납득과 방향 전환

리뷰어의 추가 답변을 통해 저는 전역 설정 방식의 장점을 확실히 이해하게 되었습니다.

사용자가 설정 파일에 제한 클래스를 등록하면, 기본적으로 모든 테스트에서 예외 또는 경고가 발생하도록 하는 것이 좋습니다. 오히려 제한을 우회할 필요가 있는 경우에만 어노테이션(@AllowMockingRestrictedClasses)을 사용하는 방식이 더 합리적이라고 생각합니다.

 

이 답변을 통해 저는 제가 초기에 생각했던 방식(어노테이션 기반의 명시적 설정)보다 리뷰어의 전역 설정 파일 방식이 더 효율적이고 현실적이라는 점에 동의하게 되었습니다. 특히, 프로젝트 전체에서 일관된 규칙을 쉽게 적용할 수 있고, 설정 관리가 간편해지는 장점이 명확해 보였습니다.

 

최종 해결 방안과 구체적인 설계

리뷰어의 의견을 받아들여 최종적으로 전역적인 설정 파일 기반의 접근 방식을 채택하고, 이를 코드로 구체화했습니다. 다음은 구현 과정에서 제가 고민한 내용을 포함한 최종 해결 방안입니다.

 

1. 설정 로딩 방식을 추상화하기 위한 전략 패턴 도입

처음부터 설정파일 로딩이 테스트 환경과 프로덕션 환경에서 다르게 동작하는 문제를 예상했습니다. 따라서 설정 로딩 방식을 추상화하여 각 환경별로 적절한 구현체를 사용하도록 코드를 작성했습니다.

  • 프로덕션 환경에서는 resources 폴더 아래 있는 mockk.properties 파일을 읽어 금지 클래스를 설정해야합니다.
  • mockk 프로젝트 내부에는 resources 폴더가 없어 mockk.properties 파일을 별도로 정의 할 수 없는 문제가 있었습니다.

공통 인터페이스 (PropertiesLoader) 정의:

interface PropertiesLoader {
    fun loadProperties(): Properties
}

 

프로덕션 환경에서는 클래스 로더로부터 직접 파일을 로드할 수 있는 DefaultPropertiesLoader를 구현:

class DefaultPropertiesLoader : PropertiesLoader {
    override fun loadProperties(): Properties {
        val properties = Properties()
        val resourceStream = Thread.currentThread().contextClassLoader.getResourceAsStream("mockk.properties")
        resourceStream?.use { properties.load(it) }
        return properties
    }
}

 

테스트 환경에서는 설정 파일을 생성할 수 없으므로 직접 설정 값을 주입하는 TestPropertiesLoader를 추가:

class TestPropertiesLoader(private val mockProperties: Map<String, String>) : PropertiesLoader {
    override fun loadProperties(): Properties {
        val properties = Properties()
        mockProperties.forEach { (key, value) -> properties[key] = value }
        return properties
    }
}

 

2. 제한 클래스를 관리하는 RestrictMockkConfiguration 설계

제한 클래스를 설정 파일에서 유연하게 관리할 수 있도록 RestrictMockkConfiguration 클래스를 개발했습니다. 이 클래스에서 기본 금지 클래스와 사용자가 정의한 추가 금지 클래스를 관리하며, 예외 발생 여부까지 설정 파일에서 제어할 수 있게 했습니다.

class RestrictMockkConfiguration(
		propertiesLoader: PropertiesLoader = DefaultPropertiesLoader()
) {
    val restrictedTypes: Set<String>
    val throwExceptionOnBadMock: Boolean

    init {
        val properties = propertiesLoader.loadProperties()
        val userDefinedRestrictedTypes = properties.getProperty("mockk.restrictedClasses", "")
            .split(",")
            .map { it.trim() }
            .filter { it.isNotEmpty() }
            .toSet()

        restrictedTypes = DEFAULT_RESTRICTED_CLAZZ + userDefinedRestrictedTypes
        throwExceptionOnBadMock = properties.getProperty("mockk.throwExceptionOnBadMock", "false").toBoolean()
    }

    companion object {
        private val DEFAULT_RESTRICTED_CLAZZ = setOf(
            "java.lang.System",
            "java.util.Collection",
            "java.util.HashMap",
            "java.io.File",
            "java.nio.file.Path"
        )
    }
}

 

3. 모킹 검증을 위한 MockkValidator 클래스 구현

킹 시도 시 설정된 제한 클래스를 검증하고, 위반할 경우 경고 혹은 예외를 발생시키는 기능을 개발했습니다. 여기서 고민한 점은 클래스의 상속 관계를 고려하여 부모 클래스가 제한되었을 때 자식 클래스까지도 제한될 수 있도록 하는 것이었습니다.

class MockkValidator(
		private val configuration: RestrictMockkConfiguration
) {
    fun validateMockableClass(clazz: KClass<*>) {
        if (clazz.qualifiedName in configuration.restrictedTypes || 
		        configuration.restrictedTypes.any { restricted ->
                try {
                    Class.forName(restricted).isAssignableFrom(clazz.java)
                } catch (e: ClassNotFoundException) {
                    false
                }
            }) {
            if (configuration.throwExceptionOnBadMock) {
                throw MockKException("Mocking \${clazz.qualifiedName} is not allowed!")
            }
        }
    }
}

Kotlin 코드에 try-catch 가 있는것과 if식 내부에 if가 중첩되어 있는것이 불편해서 리팩토링하려고 했으나, 그대로 머지되어 버렸습니다.

 

4. mockk() 함수 수정

모킹이 이루어질 때 자동으로 클래스를 검증하기 위해, 기존의 mockk() 함수 자체에 MockkValidator를 통합했습니다. 이 과정에서 기존 사용성을 해치지 않고, 모킹 시 자연스럽게 제한 검증이 수행될 수 있도록 하는 점을 고민했습니다.

inline fun <reified T : Any> mockk(
    name: String? = null,
    relaxed: Boolean = false,
    vararg moreInterfaces: KClass<*>,
    relaxUnitFun: Boolean = false,
    mockValidator: MockkValidator = MockkValidator(RestrictMockkConfiguration()),
    block: T.() -> Unit = {},
): T = MockK.useImpl {
    mockValidator.validateMockableClass(T::class)
    MockKDsl.internalMockk(name, relaxed, moreInterfaces, relaxUnitFun, block)
}

 

테스트를 통한 검증 과정

구현한 기능이 올바르게 동작하는지 확인하기 위해 다음과 같은 테스트를 작성했습니다. 예외 발생 여부와 사용자 정의 제한 클래스 추가 기능, 컬렉션 및 I/O 관련 기본 제한 클래스의 정상 동작을 검증했습니다.

 

컬렉션 클래스 모킹 제한 검증:

@Test
fun `when throwExceptionOnBadMock is true should throw MockException for collections`() {
    val config = mapOf(
        "mockk.throwExceptionOnBadMock" to "true",
        "mockk.restrictedClasses" to "java.util.Collection, java.util.Map"
    )

    val testValidator = MockkValidator(
        RestrictMockkConfiguration(TestPropertiesLoader(config))
    )

    assertThrows<MockKException> { mockk<HashMap<String, String>>(mockValidator = testValidator) }
    assertThrows<MockKException> { mockk<ArrayList<Int>>(mockValidator = testValidator) }
    assertThrows<MockKException> { mockk<LinkedList<Double>>(mockValidator = testValidator) }
}

 

사용자 정의 제한 클래스 추가 검증:

@Test
fun `when add custom restricted class should throw exception`() {
    val config = mapOf(
        "mockk.throwExceptionOnBadMock" to "true",
        "mockk.restrictedClasses" to "io.mockk.restrict.Foo"
    )

    val testValidator = MockkValidator(
        RestrictMockkConfiguration(TestPropertiesLoader(config))
    )

    assertThrows<MockKException> {
        mockk<Foo>(mockValidator = testValidator)
    }
}

open class Foo

 

상속 클래스 제한 검증:

@Test
fun `when add custom restricted sub-class should throw exception`() {
    val config = mapOf(
        "mockk.throwExceptionOnBadMock" to "true",
        "mockk.restrictedClasses" to "io.mockk.restrict.Foo"
    )

    val testValidator = MockkValidator(
        RestrictMockkConfiguration(TestPropertiesLoader(config))
    )

    assertThrows<MockKException> {
        mockk<FooChild>(mockValidator = testValidator)
    }

    assertDoesNotThrow {
        mockk<Bar>(mockValidator = testValidator)
    }
}

open class Foo
class Bar
class FooChild : Foo()

 

프로젝트를 통해 얻은 경험

리뷰어와의 소통과 피드백을 통해 설정 파일 기반 관리 방식의 장점과 필요성을 이해하게 되었습니다. 특히 전역 설정 방식을 통해 프로젝트의 규칙을 일관성 있게 유지할 수 있을 뿐 아니라, 관리 비용을 최소화하고, 코드의 유지보수성을 크게 향상시킨다는 점을 깨달았습니다.

 

처음에는 코드 내 어노테이션 방식이 더욱 직관적이라 생각했으나, 실제로 구현하고 나니 설정 파일 기반 방식이 협업 환경에서 더욱 효율적이라는 점을 알게 되었습니다. 또한, 제가 직접 고민하여 설계하고 작성한 코드가 실제 MockK 라이브러리에 포함되어 릴리스되고, 다양한 개발자들이 이를 사용하면서 발생 가능한 오류를 미연에 방지할 수 있게 되었다는 점에서 보람을 느끼고 있습니다.