The Golden Rules for Unit Tests in Android: A Comprehensive Guide
Introduction
Unit tests form an essential layer in the development process, acting as vigilant watchmen against bugs in the code. They prove crucial in Android development, where diverse device types and configurations can make bugs elusive and hard to reproduce. Unit testing refers to scrutinizing individual components of your source code — functions, methods, or classes — to ascertain their readiness for action. In this guide, we explore the golden rules for crafting effective unit tests in Android, all articulated through the lens of Kotlin, the preferred language for Android development.
Rule 1: Isolate and Test One Component at a Time
Imagine your codebase as a complex watch mechanism, with each cog and wheel playing its part. A unit test should focus on one of these components at a time, verifying its function in isolation. This approach means if a test fails, you can quickly identify the faulty part without diving deep into debugging.
For instance, consider a simple `Calculator` class in your Android project:
class Calculator {
fun add(a: Int, b: Int) = a + b
fun subtract(a: Int, b: Int) = a - b
}
To apply our rule, create separate tests for each method, ensuring each part is functioning as expected:
class CalculatorTest {
private val calculator = Calculator()
@Test
fun `test add`() {
assertEquals(5, calculator.add(2, 3))
}
@Test
fun `test subtract`() {
assertEquals(1, calculator.subtract(3, 2))
}
}
Rule 2: Use Mocking Frameworks to Isolate Dependencies
When testing a class that depends on other components, you want to exclude these external factors. This is where mocking framework like Mockito become invaluable. They create dummy objects, allowing you to simulate and control the behavior of dependencies, leading to more focused and reliable tests.
For instance, let’s consider a `UserRepository` class that relies on a `SharedPreferences` object. We use constructor injection to provide this dependency:
class UserRepository(private val sharedPreferences: SharedPreferences) {
fun saveUser(user: User) {
with(sharedPreferences.edit()) {
putString("USER_NAME", user.name)
apply()
}
}
}
To apply our rule, we can use Mockito to create a mock `SharedPreferences` and `Editor` object. This allows us to test the `saveUser` method in isolation:
class UserRepositoryTest {
private lateinit var editor = mock<SharedPreferences.Editor>()
private lateinit var sharedPreferences = mock<SharedPreferences> {
on { edit() } doReturn editor
}
private lateinit var userRepository: UserRepository = UserRepository(sharedPreferences)
@Test
fun `test saveUser`() {
val user = User("Test User")
userRepository.saveUser(user)
verify(editor).putString("USER_NAME", "Test User")
verify(editor).apply()
}
}
Rule 3: Ensure Tests Are Independent
It’s essential that unit tests don’t affect each other. They should be capable of running in any order and individually without causing any unexpected outcomes. Sharing state across tests can lead to unpredictable outcomes and hard-to-diagnose failures.
Consider two tests `testA` and `testB` that share a common variable. If `testA` modifies this variable, the result of `testB` might depend on whether `testA` ran first. To ensure test independence, reset shared variables before each test:
class ExampleTest {
private var sharedVariable: Int = 0
@Before
fun setUp() {
sharedVariable = 10
}
@Test
fun `test A`() {
sharedVariable += 5
assertEquals(15, sharedVariable)
}
@Test
fun `test B`() {
assertEquals(10, sharedVariable) // This assertion will pass even if `test A` ran first.
}
}
Rule 4: Prioritize Readability
A well-written test should be clear and understandable, benefiting the whole team in the long run. Maintain logical structure, use clear naming, and keep the code concise. Here’s an example of a readable test:
class CalculatorTest {
private val calculator = Calculator()
@Test
fun `when adding 2 and 3 should return 5`() {
val result = calculator.add(2, 3)
assertEquals(5, result)
}
@Test
fun `when subtracting 3 from 5 should return 2`() {
val result = calculator.subtract(5, 3)
assertEquals(2, result)
}
}
Rule 5: Strive for Repeatable and Consistent Results
A good unit test should always return the same result if the code hasn’t changed. It doesn’t depend on mutable external states. Here’s an example of a consistent test:
class ConsistentTest {
@Test
fun `is always the same`() {
val result = someFunctionThatDoesNotDependOnMutableState()
assertEquals(expectedResult, result)
}
}
Rule 6: Remember to Test Edge Cases
You should consider edge cases such as null inputs, empty inputs, maximum or minimum values, etc. These tests help you to ensure that your code can handle all possible input scenarios:
class EdgeCaseTest {
private val calculator = Calculator()
@Test(expected = IllegalArgumentException::class)
fun `when adding null should throw exception`() {
calculator.add(null, 1)
}
@Test
fun `when adding Int.MAX_VALUE and 1 should return Int.MIN_VALUE`() {
assertEquals(Int.MIN_VALUE, calculator.add(Int.MAX_VALUE, 1))
}
}
Rule 7: Name Your Test Functions Descriptively
Naming test functions is often overlooked, yet it’s a crucial aspect of testing. A well-named test function describes what it tests and the expected outcome. It serves as a form of documentation that can help you and other developers understand the test’s purpose at a glance.
One effective strategy for naming test functions is using a structured format that outlines the tested behavior and expected outcome. Here are a couple of formats you might use:
methodName_StateUnderTest_ExpectedBehavior
: This format begins with the name of the method being tested, followed by the scenario being tested and the expected outcome. For example,add_NegativeNumbers_ReturnsCorrectSum
ordivide_ByZero_ThrowsException
.should_ExpectedBehavior_When_StateUnderTest
: This format reverses the order, beginning with the word 'should', followed by the expected outcome and the scenario under test. For example,should_ReturnCorrectSum_When_AddingNegativeNumbers
orshould_ThrowException_When_DividingByZero
.
In Kotlin, you can use backticks (`) to write test function names with spaces, allowing you to use a more descriptive language. For example, you might name your tests as follows:
class CalculatorTest {
private val calculator = Calculator()
@Test
fun `when adding negative numbers, it returns correct sum`() {
val result = calculator.add(-2, -3)
assertEquals(-5, result)
}
@Test(expected = IllegalArgumentException::class)
fun `when dividing by zero, it throws an exception`() {
calculator.divide(5, 0)
}
}
Rule 8: Segregate Sections Inside a Test
As your test suite grows, it’s essential to keep it well-organized and maintainable. Segregating sections within a test can significantly improve readability and make it easier to understand the test’s flow. When a test has multiple logical steps or distinct scenarios, consider using section headers or comments to divide the test into smaller, more manageable parts.
Here’s an example of how you can segregate sections within a test:
class ExampleTest {
@Test
fun `test a complex scenario`() {
// Arrange - Set up test data and context
val user = User("John Doe")
val repository = UserRepository(mockedSharedPreferences)
// Act - Perform the action being tested
repository.saveUser(user)
// Assert - Verify the outcome
val retrievedUser = repository.getUser()
assertEquals(user, retrievedUser)
// Arrange - Set up another scenario
val updatedUser = User("Jane Smith")
// Act - Perform another action
repository.saveUser(updatedUser)
// Assert - Verify the new outcome
val retrievedUpdatedUser = repository.getUser()
assertEquals(updatedUser, retrievedUpdatedUser)
}
}
In this example, we use comments to separate the test into three sections: Arrange, Act, and Assert. The Arrange section sets up the test data and context. The Act section performs the action being tested. The Assert section verifies the outcomes. This approach makes it easy to grasp the flow of the test and understand each step’s purpose.
By segregating sections inside a test, you enhance the test’s readability, maintainability, and collaboration among team members. It becomes effortless to identify and address specific areas if modifications are needed.
Rule 9: Embrace Continuous Integration Systems and Test Coverage Tools
Continuous integration (CI) systems like Bitrise and test coverage tools like JaCoCo play crucial roles in maintaining a high-quality, bug-free codebase. They save time, reduce bugs, and improve code quality by automating various stages of the development process.
Bitrise, a popular CI/CD platform for mobile apps, can automatically build and test your Android project whenever changes are pushed to your codebase. This provides immediate feedback on your work and prevents bugs from slipping through into the main branch. In addition, Bitrise offers integration with various testing, deployment, and notification tools, making it a powerful and flexible choice for Android development.
In addition to CI systems, it’s also important to measure the coverage of your tests — that is, what percentage of your code is actually being tested? This is where JaCoCo comes in. JaCoCo is a free code coverage library for Java, which is widely used in Android development.
With JaCoCo, you can generate detailed coverage reports that show which parts of your code have been tested and where you might need to add more tests. It’s a great way to ensure your unit tests are comprehensive and that you’re not missing any
important areas of your code.
Here is an example of how to apply JaCoCo in your Android project’s
plugins {
jacoco
}
jacoco {
toolVersion = "0.8.7"
reportsDir = file("$buildDir/customJacocoReportDir")
}
android {
...
buildTypes {
getByName("debug") {
isTestCoverageEnabled = true
}
}
}
With this configuration, when you run your unit tests, JaCoCo will generate a coverage report in the `customJacocoReportDir` directory.
Rule 10: Measure Code Coverage
In addition to writing and running unit tests, it’s also essential to know how much of your code these tests cover. Code coverage is a measure used to describe the degree to which the source code of a program is executed when a particular test suite runs. It’s an indicator of the quality of your tests and their effectiveness in catching bugs.
A code coverage tool like JaCoCo, integrated into your build process, can provide valuable insights into parts of the code that may not have been thoroughly tested and are potentially prone to bugs. Having a high code coverage percentage is desirable, but remember, the goal of testing is to catch bugs, not to achieve 100% code coverage. While a high coverage percentage can inspire confidence, it can also lead to complacency. A “well-covered” codebase can still have bugs if the tests aren’t thoughtfully designed. In other words, focus on the quality of tests, not just the quantity.
Conclusion
Unit tests are the guardian angels of your codebase. They work tirelessly behind the scenes, catching potential issues before they manifest as bugs. By following these golden rules and leveraging tools like Bitrise for continuous integration and JaCoCo for code coverage analysis, you will write more reliable, robust, and maintainable unit tests for your Android applications. Happy testing!