如何优雅地单元测试 Kotlin/Java 中的 private 方法?

本文探讨了Java和Kotlin中私有方法的单元测试问题。对于是否测试私有方法开发者观点不一,文中介绍了几种单元测试私有方法的选择,重点阐述了使用Java反射机制访问私有属性和方法,并给出代码示例,最后展示了如何用反射进行单元测试,帮助开发者覆盖私有方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在这里插入图片描述

翻译自 https://medium.com/mindorks/how-to-unit-test-private-methods-in-java-and-kotlin-d3cae49dccd

❓如何单元测试 Kotlin/Java 中的 private 方法❓

首先,开发者应该测试代码里的 private 私有方法吗?

直接信任这些私有方法,测试到调用它们的公开方法感觉就够了吧。

对于这个争论,每个开发者都会有自己的观点。

但回到开头的问题本身,到底有没有一种合适的途径来实现私有方法的单元测试

截止到目前,在面对单元测试私有方法的问题时,一般有如下几种选择:

  1. 不去测试私有方法 😜*(选择信任,直接躺平)*

  2. 将目标方法临时改成 public 公开访问权限 😒(可我不愿意这样做,这不符合代码规范。作为一名开发者,我要遵循最佳实践

  3. 使用嵌套的测试类 😒*(将测试代码和生产代码混到一起不太好吧,我再强调一遍:我是很优秀的开发者,要遵循最佳实践)*

  4. 使用 Java 反射机制 😃*(听起来还行,可以试试这个方案)*

大家都知道通过 Java 反射机制可以访问到其他类中的私有属性和方法,而且写起来也不麻烦,在单元测试里采用该机制应该也很容易上手。

注意

只有将代码作为独立的 Java 程序运行时,这个方案才适用,就像单元测试、常规的 Java 应用程序。但如果在 Java Applet 上执行反射,则需要对 SecurityManager 做些干预。由于这不是高频场景,本文不对其作额外阐述。

Java 8 中添加了对反射方法参数的支持,使得开发者可以在运行时获得参数名称。

访问私有属性

Class 类提供的 getField(String name)getFields() 只能返回公开访问权限的属性,访问私有权限的属性则需要调用 getDeclaredField(String name)getDeclaredFields()

下面是一个简单的代码示例:一个拥有私有属性的类以及如何通过 Java 反射来访问这个属性。

public class PrivateObject {
    private String privateString = null;
    
    public PrivateObject(String privateString) {
        this.privateString = privateString;
    }
}

PrivateObject privateObject = new PrivateObject("The Private Value");
Field privateStringField = PrivateObject.class.getDeclaredField("privateString");

privateStringField.setAccessible(true);

String fieldValue = (String) privateStringField.get(privateObject);

System.out.println("fieldValue = " + fieldValue);

上述代码将打印出如下结果:内容来自于 PrivateObject 实例的私有属性 privateString 的值。

fieldValue = The Private Value

需要留意的是,getDeclaredField("privateString") 能返回私有属性没错,但其范围仅限 class 本身,不包含其父类中定义的属性。

还有一点是需要调用 Field.setAcessible(true),目的在于关闭反射里该 Field 的访问检查。

这样的话,如果访问的属性是私有的、受保护的或者包可见的,即使调用者不满足访问条件,仍然可以在反射里获取到该属性。当然,非反射的正常代码里依然无法获取到该属性,不受影响。

访问私有方法

和访问私有属性一样,访问私有方法需要调用 Class 类提供的 getDeclaredMethod(String name, Class[] parameterTypes)Class.getDeclaredMethods()

同样的,我们展示一段代码示例:定义了私有方法的类以及通过反射访问它。

public class PrivateObject {
    private String privateString = null;
    
    public PrivateObject(String privateString) {
        this.privateString = privateString;
    }
    
    private String getPrivateString(){
        return this.privateString;
    }
}

PrivateObject privateObject = new PrivateObject("The Private Value");
Method privateStringMethod = PrivateObject.class.getDeclaredMethod("getPrivateString", null);

privateStringMethod.setAccessible(true);String returnValue = (String)
privateStringMethod.invoke(privateObject, null);

System.out.println("returnValue = " + returnValue);

打印出的结果来自于 PrivateObject 实例中私有方法 getPrivateString() 的调用结果。

returnValue = The Private Value

注意点和访问私有属性一样:

  1. getDeclaredMethod() 存在 class 本身的范围限制,不能获取到父类中定义的任何方法
  2. 需要调用 Method.setAcessible(true) 来关闭反射中的 Method 的访问权限检查,确保即便不满足访问条件,亦能在反射中成功访问

了解完通过反射来访问私有属性、方法的知识之后,让我们用在 unit test 中来测试本来难以覆盖到的私有方法。

LoginPresenter.kt

比如,我们的代码库中存在如下类 LoginPresenter,并且咱们想要去单元测试其私有方法 saveAccount()

class LoginPresenter @Inject constructor(
    private val view: LoginView,
    private val strategy: CancelStrategy,
    private val navigator: AuthenticationNavigator,
    private val tokenRepository: TokenRepository,
    private val localRepository: LocalRepository,
    private val settingsInteractor: GetSettingsInteractor,
    private val analyticsManager: AnalyticsManager,
    private val saveCurrentServer: SaveCurrentServerInteractor,
    private val saveAccountInteractor: SaveAccountInteractor,
    private val factory: RocketChatClientFactory,
    val serverInteractor: GetConnectingServerInteractor
) {
    private var currentServer = serverInteractor.get() ?: defaultTestServer
    private val token = tokenRepository.get(currentServer)
    private lateinit var client: RocketChatClient
    private lateinit var settings: PublicSettings

    fun setupView() {
        setupConnectionInfo(currentServer)
        setupForgotPasswordView()
    }

    private fun setupConnectionInfo(serverUrl: String) {
        currentServer = serverUrl
        client = factory.get(currentServer)
        settings = settingsInteractor.get(currentServer)
    }

    private fun setupForgotPasswordView() {
        if (settings.isPasswordResetEnabled()) {
            view.showForgotPasswordView()
        }
    }

    fun authenticateWithUserAndPassword(usernameOrEmail: String, password: String) {
        launchUI(strategy) {
            view.showLoading()
            try {
                val token = retryIO("login") {
                    when {
                        settings.isLdapAuthenticationEnabled() ->
                            client.loginWithLdap(usernameOrEmail, password)
                        usernameOrEmail.isEmail() ->
                            client.loginWithEmail(usernameOrEmail, password)
                        else ->
                            client.login(usernameOrEmail, password)
                    }
                }
                val myself = retryIO("me()") { client.me() }
                myself.username?.let { username ->
                    val user = User(
                        id = myself.id,
                        roles = myself.roles,
                        status = myself.status,
                        name = myself.name,
                        emails = myself.emails?.map { Email(it.address ?: "", it.verified) },
                        username = username,
                        utcOffset = myself.utcOffset
                    )
                    localRepository.saveCurrentUser(currentServer, user)
                    saveCurrentServer.save(currentServer)
                    localRepository.save(LocalRepository.CURRENT_USERNAME_KEY, username)
                    saveAccount(username)
                    saveToken(token)
                    analyticsManager.logLogin(
                        AuthenticationEvent.AuthenticationWithUserAndPassword,
                        true
                    )
                    view.saveSmartLockCredentials(usernameOrEmail, password)
                    navigator.toChatList()
                }
            } catch (exception: RocketChatException) {
                when (exception) {
                    is RocketChatTwoFactorException -> {
                        navigator.toTwoFA(usernameOrEmail, password)
                    }
                    else -> {
                        analyticsManager.logLogin(
                            AuthenticationEvent.AuthenticationWithUserAndPassword,
                            false
                        )
                        exception.message?.let {
                            view.showMessage(it)
                        }.ifNull {
                            view.showGenericErrorMessage()
                        }
                    }
                }
            } finally {
                view.hideLoading()
            }
        }
    }

    fun forgotPassword() = navigator.toForgotPassword()

    private fun saveAccount(username: String) {
        val icon = settings.favicon()?.let {
            currentServer.serverLogoUrl(it)
        }
        val logo = settings.wideTile()?.let {
            currentServer.serverLogoUrl(it)
        }
        val thumb = currentServer.avatarUrl(username, token?.userId, token?.authToken)
        val account = Account(
            settings.siteName() ?: currentServer,
            currentServer,
            icon,
            logo,
            username,
            thumb
        )
        saveAccountInteractor.save(account)
    }

    private fun saveToken(token: Token) = tokenRepository.save(currentServer, token)
}

LoginPresenterTest.kt

单元测试的整体如下:

class LoginPresenterTest {
    private val view = mock(LoginView::class.java)
    private val strategy = mock(CancelStrategy::class.java)
    private val navigator = mock(AuthenticationNavigator::class.java)
    private val tokenRepository = mock(TokenRepository::class.java)
    private val localRepository = mock(LocalRepository::class.java)
    private val settingsInteractor = mock(GetSettingsInteractor::class.java)
    private val analyticsManager = mock(AnalyticsManager::class.java)
    private val saveCurrentServer = mock(SaveCurrentServerInteractor::class.java)
    private val saveAccountInteractor = mock(SaveAccountInteractor::class.java)
    private val factory = mock(RocketChatClientFactory::class.java)
    private val serverInteractor = mock(GetConnectingServerInteractor::class.java)
    private val token = mock(Token::class.java)
    
   
    const val currentServer: String = "https://open.rocket.chat"
    const val USERNAME: String = "user121"
    const val PASSWORD: String = "123456"
    
    lateinit var loginPresenter: LoginPresenter

    private val account = Account(
        currentServer, currentServer, null,
        null, USERNAME, UPDATED_AVATAR
    )

    @Before
    fun setUp() {
        MockitoAnnotations.initMocks(this)
        `when`(strategy.isTest).thenReturn(true)
        `when`(serverInteractor.get()).thenReturn(currentServer)
        loginPresenter = LoginPresenter(
            view, strategy, navigator, tokenRepository, localRepository, settingsInteractor,
            analyticsManager, saveCurrentServer, saveAccountInteractor, factory, serverInteractor
        )
    }

    @Test
    fun `check account is saved`() {
        ...
    }
}

通过反射机制,私有方法 saveAccount() 的单测则可以很方便地进行。

class LoginPresenterTest {
    ...
    @Test
    fun `check account is saved`() {
        loginPresenter.setupView()

        val method = loginPresenter.javaClass.getDeclaredMethod("saveAccount", String::class.java)
        method.isAccessible = true

        val parameters = arrayOfNulls<Any>(1)
        parameters[0] = USERNAME

        method.invoke(loginPresenter, *parameters)
        verify(saveAccountInteractor).save(account)
    }
}

本文浅显易懂,希望能向你展示反射的魔力,帮助开发者在单元测试中优雅、便捷地 cover 到私有方法!

最后,感谢你的阅读。

### KotlinJava混合开发的最佳实践 #### 1. **项目结构设计** 在KotlinJava混合开发中,合理的项目结构调整至关重要。通常建议将Kotlin文件集中放置在一个特定的模块或包下,以便于管理和维护[^1]。 ```java // Java 类调用 Kotlin 函数示例 public class JavaCaller { public void callKotlinFunction() { new KotlinClass().greet(); // 调用 Kotlin 中定义的方法 } } ``` ```kotlin // Kotlin 文件中的公开方法Java 调用 class KotlinClass { fun greet() { println("Hello from Kotlin!") } } ``` --- #### 2. **利用Kotlin的扩展函数简化代码** 通过Kotlin的扩展函数功能,可以显著减少重复代码并提高可读性。这种特性尤其适合用于增强现有的Java类[^3]。 ```kotlin fun String.reverseString(): String { return this.reversed() } // 在 Java 中使用此扩展函数 public class TestExtensionFunctions { public void testReverse() { String result = "hello".reverseString(); System.out.println(result); // 输出 olleh } } ``` --- #### 3. **处理空指针的安全机制** Kotlin内置了强大的空安全支持,在混合开发场景中尤为重要。可以通过`?`运算符或者`!!`强制解包等方式优雅地解决潜在的NullPointerException问题。 ```kotlin fun safeCall(javaObject: Any?) { val length = javaObject?.toString()?.length ?: 0 // 安全调用链 println("Length is $length") } ``` --- #### 4. **Gradle构建工具的支持** 为了实现高效的混编环境设置,推荐使用最新版的Gradle插件,并确保正确配置Kotlin插件版本。此外,对于复杂的跨平台需求(如移动端),还可以借助KonanCompileTask完成定制化任务[^2]。 ```groovy plugins { id 'org.jetbrains.kotlin.jvm' version '1.8.20' } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib" } ``` --- #### 5. **性能优化与内存管理** 理解JVM内部工作原理有助于更好地控制程序行为。例如,合理分配对象到堆空间的不同区域能够有效降低垃圾回收频率;同时注意避免频繁创建短生命周期的对象影响效率[^4]。 ```java // 避免不必要的临时对象生成 private final StringBuilder buffer = new StringBuilder(); public String concatenateStrings(List<String> list) { synchronized (buffer) { buffer.setLength(0); for (String s : list) { buffer.append(s).append(","); } return buffer.toString(); } } ``` --- #### 6. **测试驱动开发(TDD)** 无论是纯Java还是引入Kotlin的部分,都应坚持采用单元测试覆盖核心逻辑的方式验证质量。JUnit框架兼容良好,可以直接应用于此类场景下的自动化检验过程。 ```kotlin import org.junit.jupiter.api.Test import kotlin.test.assertEquals internal class UtilityTest { @Test fun `test string reversal`() { assertEquals("olleh", "hello".reverse()) } } ``` ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

TechMerger

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值