Unit Testing: Lessons learned so far

Arun Yogeshwaran
6 min readFeb 8, 2023

Adding a unit test is like hiring a dedicated tester to ensure the intended behavior of a method always stays intact.

Having emphasized the importance of unit testing, here are some of the best practices I learned over time while working with unit tests.

Test Clarity

The name of the test method should be as descriptive as possible to clearly explain the behavior that it is testing. The contents of the test should preserve the relevant information and abstract the distracting details.

Generally, the code required to build the object under test and the fetching of test data can be moved to a helper function while the arrangements needed to call the method under test should always be present inside the test body.

Look at the below example of a simple system that fetches jobs with a user who can be registered or unregistered.

Descriptive test name example

The name of the first test method doesn’t give any details about what is being tested inside whereas the second test method clearly mentions the context under which the method is tested and also the expected result. This is particularly helpful for new developers who try to understand the code as the test names are clearly stating how the code should behave under various conditions.

The following example demonstrates the clarity of a test — A system that fetches ML-based job recommendations.

An example of preserving necessary details right in the test itself

In ClearUnitTest.kt, the important details with regard to the test case (It’s about a new user getting ML recommendation) can be seen evidently in the test case whereas in UnclearUnitTest.kt relies on the helper method getTestUserInfo() to get the new user.

Verification through state

Unit testing of a method can be done through either Interaction testing or State testing.

  • Interaction testing aims to verify that the method under test takes a certain route (by calling other methods) while intending to achieve the final state.
  • State testing doesn’t care about the internals of the system under test and just verifies the final state of the system after the method is invoked.

State testing should be preferred over Interaction testing because the latter can easily lead to false positives and false negatives because of the following reasons:

  1. When the method under test is refactored to call a different set of methods to arrive at the same result, the test would fail even though the behavior of the method is unaltered.
  2. If a logical change is introduced in one of the methods that the test method calls, the behavior of the system is altered but the test would still pass as the interaction between the methods is not changed.

Case 2 is even more critical because it gives an impression that the test is passing when it’s supposed to fail and points out the bug. In both cases, the unit test requires a change which leads to unnecessary test maintenance work.

Look at the below example of a system that toggles the favorite state of a given job in the cache.

Interaction testing vs State testing

Notice how the second test case verifies the end state of the job in the cache rather than just verifying the interaction with the cache API.

Use the facade to test

What this means is to test the class using the public-facing APIs and to avoid annotating the private methods with VisibleForTesting as much as possible. This also ensures that the production code changes only with a change in business requirements and the test code doesn’t break when the internal details of a class are altered.

This also aligns with the Abstraction principle — Meaning, the consumers of a class would only interact with the public-facing APIs of the class and not with the private details of the class. Similarly, unit tests should also test the public APIs without trying to interact with the private methods.

This is especially useful for library projects where a unit test failure would easily indicate a breaking behavior in the consumers of the library.

When we are talking about public-facing APIs, it’s also important to define what “public” means in this context and it doesn’t always mean the public visibility modifier provided by some programming languages. Rather, it depends on what constitutes a unit with regard to the system being tested.

In some cases, a class or a method can be considered a unit, in some other cases, a package as a whole constitutes a unit. So, the definition of a unit varies based on the usage and so is the definition of public-facing APIs.

Look at the below example of a class that contains logic to refer a friend based on certain internal conditions.

Testing using Public APIs vs Testing internal details

Notice how TestUsingPublicApi.kt tests only the public-facing APIs while TestInternalDetails.kt weakens the visibility of private methods using VisibleForTesting annotation and tries to test the internal implementation details.

Test the behavior

While it may seem straightforward to add a test method for every other source method, this can lead to added complexity in tests when the method under test grows. This is the reason why behavior-driven testing should be preferred over method-driven testing.

So, it’s recommended to add a test for each behavior rather than for each method. A behavior can be defined as an action taken by the system to achieve a certain result when it is in a predefined state.

Behavior-driven tests can also be easily understood by someone who is aware of the system requirements and it also serves as indirect documentation of the code being tested. They also help in reducing the size of a test method, as only one behavior is tested at any time even though the method under test contains multiple functionalities.

Notice how TestingBehaviour.kt tests all the behaviors of the method separately and the TestingMethod.kt tests everything in a single method with multiple assertions which is not ideal.

Bullet-proof tests

A test should change if and only if the requirements of the system under test change.

Feature development and bug fixes are the most common changes to a project. In both these cases, the existing tests shouldn’t ideally change. Some bug fixes might require changes in an existing test case.

When a new feature is added to the codebase, the system’s existing behavior should remain unchanged. This simply means that existing unit tests in the project shouldn’t require any changes. If such a change is required, either the existing tests are imperfect or the new feature is likely to cause bugs in the product.

Similarly, a bug in the system means it was not caught by the unit tests and that indicates a missing test. So, any bug-fixing code change should be accompanied by a test case. In other cases, a bug can still occur even though a test method is present to verify the behavior and it means the test case is not good enough to verify the intended behavior and needs to be fixed along with the bug.

Finally, any kind of refactoring in the code shouldn’t require any change in the test cases as the existing behavior should never be affected during code refactoring. If it requires a change, then the refactoring is breaking the existing implementation.

Bonus

As an Android Developer, it’s quite common to think about whether the View layer(Such as Activities & Fragments) of the app should be unit-tested.

Of course, it is possible to unit-test view classes using frameworks such as Robolectric, and here is a good article about why it should be used sparingly.

Ideally, the UI layer of the app should be tested through UI testing frameworks such as Espresso. If there is a need to unit-test view classes, then that indicates business logic code is placed in the UI layer and it has to be extracted to other parts of the presentation layer such as ViewModel or Presenter class.

The recommendation is to extract code as much as possible from the classes that inherit from Android framework classes.

Thank you for reading through. Comments & Suggestions are welcome :)

--

--