Practical Unit Testing for Android with JUnit and Mockito
Unit tests give Android projects a fast way to prove that calculations, state transitions and business rules behave as intended. Instead of launching an emulator and tapping through every screen, a local test can execute on the development machine in milliseconds, providing immediate feedback while code is still being shaped. JUnit supplies the test structure and assertions, while Mockito supplies controlled test doubles for collaborators such as repositories, gateways and preference stores.
The strongest suites are not collections of broad smoke tests. They isolate one behaviour per test, name that behaviour clearly, and cover ordinary paths, awkward inputs and failure responses. This approach works for projects built with Kotlin or Java and scales from a first Android Studio exercise to a mature application maintained by a distributed team. The result is a repeatable safety net for every change, rather than a manual check that depends on who happens to test the release.
Setting Up the Test Harness
Place JVM unit tests in the standard source set at app/src/test/java/. In the app-level build.gradle.kts file, add JUnit for assertions and test execution, plus Mockito when a test needs to replace a collaborator:
dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("org.mockito:mockito-core:5.12.0")
}
These dependencies belong under dependencies, not androidTestImplementation. The latter is reserved for instrumented tests that run on an emulator or physical device. Keep the versions aligned with the JDK and Kotlin configuration used by the project, especially when an older Android build is being maintained.
Local unit tests run on the host JVM and cannot directly interact with Android resources or many framework methods. If a framework call produces a “method not mocked” error, isolate it behind an application class, mock that class or introduce Robolectric when resource-aware behaviour is genuinely required. Avoid enabling default return values indiscriminately, because they can conceal missing behaviour.
Designing Code for Isolation
Testable code keeps business rules away from Android UI objects. Activities and fragments can receive data from a presenter, use case or service, while those classes depend on small interfaces. Constructor injection then gives a test complete control over each dependency.
Consider a shopping-cart calculation expressed in integer cents:
data class CartLine(val priceCents: Int)
interface CartStore {
fun lines(): List<CartLine>
fun save(totalCents: Int)
}
class CartTotal(private val store: CartStore) {
fun totalCents(): Int = store.lines().sumOf { it.priceCents }
fun saveTotal(): Int {
val total = totalCents()
store.save(total)
return total
}
}
This design has no dependency on Context, SharedPreferences or a database connection. A production test can provide a real in-memory store, while a Mockito test can supply a controlled implementation. Representing money as cents also avoids the rounding surprises that commonly accompany floating-point arithmetic.
Writing Focused JUnit Tests
A JUnit test should describe one expected outcome in its name and arrange only the data relevant to that outcome. Assertions should verify observable results rather than reproduce the implementation. A small calculator provides a useful first example:
class DiscountCalculator {
fun apply(amountCents: Int, percentage: Int): Int =
amountCents - (amountCents * percentage / 100)
}
class DiscountCalculatorTest {
@Test
fun `applies a percentage discount`() {
assertEquals(900, DiscountCalculator().apply(1000, 10))
}
@Test
fun `returns the original amount without a discount`() {
assertEquals(1000, DiscountCalculator().apply(1000, 0))
}
@Test
fun `returns zero for a full discount`() {
assertEquals(0, DiscountCalculator().apply(1000, 100))
}
}
JUnit provides assertions such as assertEquals, assertTrue, assertNull and assertThrows, along with lifecycle hooks such as @Before and @After. Use a setup hook for shared fixtures, but keep each test independent so it can run in any order. The familiar red-green-refactor cycle works well here: create a failing test, make the smallest change that satisfies it, and then improve the implementation without changing the test’s intention.
Using Mockito to Control Collaborators
Mockito creates mocks that implement an interface or extend a class while recording calls and returning configured results. Stubs define what a dependency returns, while verification confirms that the code under test used it correctly. For the cart example, a mock can represent a store containing two lines:
@Test
fun `reads lines and saves their total`() {
val store = mock(CartStore::class.java)
`when`(store.lines()).thenReturn(
listOf(CartLine(1250), CartLine(3750))
)
val cart = CartTotal(store)
assertEquals(5000, cart.totalCents())
cart.saveTotal()
verify(store).save(5000)
}
Prefer when for arranging return values and verify for checking meaningful interactions. never(), times() and atLeastOnce() can express stricter expectations, but excessive verification makes a test fragile and duplicates the implementation. A useful rule is to verify the externally relevant effect, such as saving a correct total, rather than every internal method call.
Avoid mocking every class automatically. Pure value objects and simple calculations often work better with ordinary instances, while mocks are most valuable at boundaries involving networks, clocks, databases and platform services.
Exercising Failures and Boundary Conditions
Happy-path tests need companions that cover empty data, invalid values, interrupted requests and unavailable services. Mockito can simulate an exception without contacting a real service:
@Test
fun `reports a store failure`() {
val store = mock(CartStore::class.java)
`when`(store.lines()).thenThrow(IOException("offline"))
val error = assertThrows(IOException::class.java) {
CartTotal(store).totalCents()
}
assertEquals("offline", error.message)
}
Also test an empty cart, a single free item, duplicate lines and the largest amount the business accepts. These cases often reveal assumptions hidden in ordinary examples. When time, randomness or network state affects a result, inject those dependencies so each test remains deterministic.
Keep production customer information out of fixtures. For teams handling personal information in Australia, the Privacy Act 1988 and the Australian Privacy Principles make careful data handling part of release work. Synthetic names, invented order numbers and fabricated amounts provide realistic coverage without copying records into a repository or CI log.
Running and Monitoring the Test Suite
Android Studio can run an individual class or method from the gutter beside a test. The Gradle command is useful for repeatable local and CI runs:
./gradlew testDebugUnitTest
./gradlew testDebugUnitTest --tests "com.example.shopping.CartTotalTest"
A multi-module project can target the app module directly with ./gradlew :app:testDebugUnitTest. Reports appear under the module’s build directory, while Android Studio’s coverage view highlights untested branches. Coverage is a diagnostic aid rather than a target: a high percentage can still miss the most damaging behaviour.
A shared build server gives developers in Sydney, Melbourne and Perth the same tested commit even though their local times and daylight-saving patterns differ. CI should fail on compilation errors, failed assertions and accidental test exclusions. Keep network-dependent checks out of the normal unit-test job, and investigate flaky tests rather than repeatedly restarting the build.
Connecting Tests to Real Android Use
Unit tests verify application logic quickly, but they do not replace instrumented tests with Espresso or checks on a physical device. A presenter test might prove that a dark-theme preference is selected correctly, while an instrumented test confirms that the button, colour and system setting render as expected. The complete flow still needs release testing across supported Android versions.
Theme preferences deserve a visual cross-check as well; this Android dark mode walkthrough can help confirm how the selected setting appears in an actual app. Australian Android users regularly rely on Google Play apps for banking, shopping, transport and superannuation services, so a small calculation or state-management fault can affect real transactions.
Everyday conditions add another dimension. Commuters in Sydney and Melbourne often check shopping, transport or payment apps while travelling on trains and trams, sometimes with a temporarily unreliable connection. Clear error handling, preserved preferences and predictable totals therefore matter as much as the assertions inside a test suite. Unit tests act as the quiet, repeatable guardrail behind every Android release, while device-level checks confirm that the complete experience still works for people on the move.