Blog Infos
Author
Published
Topics
Published

In the first part of this series we described the elements that define a good testing strategy and the different types of tests we should implement. In this second part, we focus on unit testing — what they are, how, and when to implement them.

Definition of Unit Test

In a nutshell, a unit test is an automated piece of software that verifies the correctness of a small part of the codebase, does it quickly, and in isolation from the rest of the system. Let’s see an example:

@Test
fun `calculateStops returns correct number of stops for a given line ID`() {

  val repository = LinesRepository()
  val route = BusRoute(repository, "Line123")
    
  val numberOfStops = route.calculateStops()

  assertEquals(3, numberOfStops)
}

As we can see we have a class named “BusRoute” that calculates the number of stops for a specific bus line ID. In this test, we check that the function works properly and returns the expected number of stops for that bus line.

The two Unit Testing schools of Thought

The software engineering community generally agrees on the key characteristics of a unit test, but opinions differ when it comes to the best approach for isolation. When we talk about testing a small piece of code, what exactly does that mean? Is it just a single function, or does it refer to an entire class? Could it be a component that includes other classes as collaborators? Should these dependencies be tested as well, or is it acceptable to replace them with substitutes like mocks or stubs? Let’s explore these questions in more detail.

The Classical or Detroit School

The basic idea here is that we have to test the unit of code using real dependencies, ensuring that the functionality works as expected with real objects interacting with each other. This guarantees usage close to real-world scenarios, reducing the risk of false positives.

In this approach, isolation doesn’t mean completely detaching the code under test from the rest of the system, including collaborators and dependencies. We only want to isolate this particular functionality from any other logic not related to the Subject Under Test (SUT from now on). The dependencies need to be the same as in the production code. However, to keep the tests fast, reliable, and to avoid flakiness, we must avoid adding real instances of “out-of-process” dependencies like databases, network requests, etc.

In the above example, when we instantiate the SUT, BusRoute, we pass a real instance (not a mock) of the LinesRepository.

The main criticism of this school of thought is that the dependency graph of a single component can get really complicated, and providing real objects for every single component can be arduous or even impossible in some cases. Still, it is worth aiming for this approach because of the advantages mentioned earlier, and we should define clear boundaries for when to use mocks instead of real objects.

The Mockist or London School

Proponents of this approach suggest that the SUT must be completely isolated, with any dependency mocked and replaced to provide the bare minimum functionality needed to exercise the correctness of the test unit. The idea is to exclusively test the SUT and nothing else, assuming that every collaborator will be tested on its own. Let’s see the above example now with the Mockist approach:

@Test
fun `calculateStops returns correct number of stops for a given line ID using a mocked service`() {
    
    val busStopService = mock(BusStopService::class.java)
    `when`(busStopService.getStops("Line123")).thenReturn(listOf("Station A", "Station B", "Station C"))
    
    val route = BusRoute(busStopService)
    val numberOfStops = route.calculateStops(lineId)

    assertEquals(3, numberOfStops)
}

In this example, the SUT is the BusRoute class, and the dependencies have been mocked to provide the expected behavior. This is valuable because the focus is 100% on the functionality of this class and nothing else. If a regression is introduced and the test fails, we can be completely certain that the bug is in this element and only here. This granularity provides an efficient and fast process for finding issues in our code.

What should a Unit Test cover?

When discussing testing, one of the main challenges is defining the different types of tests: what should we validate, what is the scope of the code under test, and what components or parts should be part of our strategy. So, what are we supposed to test in a unit test?

Behaviour Over Implementation

Test the outcomes and results of the unit’s behavior, not the implementation details or internal functionality. In the examples above, even if we change how we calculate the number of stops, the outcome should be the same. Therefore, the test should always validate the outcome and not how we accomplish that.

Single Responsibility

The tests should verify one single unit of behavior so it’s easy to understand and maintain, and so that we have a clear and single source of the issue. For example, in the bus route example, we should test only the number of stops and not try to also validate, say, the names of the stops (which might be returned by a different function).

Edge Cases

Your tests should cover edge cases like null inputs, empty values, error cases, and so on — not only the typical happy paths. In the example above, we should also test what happens when we pass an invalid bus line ID, or what happens if the repository call fails, etc.

Anatomy of a Unit Test

The Arrange — Act — Assert (AAA) pattern has been widely accepted in the software industry as it provides a methodical and clear approach for writing and maintaining tests. The idea is to divide the test in three parts:

Arrange

We set up the initial conditions required for the test, such as initialising objects and configuring mock behaviour.

Act

This is where we execute the functionality we want to validate.

Assert

Finally, we verify the outcome of the functionality under test and check that it matches the expected result.

Here’s how this looks in practice:

@Test
fun `calculateStops returns correct number of stops for a given line ID`() {
    
  // Arrange
  val repository = LinesRepository()         // Set up the real or mock repository
  val route = BusRoute(repository, "Line123")// Create the BusRoute instance with the given line ID

  // Act
  val numberOfStops = route.calculateStops() // Call the method under test

  // Assert
  assertEquals(3, numberOfStops)             // Verify that the result matches the expected number of stops
}
Test Doubles

In the London or Mockist School of testing, we often refer to all objects used to replace dependencies of the SUT as “mocks.” However, in reality, there are several distinct types of test doubles, each serving a specific purpose in unit testing.

Dummy

A dummy is an object that is passed as a dependency but is never actually used in the test. It is included only to satisfy the method or class parameters.

When to Use: Use a dummy when the SUT requires an object that is irrelevant to the test, such as a Context in a test where the context is not directly utilized.

Example: Passing a dummy Context when testing a method that doesn’t interact with the Android framework.

Library/Framework: No specific library is needed for dummies, as they can simply be placeholders or null objects.

@Test
fun `calculateStops returns correct number of stops using a dummy Context`() {
    // Arrange
    val dummyContext = mock(Context::class.java)
    val route = BusRoute(dummyContext, "Line123")

    // Act
    val numberOfStops = route.calculateStops()

    // Assert
    assertEquals(3, numberOfStops)
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

Fake

A fake is a working implementation of a dependency with simplified logic. It provides just enough functionality to support the SUT during the test.

When to Use: Use a fake when you need a lightweight, functional version of a component, such as an in-memory database, which is faster and easier to manage during testing.

Example: An in-memory implementation of SharedPreferences or a fake network service that returns predefined responses.

Library/Framework:

  • Room: Use an in-memory database configuration to fake the persistence layer.
  • Faker: Use libraries like MockWebServer to fake network responses.
class FakeBusStopService : BusStopService {
    override fun getStops(lineId: String): List<String> {
        return listOf("Station A", "Station B", "Station C")
    }
}

@Test
fun `calculateStops returns correct number of stops using a FakeBusStopService`() {
    // Arrange
    val fakeService = FakeBusStopService()
    val route = BusRoute(fakeService, "Line123")

    // Act
    val numberOfStops = route.calculateStops()

    // Assert
    assertEquals(3, numberOfStops)
}
Stub

A stub is a test double that provides specific, predetermined responses to method calls. This allows the SUT to proceed with its logic without relying on the full implementation of the dependency.

When to Use: Use stubs when you need to control the output of a dependency to test specific scenarios within the SUT.

Example: Stubbing a method in a repository to return a specific value when testing a ViewModel.

Library/Framework:

  • Mockito: Use the when().thenReturn() construct to create stubs.
  • Kotlin: Use MockK for more idiomatic Kotlin stubbing.
@Test
fun `calculateStops returns correct number of stops using a stubbed BusStopService`() {
    // Arrange
    val busStopService = mock(BusStopService::class.java)
    `when`(busStopService.getStops("Line123")).thenReturn(listOf("Station A", "Station B", "Station C"))
    val route = BusRoute(busStopService, "Line123")

    // Act
    val numberOfStops = route.calculateStops()

    // Assert
    assertEquals(3, numberOfStops)
}
Mock

A mock is a test double that not only provides responses like a stub but also records interactions, allowing you to verify that specific methods were called with the expected parameters.

When to Use: Use mocks when you need to verify interactions between the SUT and its dependencies, such as ensuring a certain method is called only once.

Example: Verifying that a method to save data in a repository is called when a form is submitted.

Library/Framework:

  • Mockito: The most widely used library for creating mocks and verifying interactions.
  • MockK: A powerful alternative for Kotlin projects.
@Test
fun `calculateStops calls getStops on BusStopService`() {
    // Arrange
    val busStopService = mock(BusStopService::class.java)
    val route = BusRoute(busStopService, "Line123")

    // Act
    route.calculateStops()

    // Assert
    verify(busStopService).getStops("Line123")
}
Spy

A spy wraps a real object, allowing you to call its actual methods while still monitoring and verifying interactions with it. This is useful when you want to partially mock an object while keeping some of its real behavior.

When to Use: Use spies when you need to track method calls but still want to use the actual implementation for other methods.

Example: Spying on a ViewModel to ensure certain lifecycle methods are invoked while still executing the real ViewModel logic.

Library/Framework:

  • Mockito: Use spy() to create a spy around a real object.
  • MockK: Provides support for spies as well, allowing for more flexible testing scenarios in Kotlin.
A solid Unit Testing strategy

A good unit test is not just about ensuring that the code works as expected; it also serves as a long-term safeguard for the stability and maintainability of your application. Here are the key characteristics:

Protection Against Regressions and Refactoring

One of the primary purposes of a unit test is to prevent regressions — unintended changes in behavior when new code is added or existing code is modified. A well-written test detects breaking changes in functionality by focusing on testing outcomes and behavior rather than implementation details.

Maintainability

Unit tests should be easy to maintain, understand, and modify when necessary. A red flag is an overly complicated “Arrange” section or difficulty in providing dependencies, which can make the test harder to work with in the long term.

Fast Feedback

Unit tests should execute quickly, providing fast feedback to developers. This encourages frequent test runs and a straightforward process for identifying and fixing bugs when tests fail. Quick execution is essential for maintaining a productive development workflow, especially in continuous integration environments.

Unit Testing in Android

Now that we have a good understanding about unit testing, what parts and components of our Android app we should unit test?

(Note: In the examples below I’m using mocks for simplicity but ideally we should prefer fakes and real implementations when possible)

Business Logic
  • ViewModel: They are going to contain most of the UI logic and in many cases also business logic (depending on our architecture and separation of concern level). Here we should be testing things like the different model states after some user interaction, data responses from a repository, etc.
class BusRouteViewModelTest {

    private lateinit var viewModel: BusRouteViewModel
    private val repository = mock(LinesRepository::class.java)

    @Before
    fun setUp() {
        viewModel = BusRouteViewModel(repository)
    }

    @Test
    fun `fetchStops sets stops LiveData correctly`() {
        // Arrange
        val expectedStops = listOf("Station A", "Station B", "Station C")
        `when`(repository.getStops("Line123")).thenReturn(expectedStops)

        // Act
        viewModel.fetchStops("Line123")

        // Assert
        assertEquals(expectedStops, viewModel.stops.getOrAwaitValue())
    }
}
  • Use Cases or Interactors: If we are using this component in our Clean Architecture implementation, they will contain business logic that must be unit tested.
Utility Classes, Helpers, Mappers

Any function, class, helper, mappers, etc that perform calculations, data formatting and similar logic should be tested.

//  Assuming this utility class:
object BusRouteUtils {
    fun formatStops(stops: List<String>): String {
        return stops.joinToString(separator = " -> ")
    }
}

//  This could be a test case:
class BusRouteUtilsTest {
    @Test
    fun `formatStops returns correctly formatted string`() {
        // Arrange
        val stops = listOf("Station A", "Station B", "Station C")
        val expectedFormat = "Station A -> Station B -> Station C"

        // Act
        val result = BusRouteUtils.formatStops(stops)

        // Assert
        assertEquals(expectedFormat, result)
    }
}
Data Layer
  • Repositories: Test that the repository fetch, store, cache and process the data correctly. Specially important if this component has actually repository functionalities like fetching data from different sources, mapping or similar.
  • Data mappers: If we are converting data models to lets say domain models, this are the perfect candidate for unit testing as well.
class BusRouteRepositoryTest {

    private lateinit var repository: BusRouteRepository
    private val localDataSource = mock(BusStopDataSource::class.java)
    private val remoteDataSource = mock(BusStopDataSource::class.java)

    @Before
    fun setUp() {
        repository = BusRouteRepository(localDataSource, remoteDataSource)
    }

    @Test
    fun `getStops returns local data when available`() {
        // Arrange
        val expectedStops = listOf("Station A", "Station B")
        `when`(localDataSource.getStops("Line123")).thenReturn(expectedStops)

        // Act
        val result = repository.getStops("Line123")

        // Assert
        assertEquals(expectedStops, result)
        verify(remoteDataSource, never()).getStops(anyString())
    }

    @Test
    fun `getStops fetches from remote and saves locally when local data is empty`() {
        // Arrange
        val expectedStops = listOf("Station A", "Station B")
        `when`(localDataSource.getStops("Line123")).thenReturn(emptyList())
        `when`(remoteDataSource.getStops("Line123")).thenReturn(expectedStops)

        // Act
        val result = repository.getStops("Line123")

        // Assert
        assertEquals(expectedStops, result)
        verify(localDataSource).saveStops("Line123", expectedStops)
    }
}
Conclusion

In this article, we explored the essential elements of unit testing in Android development. We started by defining what unit tests are and their importance in preventing regressions, maintaining code quality, and providing fast feedback. We discussed the different types of test doubles, such as dummies, fakes, stubs, mocks, and spies, and how they can be effectively used in Android projects. We also covered what should be unit tested in an Android project, including ViewModels, utility classes, and repositories, along with best practices for writing maintainable and efficient tests.

While this article provided a comprehensive overview of unit testing, topics like Test-Driven Development (TDD) and integrating tests into Continuous Integration (CI) pipelines are crucial aspects that will be covered in future articles. For those interested in deepening their understanding of unit testing principles, I highly recommend the book Unit Testing: Principles, Practices, and Patterns by Vladimir Khorikov, which served as a valuable reference for this post.

Thank you for reading, and I hope this guide helps you write better tests and build more robust Android applications!

Disclaimer: The views and opinions expressed in this blog post are solely my own and do not reflect the official policy or position of my employer. The approaches and strategies discussed here are based on my personal experience and understanding of unit testing in Android development.

This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Now that Android Studio Iguana is out and stable, I wanted to write about…
READ MORE
blog
The ModalBottomSheet in Jetpack Compose is easy to use, it just pops up at…
READ MORE
blog
Discussions about accessibility, especially in software development, often center around screen reader accessibility. With…
READ MORE
blog
Hey folks. If you are reading this article, you may be having trouble figuring…
READ MORE
Menu