Android unit testing at Zenjob

Arun Yogeshwaran
Zenjob Technology Blog
7 min readJul 11, 2023

At Zenjob, we work hard to ensure an excellent experience for our customers and our Talents.¹ We have quality checks embedded in every step of the development process, along with multiple tools in place to run those checks for every change we add to the codebase. Examples of these are Sonar, CodeScene, and Espresso UI tests.

Along with these tools, developers also add unit tests for any change in the business logic. Unit tests function like dedicated testers to ensure things behave as intended. This post will cover some of the best practices we try to follow with regard to Android unit testing at Zenjob.

Test clarity

The method name of a test is very important, and we try to keep the name of our test methods as descriptive as possible. This is so that anyone looking at a test case can quickly and easily understand what’s going on. Additionally, the contents of each test preserve the relevant details and conceal the distracting details by extracting the unimportant information to a helper method.

Generally, the code required to construct an object under test and the fetching of test data is moved to a helper function, while the arrangements needed to call the method under test are always present inside the test body.

The example below shows a simple system that fetches jobs for a user who can be either registered or unregistered.

DescriptiveTestNameExample.kt

The name of the first test method (line 12) doesn’t give any details about what’s being tested, whereas the second test method (line 21) clearly mentions the context under which the method is tested, along with the expected result. The latter is particularly helpful for any developer who’s trying to understand the code, as the test names clearly indicate how the code should behave under various conditions.

As another example, consider the following, which shows a system that fetches machine learning-based job recommendations — notice how the call to the `getJobs()` method differs between the two examples.

ClearUnitTest.kt
UnclearUnitTest.kt

In the first example above, `ClearUnitTest.kt`, the important details required for the test case — such as creating a user and calling the `getJobs` API — can be clearly seen in the test case. In contrast, the second example, `UnclearUnitTest.kt`, relies on the `getTestUserInfo()` helper method to get the new user. As a result, it’s not clear how the test user object was created, as that logic is shifted to the `getTestUserInfo()` helper method.

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 calls its dependent methods to fulfill its purpose.
  • 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.

At Zenjob, we try to stick to state testing, because interaction testing can easily lead to false positives² and false negatives.³ This is due to a couple of things:

  1. When the method under test is refactored to call a different set of methods but arrives at the same result, the test fails even though the behavior of the method is unaltered.
  2. If a logical change is introduced in one of the test method calls, the behavior of the system is altered, but the test still passes, as the interaction between the methods hasn’t changed.

While the first scenario isn’t good, the second case is even more dangerous because it gives the impression that the test is passing when it’s really supposed to fail and point out the bug. In both cases, the unit test requires a code change, which leads to unnecessary test maintenance work.

The example below shows a system that toggles the “favorite” state of a given job in the cache.

StateVsInteractionTesting.kt

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. This is how a test verification should ideally be done because it verifies the state like a manual tester would do.

Using the facade to test

Using the facade to test means testing a class using public-facing APIs. It also ensures that the production code is changed only when the business requirements change, and that the test code doesn’t break when the internal details of a class are altered.

This also aligns with the abstraction principle — in other words, consumers of a class 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 public APIs without trying to interact with private methods. This is especially useful for library projects where a unit test failure would easily indicate a breaking behavior for the consumers of the library.

When talking about public-facing APIs, it’s also important to define what “public” means in this context; it doesn’t always mean the public visibility modifier provided by some programming languages, as shown in the example below. Rather, it depends on what’s considered a unit by the system being tested.

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

The example below shows a class that contains the logic of a feature in an app where a user can invite another user based on certain internal conditions.

FriendReferral.kt
TestingInternalDetails.kt
TestingUsingPublicAPI.kt

Notice how `TestingUsingPublicApi.kt` only tests the public-facing APIs, while `TestInternalDetails.kt` weakens the visibility of private methods using the `VisibleForTesting` annotation and tries to test the internal implementation details. So, it’s a good practice to only use the public-facing APIs to test because it ensures the internal details of a class aren’t exposed to other classes, and it also supports stronger encapsulation.

Testing 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 we prefer behavior-driven testing over adding a test case for each method.

So, we aim 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’s in a predefined state.

Behavior-driven tests can be easily understood by someone who’s familiar with system requirements. They also serve as indirect documentation of the code being tested, and they help in reducing the size of a test method, as only one behavior is tested at any time, even though the method under test does many things.

Demonstrating testing by behavior

In the code above, notice how `TestingBehaviour.kt` tests all the behaviors of the method separately, whereas`TestingMethod.kt` tests everything in a single method with multiple assertions. The latter isn’t ideal, because it won’t be evident in which assertion the test failed — and one assertion failure stops the following assertions from being executed.

Bulletproof tests

Ideally, 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 ideally shouldn’t change. However, 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 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 wasn’t caught by the unit tests, and that indicates a missing test. So, any bug fixing that we do should be accompanied by a test case. In other cases, a bug can still occur even though a test method was present to verify the behavior. This means the test case we added wasn’t good enough to verify the intended behavior and it would be fixed along with the bug.

Finally, for any kind of refactoring in the code, we don’t make any changes 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 business logic, and we should consider a different approach for the refactoring.

Conclusion

Unit testing is an integral part of the Android development process at Zenjob. By following these best practices, we can ensure the reliability, maintainability, and scalability of our codebase. By writing testable code and leveraging reliable testing frameworks, these practices enable us to build robust and high-quality Android applications. By prioritizing unit testing, we can deliver a better experience to our users and maintain a solid foundation for future development.

  1. Talents are users of the Zenjob app. They’re called Talents because they have the ability to fulfill the jobs created by the companies.
  2. False positives occur when a test fails to raise a red flag when there’s an issue.
  3. False negatives occur when a test raises a red flag when there’s no issue.

--

--