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

翻译自 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 反射来访问这个属性。

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 的值。

bash 复制代码
fieldValue = The Private Value

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

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

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

访问私有方法

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

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

java 复制代码
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() 的调用结果。

bash 复制代码
returnValue = The Private Value

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

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

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

LoginPresenter.kt

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

kotlin 复制代码
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

单元测试的整体如下:

kotlin 复制代码
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() 的单测则可以很方便地进行。

kotlin 复制代码
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 到私有方法!

最后,感谢你的阅读。

相关推荐
Dxy12393102169 分钟前
MySQL如何加唯一索引
android·数据库·mysql
启山智软12 分钟前
【中大企业选择源码部署商城系统】
java·spring·商城开发
我真的是大笨蛋14 分钟前
深度解析InnoDB如何保障Buffer与磁盘数据一致性
java·数据库·sql·mysql·性能优化
怪兽源码42 分钟前
基于SpringBoot的选课调查系统
java·spring boot·后端·选课调查系统
恒悦sunsite1 小时前
Redis之配置只读账号
java·redis·bootstrap
梦里小白龙1 小时前
java 通过Minio上传文件
java·开发语言
人道领域1 小时前
javaWeb从入门到进阶(SpringBoot事务管理及AOP)
java·数据库·mysql
sheji52611 小时前
JSP基于信息安全的读书网站79f9s--程序+源码+数据库+调试部署+开发环境
java·开发语言·数据库·算法
毕设源码-邱学长1 小时前
【开题答辩全过程】以 基于Java Web的电子商务网站的用户行为分析与个性化推荐系统为例,包含答辩的问题和答案
java·开发语言
摇滚侠2 小时前
Java项目教程《尚庭公寓》java项目从开发到部署,技术储备,MybatisPlus、MybatisX
java·开发语言