如何单元测试 Kotlin 中的 private 方法?

翻译自: medium.com/mindorks/ho...

原作者:GOVIND DIXIT

❓如何单元测试 Kotlin 中的 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 反射来访问这个属性。

ini 复制代码
 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 的值。

ini 复制代码
 fieldValue = The Private Value

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

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

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

访问私有方法

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

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

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

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

最后,感谢你的阅读。

相关推荐
木头没有瓜33 分钟前
ruoyi 请求参数类型不匹配,参数[giftId]要求类型为:‘java.lang.Long‘,但输入值为:‘orderGiftUnionList
android·java·okhttp
键盘侠00735 分钟前
springboot 上传图片 转存成webp
android·spring boot·okhttp
江上清风山间明月1 小时前
flutter bottomSheet 控件详解
android·flutter·底部导航·bottomsheet
Crossoads3 小时前
【汇编语言】外中断(一)—— 外中断的魔法:PC机键盘如何触发计算机响应
android·开发语言·数据库·深度学习·机器学习·计算机外设·汇编语言
sunphp开发者4 小时前
黑客攻击网站,篡改首页问题排查修复
android·js
我又来搬代码了4 小时前
【Android Studio】创建新项目遇到的一些问题
android·ide·android studio
ggs_and_ddu8 小时前
Android--java实现手机亮度控制
android·java·智能手机
zhangphil14 小时前
Android绘图Path基于LinearGradient线性动画渐变,Kotlin(2)
android·kotlin
watl014 小时前
【Android】unzip aar删除冲突classes再zip
android·linux·运维
键盘上的蚂蚁-14 小时前
PHP爬虫类的并发与多线程处理技巧
android