DEV Community

Cover image for Kotlin - Unit Testing Classes Without Leaking Public API!
Stanislav Kozlovski
Stanislav Kozlovski

Posted on

Kotlin - Unit Testing Classes Without Leaking Public API!

Introduction

Kotlin is an amazing new up-and-coming language. It's very actively developed and has a ton of features that make it very appealing.
It's been steadily gaining market share ever since Google added Android Development support for it (back in 2017) and made it the preferred language for such development exactly one year ago (May 2019)

For any Java developer coming into the language, spoiler alert, there is one big surprise that awaits you --- the package-private visibility modifier is missing. It doesn't exist.

Access Modifiers

An access modifier is the way to set the accessibility of a class/method/variable in object-oriented languages. It's instrumental in facilitating proper encapsulation of components.

Java

In Java, we have four access modifiers for any methods inside a class:

  • void doSomething()- the default modifier, we call this package-private. Any such declarations are only visible within the same package (hence the name, private for the package)
  • private void doSomething() --- the private modifier, declarations of which are visible only within the same class
  • protected void doSomething() --- declarations only visible within the package or all subclasses.
  • public void doSomething() --- declarations are visible everywhere.

This wide choice of options allows us to tightly-control what our class exposes. It also gives us greater control over what constitutes a unit of testable code and what isn't. See the following crude example:

package doer.something;

/**
 * Does something.
 */
public class JSomethingDoer {

  private int motivationLevel;

  /**
   * @param motivationLevel a 0-100 integer, denoting how motivated the #{@link JSomethingDoer} is.
   */
  public JSomethingDoer(int motivationLevel) {
    this.motivationLevel = motivationLevel;
  }

  public void doSomething() {
    doOneLittleThing();
    maybeDoSecondLittleThing();
  }

  void doOneLittleThing() {
    System.out.println("Doing one thing.");
  }

  // package-private for testing
  void maybeDoSecondLittleThing() {
    if (feelingAmbitious()) {
      doSecondLittleThing();
    }
  }

  private void doSecondLittleThing() {
    System.out.println("We're overachievers, doing a second thing!!!");
  }

  private boolean feelingAmbitious() {
    return motivationLevel > 90;
  }
}
Enter fullscreen mode Exit fullscreen mode

We can only directly test three of the five methods above. Because doSecondLittleThing() and feelingAmbitious() are private, our unit tests literally cannot call those methods, hence we cannot test them directly.

The only way to test them is through the package-private method maybeDoSecondLittleThing() , which can be called in our unit tests because they're in the same package as the class they're testing.

package doer.something;

import org.junit.Before;
import org.junit.Test;

public class JSomethingDoerTest {

  private JSomethingDoer doer;

  @Before
  public void setUp() {
    doer = new JSomethingDoer(11);
  }

  @Test
  public void Test_doSomething() {
    doer.doSomething();
    // assert
  }

  @Test
  public void Test_doOneLittleThing() {
    doer.doOneLittleThing();
    // assert
  }

  @Test
  public void Test_maybeDoSecondLittleThing() {
    doer.maybeDoSecondLittleThing();
    // assert
  }
}
Enter fullscreen mode Exit fullscreen mode

The package-private modifier comes very useful in this example, because it allows us to test the maybeDoSecondLittleThing() method directly!
Testing would be more cumbersome if we had to test all code paths of the maybeDoSecondLittleThing() method by calling the public doSomething() method.

The package-private modifier also ensures that we don't leak any such internal methods to packages outside of this one.

Java IDE Example
Notice the different package doer in the Main class.

Kotlin

Kotlin is a bit weirder in this regard. It also has four modifiers:

  • private fun doSomething() --- vanilla private modifier
  • protected fun doSomething() --- the same as Java's protected --- only visible within this class and subclasses.
  • public fun doSomething() --- visible everywhere

These three are pretty standard. I want to focus on the fourth one:

  • internal fun doSomething() --- Internal declarations are visible anywhere inside the same module.

This is as good as public inside the same module, in that sense. Many small projects have no need for multiple modules, but they're likely to have multiple packages.

There is basically no way to limit access to a Kotlin method to only be within the same package.

What does this mean? It means that we either need to make our methods private, thus making our testing life harder, or make them public, thus exposing unnecessary API to components in the same module.

Our same example from above, again in the doer.something package:

package doer.something

/**
 * Does something.
 * @param motivationLevel a 0-100 integer, denoting how motivated the #[SomethingDoer] is.
 */
open class SomethingDoer(private val motivationLevel: Int) {
  fun doSomething() {
    doOneLittleThing()
    maybeDoSecondLittleThing()
  }

  protected fun doOneLittleThing() {
    println("Doing one thing.")
  }

  private fun maybeDoSecondLittleThing() {
    if (feelingAmbitious()) {
      doSecondLittleThing()
    }
  }

  private fun doSecondLittleThing() {
    println("We're overachievers, doing a second thing!!!")
  }

  private fun feelingAmbitious(): Boolean {
    return motivationLevel > 90
  }
}
Enter fullscreen mode Exit fullscreen mode

But this time, from the doer package, we can call every method:

IDE Kotlin Example

How do we solve it?

Solution 1 --- Refactor!

Us refactoring our code

It seems to be commonly believed that we shouldn't test methods that are/should be private. The idea being that:

  • all private methods are reachable by the public methods
  • the public methods expose the interface/contract of a class
  • private methods are implementation details, and we want to test the class' functionality, not implementation

While that makes sense in theory, reality is not as black-and-white.

Regardless, you should treat any need to test non-public methods as a code smell.
Re-evaluate your design and rethink whether it makes sense to refactor the logic around such that you get the same test coverage by testing public interface contracts, rather than implementation details.

In some cases, though, it is cleaner and more maintainable to get full test coverage by testing each tiny private method code thoroughly, rather than testing each conditional branch via public methods or creating unnecessary boilerplate (e.g additional classes).

Solution 2 --- Reflection

Reflection

An alternative solution is to keep the methods private and use the language's reflection features to call into said private methods.

Add a dependency on the kotlin-reflect library and code away!

  fun callPrivate(objectInstance: Any, methodName: String, vararg args: Any?): Any? {
    val privateMethod: KFunction<*>? =
        objectInstance::class.members.find { t -> return@find t.name == methodName } as KFunction<*>?

    val argList = args.toMutableList()
    (argList as ArrayList).add(0, objectInstance)
    val argArr = argList.toArray()

    if (privateMethod?.javaMethod?.trySetAccessible()!!) {
      privateMethod?.apply {
        return call(*argArr)
      } ?: throw NoSuchMethodException("Method $methodName does not exist in ${objectInstance::class.qualifiedName}")
    } else throw IllegalAccessException("Method $methodName could not be made accessible")

    return null
  }
Enter fullscreen mode Exit fullscreen mode

Woah.
Code and tests

Here, our callPrivate() test helper is the star of the show! It allows us to call into private methods, albeit in a funky way.
We need to specify the method name by a string, hence we lose all type checking guarantees. This results in fragile code that's also hard to read.

We can leverage a compiler plugin, like Manifold, to get type-safe meta-programming. But you can argue that takes it a bit too far, as it introduces a large dependency that involves a lot more meta-programming behind the scenes.

Solution 3 --- Open the class, protect the methods and test a wrapper!

A third, perhaps preferable way to solve this is with a bit of subclassing work.

First, we need to make our class open for extension via the open keyword. Then, we need to make our private methods protected.

open class SomethingDoer(private val motivationLevel: Int) {
  fun doSomething() {
    doOneLittleThing()
    maybeDoSecondLittleThing()
  }

  protected fun doOneLittleThing() {
    println("Doing one thing.")
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we can create a subclass inside the test class whose sole goal is to expose the protected method directly.

class SomethingDoerTest {

  class TestSomethingDoer(motivationLevel: Int) : SomethingDoer(motivationLevel) {
    fun testDoOneLittleThing() {
      super.doOneLittleThing()
    }
  }
  private lateinit var doer: SomethingDoer
  private lateinit var doerWrapper: TestSomethingDoer

  @Before
  fun setUp() {
    doer = SomethingDoer(99)
    doerWrapper = TestSomethingDoer(99)
  }

  @Test
  fun doSomething() {
    doer.doSomething()
    // assert
  }

  @Test
  fun Test_doOneLittleThing() {
    doerWrapper.testDoOneLittleThing()
    // assert
  }
}
Enter fullscreen mode Exit fullscreen mode

This is yet another imperfect solution to the problem which results in more boilerplate and a non-totally-private method.

It is better than the reflection approach in that it's type-safe, therefore less prone to breakage and significantly easier to maintain.

Summary

In this short article we went over the access modifiers of both Java and Kotlin and saw where Kotlin comes short. We proposed some solutions to Kotlin's lack of the package-private modifier with relation to testing, all with sufficient code examples!

Speaking of which, the code for this article live on GitHub --- here.

References

This problem has been hit by many, unsurprisingly. It makes sense given that most people come from the same background --- we miss our good old Java features 😭

Top comments (0)