Kahibaro
Discord Login Register

29.1 Unit Testing

Understanding Unit Testing in Android

Unit testing is about checking the smallest pieces of your code, usually individual functions or classes, to make sure they behave exactly as you expect. In Android projects this is done in a regular JVM environment, without launching an emulator or a real device. This makes unit tests fast, cheap to run, and suitable for running very frequently during development.

In this chapter you will focus on how unit testing fits into an Android project, how to write tests in Kotlin with JUnit, and how to structure your code to make unit testing easier. UI related testing belongs to a different chapter, so here you will mostly deal with plain Kotlin logic such as calculations, input validation, or ViewModel logic that does not depend directly on Android UI classes.

Unit Tests in the Android Project Structure

An Android Studio project separates tests into two main source sets. Instrumented tests that run on a device or emulator live under app/src/androidTest. Unit tests that run on the JVM live under app/src/test. In this chapter you work exclusively with the test directory.

Code in src/test has access to your production code in src/main, but it does not have access to the Android runtime classes such as Activity or Context by default. This is exactly what makes these tests fast, since they do not need to start an Android environment.

You typically create test files that mirror your production package structure. If you have a class com.example.app.math.Calculator in src/main, you would usually create CalculatorTest in the same package path under src/test. Android Studio can generate a test class for you when you place the caret on a class or function and use the "Generate" action, then choose "Test."

JUnit and the Basic Test Structure

Android uses JUnit as the main framework for unit tests. When you create a new Android project, Gradle already includes JUnit 4 as a test dependency. This allows you to write tests as regular Kotlin classes annotated with @Test.

A very simple unit test has three main parts. First you set up any inputs or objects that you want to test. Then you call the function that you want to test. Finally you verify the result using assertions.

Here is a basic example of a unit test class that tests a simple calculator:

package com.example.app.math
import org.junit.Assert.assertEquals
import org.junit.Test
class CalculatorTest {
    @Test
    fun add_twoPositiveNumbers_returnsCorrectSum() {
        // Arrange
        val calculator = Calculator()
        // Act
        val result = calculator.add(2, 3)
        // Assert
        assertEquals(5, result)
    }
}

The method annotated with @Test will be executed by the test runner. The test uses assertEquals to compare the expected value with the actual result. If they match, the test passes. If they do not match, the test fails and you will see an error in the test output.

It is common to name test methods in a descriptive way, for example methodName_condition_expectedResult. This is not required, but it makes test failures easier to understand.

Write small, focused tests that check one behavior at a time. If a test checks many different things, it becomes hard to understand and hard to maintain.

Assertions and Expected Failures

Assertions are the way you express your expectations in unit tests. JUnit provides several useful assertion functions through the Assert class. You already saw assertEquals(expected, actual). There are others such as assertTrue(condition), assertFalse(condition), and assertNull(value).

For example, you might have an input validator that should return true only for valid emails. A test could look like this:

import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class EmailValidatorTest {
    @Test
    fun isValid_validEmail_returnsTrue() {
        val result = EmailValidator.isValid("user@example.com")
        assertTrue(result)
    }
    @Test
    fun isValid_missingAtSymbol_returnsFalse() {
        val result = EmailValidator.isValid("userexample.com")
        assertFalse(result)
    }
}

You can also test that your code throws an exception when given invalid input. JUnit 4 allows you to do this with the expected parameter on the @Test annotation, but a more expressive pattern uses the assertThrows function from JUnit 4.13 and later:

import org.junit.Assert.assertThrows
import org.junit.Test
class CalculatorTest {
    @Test
    fun divide_byZero_throwsException() {
        assertThrows(IllegalArgumentException::class.java) {
            Calculator().divide(4, 0)
        }
    }
}

Assertions are the heart of a unit test. A test without an assertion does not verify anything. Sometimes you will see tests that only check that no exception is thrown. In those cases you should comment clearly that the purpose of the test is to ensure that a method can run without errors.

Arrange, Act, Assert Pattern

A helpful way to structure unit tests is often called the Arrange, Act, Assert pattern. It keeps tests clear and easy to read.

First you arrange any data and objects that you need. Then you act by calling the method under test. Finally you assert that the result or behavior matches your expectations.

You already saw this pattern implicitly. Here is another example with clear comments:

class DiscountCalculatorTest {
    @Test
    fun applyDiscount_validPercentage_reducesPrice() {
        // Arrange
        val calculator = DiscountCalculator()
        val originalPrice = 100.0
        val discountPercentage = 20.0
        // Act
        val finalPrice = calculator.applyDiscount(originalPrice, discountPercentage)
        // Assert
        assertEquals(80.0, finalPrice, 0.001)
    }
}

The third parameter to assertEquals for doubles is a delta. Floating point calculations can produce small rounding differences. The delta defines how close the values must be to count as equal.

Follow Arrange, Act, Assert in each test:
Arrange your inputs,
Act by calling the method,
Assert the result or behavior.
Avoid mixing several actions and unrelated assertions in one test.

Running Unit Tests in Android Studio

To run unit tests you do not need a device or emulator. The tests run directly on your computer using the Java Virtual Machine.

In Android Studio you can run tests in several ways. You can click the green run icon next to a test method or test class to run just that test or that group of tests. You can also open the test directory in the Project view, right click it, and choose to run all tests in that directory. Gradle will compile and execute the tests and display the results in the "Run" or "Test Results" window.

Each test will show as passed, failed, or ignored. If a test fails, you can read the stack trace and the assertion message to understand why. Often the message from assertEquals will show you the expected value and the actual value, which points you directly to the problem.

From the command line you can run unit tests with Gradle by using the test task, for example ./gradlew testDebugUnitTest. This is useful for continuous integration systems, which run tests automatically whenever you push code.

Testable Design and Pure Kotlin Logic

Good unit tests go hand in hand with good design. Code that has clear responsibilities, small functions, and explicit dependencies is easier to test. Code that reaches out directly to Android APIs, global singletons, or static methods is more difficult to test in the test source set.

For business logic you should try to keep as much as possible in plain Kotlin classes that do not depend on Android. Classes such as input validators, calculators, parsers, or mappers are straightforward to test. ViewModels that expose simple state changes without touching Android UI APIs can also be tested in unit tests.

Here is a simple example of a ViewModel style class that is easy to unit test, even though you are not using the full Android architecture here:

class LoginValidator {
    fun canLogin(username: String, password: String): Boolean {
        if (username.isBlank()) return false
        if (password.length < 8) return false
        return true
    }
}

A unit test for this logic is simple and fast:

import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class LoginValidatorTest {
    @Test
    fun canLogin_blankUsername_returnsFalse() {
        val validator = LoginValidator()
        val result = validator.canLogin("", "password123")
        assertFalse(result)
    }
    @Test
    fun canLogin_shortPassword_returnsFalse() {
        val validator = LoginValidator()
        val result = validator.canLogin("user", "123")
        assertFalse(result)
    }
    @Test
    fun canLogin_validInput_returnsTrue() {
        val validator = LoginValidator()
        val result = validator.canLogin("user", "password123")
        assertTrue(result)
    }
}

By keeping Android dependent code separate from pure logic, you can achieve high unit test coverage without needing special frameworks or complex setups.

Mocking Dependencies

Sometimes the class you want to test depends on other classes. For example, a repository might talk to a network client and a local database. You do not want your unit tests to perform real network requests or write actual files. Instead you replace those dependencies with test doubles, often called mocks or fakes.

While this course does not go deep into mocking libraries, it is still useful to understand the basic idea. You design your classes so that they accept dependencies through their constructor. In tests you then pass in simple fake implementations that behave in predictable ways.

Here is a basic example without any external mocking framework:

interface UserDataSource {
    fun getUserName(): String
}
class GreetingRepository(private val dataSource: UserDataSource) {
    fun getGreeting(): String {
        val name = dataSource.getUserName()
        return "Hello, $name"
    }
}
class FakeUserDataSource(private val name: String) : UserDataSource {
    override fun getUserName(): String = name
}

Now the unit test can inject the fake data source:

import org.junit.Assert.assertEquals
import org.junit.Test
class GreetingRepositoryTest {
    @Test
    fun getGreeting_returnsGreetingWithUserName() {
        val fakeDataSource = FakeUserDataSource("Alice")
        val repository = GreetingRepository(fakeDataSource)
        val greeting = repository.getGreeting()
        assertEquals("Hello, Alice", greeting)
    }
}

This pattern keeps your unit tests focused and fast because they only exercise the logic of the class under test, not the behavior of its collaborators.

To make code testable, inject dependencies through constructors or function parameters. Avoid creating complex dependencies directly inside the class under test.

Parameterized Tests for Multiple Inputs

Sometimes you want to test the same logic with many different inputs and expected outputs. Instead of writing many very similar test methods, you can use parameterized tests. In JUnit 4 this requires an additional runner configuration. The exact setup can vary, but the concept is simple.

You provide a list of input and expected pairs, and JUnit runs the same test method once for each pair. Here is a conceptual example using JUnit 4 parameterized tests in Kotlin:

import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
class TaxCalculatorTest(
    private val income: Double,
    private val expectedTax: Double
) {
    companion object {
        @JvmStatic
        @Parameterized.Parameters
        fun data(): List<Array<Any>> {
            return listOf(
                arrayOf(0.0, 0.0),
                arrayOf(1000.0, 100.0),
                arrayOf(5000.0, 500.0)
            )
        }
    }
    @Test
    fun calculateTax_returnsExpectedValue() {
        val calculator = TaxCalculator()
        val result = calculator.calculateTax(income)
        assertEquals(expectedTax, result, 0.001)
    }
}

Although parameterized tests are more advanced than simple tests, they can reduce duplication when you need to cover many similar scenarios. For absolute beginners it is fine to start with separate test methods, then move to parameterized tests later.

Unit Tests and Code Coverage

Code coverage measures how much of your production code is executed by your tests. While this chapter does not focus on coverage tools, it is useful to understand the idea.

If a function is never called in any unit test, its coverage is zero percent. If every branch in the function is executed by at least one test, its coverage is high. Coverage tools can help you see which parts of your code are not tested yet.

High coverage does not guarantee high quality tests. A single weak test could execute many lines without asserting anything useful. Still, coverage can highlight missing tests and help you decide where to add more checks.

A practical approach for beginners is to focus on testing the most important logic. As you get more comfortable, you can start to look at coverage reports and refine your testing strategy.

Organizing Unit Tests in Your Project

As your project grows you will have many tests. Good organization helps you keep them readable and maintainable.

One simple rule is to mirror the package structure of your production code. If your main code has com.example.app.login.LoginValidator, then your test code should have com.example.app.login.LoginValidatorTest. This makes it easy to find the corresponding test when you work on a class.

Inside test classes you can group related tests by behavior. You can also add helper functions that reduce duplication, such as methods that create default objects or common test data. Be careful not to hide too much detail in helpers, since tests should remain easy to read.

When a bug appears in your app, it is often useful to write a unit test that reproduces the bug, watch the test fail, then fix the code and see the test pass. This gives you confidence that the same bug will not return in the future.

Whenever you fix a bug in logic that can be tested without Android, add a unit test that reproduces the bug. This turns past problems into permanent safeguards.

By relying on unit tests for your core logic, you can change and refactor code with more confidence. Combined with other types of tests, unit testing is one of the main tools for keeping Android apps reliable and maintainable over time.

Views: 2

Comments

Please login to add a comment.

Don't have an account? Register now!