You have built apps that work. But how do you know they work? So far the answer has been "I ran it and tapped around". That is fine for a first app, and a disaster for a real one. Imagine an app with fifty screens. After every change you would need to tap through all fifty to be sure you broke nothing. Nobody does that, so bugs slip out.
Automated tests are small programs that check your app for you, in seconds, as often as you like. In this lesson you will write your first ones for a tip calculator: local unit tests that check calculations, and Compose UI tests that check what the user sees.
This lesson covers the Write automated tests codelab in Unit 2 of Google's official Android Basics with Compose course.
By the end you will be able to:
- Explain why automated tests exist and what a "test" really is.
- Tell local tests (the
testfolder) from instrumented tests (theandroidTestfolder). - Write a JUnit test using the Arrange, Act, Assert pattern.
- Avoid the locale trap that breaks currency tests on other people's computers.
- Write a Compose UI test that types into text fields and checks the screen.
- Run tests in Android Studio and from the command line, and read the results.
Mindset: a test is a question you ask your code that has a known answer. "If the bill is 10 and the tip is 20 per cent, is the tip 2?" If the code answers anything else, the test fails and tells you exactly where.
- Functions with parameters and defaults (lesson 8). Our calculator is a function with default arguments.
- State and text fields (lesson 19). The UI test types into text fields that update state.
- Gradle dependencies (lesson 03). Tests need libraries, declared in
build.gradle.ktsand the version cataloglibs.versions.toml. - Annotations such as
@Testand@Composable. An annotation is a label that tells a tool how to treat the code beneath it.
The code under test
To keep this lesson self-contained, here is the calculation we will test. If you built the tip calculator in lesson 19 your function is probably very similar. Use this version so the tests below match exactly.
package com.example.tipcalculator
import java.text.NumberFormat
/**
* Returns the tip as formatted currency text, for example "$2.00".
* [tipPercent] is a percentage, so 20.0 means 20 per cent.
*/
fun calculateTip(
amount: Double,
tipPercent: Double = 15.0,
roundUp: Boolean = false
): String {
var tip = tipPercent / 100 * amount
if (roundUp) {
tip = kotlin.math.ceil(tip)
}
return NumberFormat.getCurrencyInstance().format(tip)
}
Read it once. It multiplies, optionally rounds up to the next whole currency unit with ceil, and formats the result for the user's locale. That last step is important for a trap we reach shortly.
The test pyramid
Different tests cost different amounts. A healthy project has many cheap tests and a few expensive ones:
/\
/UI\ few: slow, need an emulator or phone
/----\
/ unit \ many: fast, run on your computer only
/--------\
| Kind | Folder | Runs on | Speed | Good for |
|---|---|---|---|---|
| Local unit test | app/src/test |
Your computer's JVM | Milliseconds | Calculations, ViewModels, parsing |
| Instrumented test | app/src/androidTest |
Emulator or real phone | Seconds | Compose UI, database, anything needing Android |
Rule of thumb: if the code does not need Android, test it as a local unit test. It is faster, and you will actually run it.
Anatomy of a test: Arrange, Act, Assert
Every good test has three parts, and naming them makes tests easy to write and read:
- Arrange the situation: create inputs and expected results.
- Act: call the code you are testing.
- Assert: compare the actual result with the expected one.
A test passes when all assertions hold and fails when one does not. A failing test is not bad news. It is the test doing its job.
Why tests are worth the effort
- They catch regressions: an old feature quietly breaking when you add a new one.
- They are documentation: each test is a worked example of how the function behaves.
- They let you refactor without fear: change the insides, run the tests, trust the green ticks.
- They force you to design code that is easy to test, which is usually easier to understand too.
Switch the Project tool window to Project view (not Android view) and look inside app/src. You will see three folders: main (your app), test (local tests) and androidTest (instrumented tests). The template already created a sample test file in each of the last two.
The Empty Activity template also adds the testing libraries. In app/build.gradle.kts look for lines like these in the dependencies block (the exact names depend on your template version):
dependencies {
// ... app dependencies ...
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
What each line does:
testImplementationmakes a library available only to local tests.junitis the testing framework.androidTestImplementationmakes a library available only to instrumented tests.ui-test-junit4gives youcreateComposeRule()and the finders.debugImplementation(libs.androidx.ui.test.manifest)is required for Compose UI tests: it adds a manifest entry that lets the test activity launch. If you forget it, UI tests fail with a confusing "no activity" error.
The matching entries live in gradle/libs.versions.toml. Never type version numbers from a tutorial blindly. Use the versions the template gives you, or ask Android Studio to suggest updates.
Create the test file
- In the Android view, expand
com.example.tipcalculator (test). - Right-click it, choose New > Kotlin Class/File, and name it
TipCalculatorTests. - Write the test:
package com.example.tipcalculator
import org.junit.Assert.assertEquals
import org.junit.Test
import java.text.NumberFormat
class TipCalculatorTests {
@Test
fun calculateTip_20PercentNoRoundup() {
// Arrange
val amount = 10.00
val tipPercent = 20.00
val expectedTip = NumberFormat.getCurrencyInstance().format(2)
// Act
val actualTip = calculateTip(amount = amount, tipPercent = tipPercent, roundUp = false)
// Assert
assertEquals(expectedTip, actualTip)
}
}
Line by line
@Testtells JUnit "this function is a test, run it".- The name
calculateTip_20PercentNoRoundupfollows the convention functionName_scenario. When it fails, the name alone explains what broke. assertEquals(expected, actual)passes when both are equal. Always put the expected value first, because failure messages assume that order.NumberFormat.getCurrencyInstance().format(2)is the locale trap solution. If you wrote the literal"$2.00"and a teammate in Nigeria ran the test with a Naira locale, the actual result would be₦2.00and the test would fail for a reason unrelated to your code. Building the expected text with the same formatter makes the test pass everywhere.
Run it
Click the green triangle beside the class name or function and choose Run 'TipCalculatorTests'. The Run window shows a green tick and a summary such as 1 test passed. It finishes in under a second because nothing needs the emulator.
Make it fail on purpose
Change expectedTip to format(3) and run again. The failure message tells you something like expected:<$3.00> but was:<$2.00>. Learning to read these messages is half of testing. Change it back.
More tests
One test proves little. Add a second that exercises rounding:
@Test
fun calculateTip_15PercentWithRoundup() {
val expectedTip = NumberFormat.getCurrencyInstance().format(2)
val actualTip = calculateTip(amount = 10.00, tipPercent = 15.00, roundUp = true)
assertEquals(expectedTip, actualTip)
}
@Test
fun calculateTip_defaultPercentIsFifteen() {
val expectedTip = NumberFormat.getCurrencyInstance().format(1.5)
val actualTip = calculateTip(amount = 10.00)
assertEquals(expectedTip, actualTip)
}
Check the arithmetic: 15 per cent of 10 is 1.5, rounded up it is 2. And with the default of 15 per cent and no rounding, the tip is 1.5. Each test uses named arguments so the call explains itself.
Local tests cannot see the screen. To test what the user sees, you write an instrumented UI test that launches your composable on a device or emulator, performs actions and checks the result.
A screen worth testing
Here is a minimal tip screen. It has two text fields, a Switch and a result line. Put it in TipScreen.kt in main:
package com.example.tipcalculator
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
@Composable
fun TipScreen(modifier: Modifier = Modifier) {
var amountInput by remember { mutableStateOf("") }
var percentInput by remember { mutableStateOf("") }
var roundUp by remember { mutableStateOf(false) }
val amount = amountInput.toDoubleOrNull() ?: 0.0
val percent = percentInput.toDoubleOrNull() ?: 0.0
val tip = calculateTip(amount, percent, roundUp)
Column(modifier = modifier.padding(16.dp)) {
OutlinedTextField(
value = amountInput,
onValueChange = { amountInput = it },
label = { Text("Bill Amount") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
OutlinedTextField(
value = percentInput,
onValueChange = { percentInput = it },
label = { Text("Tip Percentage") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
Switch(checked = roundUp, onCheckedChange = { roundUp = it })
Text(text = "Tip Amount: $tip")
}
}
Write the UI test
Create TipUiTests.kt in the androidTest folder (com.example.tipcalculator (androidTest)):
package com.example.tipcalculator
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import org.junit.Rule
import org.junit.Test
import java.text.NumberFormat
class TipUiTests {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun calculate_20_percent_tip() {
composeTestRule.setContent {
TipScreen()
}
composeTestRule.onNodeWithText("Bill Amount").performTextInput("10")
composeTestRule.onNodeWithText("Tip Percentage").performTextInput("20")
val expectedTip = NumberFormat.getCurrencyInstance().format(2)
composeTestRule.onNodeWithText("Tip Amount: $expectedTip").assertExists(
"No node with this text was found."
)
}
}
How it works
createComposeRule()creates a rule: a helper JUnit runs around every test. It launches an empty activity and gives yousetContent.setContent { TipScreen() }shows only the composable you are testing, with no navigation or theme noise. Testing a composable in isolation keeps tests fast and focused.onNodeWithText("Bill Amount")is a finder. Compose builds a semantics tree describing the screen for accessibility and testing, and finders search that tree. Here we find the node whose text is the label.performTextInput("10")is an action: it types into the field.assertExists(...)is an assertion: it fails with your message if no node matches.
Start an emulator (lesson 04), then run the test with the green triangle. The emulator flashes your screen for a moment, types the numbers, and the Run window reports a pass.
Preview of lesson 45: Compose testing has much more: tags,
useUnmergedTree, waiting for async work, and debugging flaky tests. Here you learned the shape; there you will master it.
| What | How |
|---|---|
| One test | Click the green triangle beside it |
| A whole class or folder | Right-click it and choose Run Tests |
| All local tests (terminal) | ./gradlew test (gradlew.bat test on Windows) |
| All instrumented tests (terminal) | ./gradlew connectedAndroidTest with a device or emulator attached |
| HTML report | app/build/reports/tests/testDebugUnitTest/index.html |
Run all local tests before every commit. Because they are fast, you will not mind.
Exercise 1: Edge cases
Good testers think about the strange inputs. Add local unit tests for each case below and decide what the function should do first:
- A bill of
0.0returns a zero tip. - A tip percentage of
0.0returns a zero tip. - Rounding up when the tip is already a whole number (for example a bill of 20 at 10 per cent) leaves it unchanged.
- A tiny bill of
0.01at 10 per cent with rounding up returns a tip of 1.
@Test
fun calculateTip_zeroBill() {
val expected = NumberFormat.getCurrencyInstance().format(0)
assertEquals(expected, calculateTip(amount = 0.0, tipPercent = 20.0))
}
@Test
fun calculateTip_zeroPercent() {
val expected = NumberFormat.getCurrencyInstance().format(0)
assertEquals(expected, calculateTip(amount = 50.0, tipPercent = 0.0))
}
@Test
fun calculateTip_roundUpOnWholeNumberKeepsValue() {
val expected = NumberFormat.getCurrencyInstance().format(2)
assertEquals(expected, calculateTip(amount = 20.0, tipPercent = 10.0, roundUp = true))
}
@Test
fun calculateTip_tinyBillRoundsUpToOne() {
val expected = NumberFormat.getCurrencyInstance().format(1)
assertEquals(expected, calculateTip(amount = 0.01, tipPercent = 10.0, roundUp = true))
}
Why the last two matter: ceil(2.0) is 2.0, so a whole tip stays put, and ceil(0.001) is 1.0, so any positive tip rounds up to at least one. Edge cases like these are where real bugs hide.
Exercise 2: Test the Switch
Write a UI test that types a bill of 10 and a percentage of 15, taps the round-up Switch, and checks that the tip text becomes the currency value for 2. Hint: the Switch has no text, so give it a test tag with Modifier.testTag("roundUp") in TipScreen, then find it with onNodeWithTag("roundUp") and call performClick().
In TipScreen, change the Switch to:
Switch(
checked = roundUp,
onCheckedChange = { roundUp = it },
modifier = Modifier.testTag("roundUp")
)
with import androidx.compose.ui.platform.testTag. Then the test:
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
@Test
fun roundUpSwitch_roundsTipUp() {
composeTestRule.setContent { TipScreen() }
composeTestRule.onNodeWithText("Bill Amount").performTextInput("10")
composeTestRule.onNodeWithText("Tip Percentage").performTextInput("15")
composeTestRule.onNodeWithTag("roundUp").performClick()
val expectedTip = NumberFormat.getCurrencyInstance().format(2)
composeTestRule.onNodeWithText("Tip Amount: $expectedTip").assertExists()
}
A tag is the safest way to find UI that has no text. Tags are invisible to users and never read by screen readers.
Break your calculator on purpose by changing ceil to floor in calculateTip. Run all your tests and list which ones fail. Which failing test would have been the first to warn a teammate?
Mistake #1: Hard-coding currency text. The wrong code: assertEquals("$2.00", actual). What happens: it passes on your laptop and fails on a colleague's with a different locale. The fix: build the expected text with NumberFormat.getCurrencyInstance() or pass a fixed Locale into the function.
Mistake #2: Putting plain logic tests in androidTest. What happens: they need an emulator and take ten times longer, so you stop running them. The fix: anything that does not need Android belongs in test.
Mistake #3: Swapping expected and actual. What happens: the test still passes or fails correctly, but the failure message is backwards and confusing. The fix: assertEquals(expected, actual), always in that order.
Mistake #4: Forgetting ui-test-manifest. What happens: UI tests crash with a message about being unable to find an activity. The fix: add debugImplementation(libs.androidx.ui.test.manifest).
Mistake #5: Testing the wrong thing. The wrong test: checking that a private variable has a certain value. The fix: test behaviour the user can observe, such as the text on the screen or the return value, not internal details that may change.
Mistake #6: One giant test. What happens: when it fails you do not know which part broke. The fix: small tests, one behaviour each, named by function_scenario.
Mistake #7: Tests that depend on each other. What happens: they pass in one order and fail in another. The fix: every test builds its own inputs and never relies on leftovers from another test.
Mistake #8: Ignoring a failing test. The fix: a red test means either the code or the test is wrong. Fix one of them immediately. A test suite people ignore is worse than none.
Question 1. What is the difference between a local unit test and an instrumented test?
A local unit test lives in src/test and runs on your computer's JVM, which is fast. An instrumented test lives in src/androidTest and runs on an emulator or device, so it can use real Android components such as Compose UI. Prefer local tests wherever possible.
Question 2. What are the three parts of a well-structured test?
Arrange (set up inputs and expected values), Act (call the code under test) and Assert (compare actual with expected).
Question 3. Why is assertEquals("$2.00", actual) risky in a currency test?
NumberFormat.getCurrencyInstance() formats using the device or JVM locale, so the symbol and separators vary. Build the expected text with the same formatter or fix the locale.
Question 4. What does createComposeRule() give you, and why test a composable in isolation?
It gives you a JUnit rule that launches a test activity and provides setContent, finders, actions and assertions. Showing only the composable under test keeps the test fast, focused and independent of navigation or theming.
Question 5. How do you find a Switch that has no text in a UI test?
Give it Modifier.testTag("name") and locate it with onNodeWithTag("name").
Go deeper: official resources
- Android Basics with Compose, Unit 2, Pathway 3: Interacting with UI and state: the official pathway containing the Write automated tests codelab.
- Android Basics with Compose course page: all units in order.
- Testing codelab for Jetpack Compose: the advanced Compose testing you will study in lesson 45.
- Jetpack Compose for Android developers, pathway 4: Accessibility, testing and performance: where testing is explored further.
Project goal: Write a small function and prove it works with automated tests, the way professionals do.
Requirements:
- Create
fun gradeFor(score: Int): StringinGradeCalculator.ktusing these rules: 70 to 100 is"A", 60 to 69 is"B", 50 to 59 is"C", 40 to 49 is"D", 0 to 39 is"F", and any score below 0 or above 100 returns"Invalid". - Write at least eight local unit tests, including the boundary values 70, 69, 60, 50, 40, 39, 0, 100, -1 and 101.
- Write one Compose UI test for a small screen that has a text field for the score and a
Textthat showsGrade: X. - Run the whole suite from the terminal with
./gradlew test.
Stretch goal: Use JUnit's @Test(expected = ...) or assertThrows after changing the function to throw an exception for invalid scores, and update the tests.
package com.example.tipcalculator
fun gradeFor(score: Int): String = when (score) {
in 70..100 -> "A"
in 60..69 -> "B"
in 50..59 -> "C"
in 40..49 -> "D"
in 0..39 -> "F"
else -> "Invalid"
}
package com.example.tipcalculator
import org.junit.Assert.assertEquals
import org.junit.Test
class GradeCalculatorTests {
@Test fun gradeFor_100_isA() = assertEquals("A", gradeFor(100))
@Test fun gradeFor_70_isA() = assertEquals("A", gradeFor(70))
@Test fun gradeFor_69_isB() = assertEquals("B", gradeFor(69))
@Test fun gradeFor_60_isB() = assertEquals("B", gradeFor(60))
@Test fun gradeFor_59_isC() = assertEquals("C", gradeFor(59))
@Test fun gradeFor_50_isC() = assertEquals("C", gradeFor(50))
@Test fun gradeFor_49_isD() = assertEquals("D", gradeFor(49))
@Test fun gradeFor_40_isD() = assertEquals("D", gradeFor(40))
@Test fun gradeFor_39_isF() = assertEquals("F", gradeFor(39))
@Test fun gradeFor_0_isF() = assertEquals("F", gradeFor(0))
@Test fun gradeFor_negative_isInvalid() = assertEquals("Invalid", gradeFor(-1))
@Test fun gradeFor_101_isInvalid() = assertEquals("Invalid", gradeFor(101))
}
package com.example.tipcalculator
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@Composable
fun GradeScreen() {
var input by remember { mutableStateOf("") }
val grade = input.toIntOrNull()?.let { gradeFor(it) } ?: "-"
Column {
OutlinedTextField(
value = input,
onValueChange = { input = it },
label = { Text("Score") }
)
Text(text = "Grade: $grade")
}
}
package com.example.tipcalculator
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import org.junit.Rule
import org.junit.Test
class GradeUiTests {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun score_72_showsGradeA() {
composeTestRule.setContent { GradeScreen() }
composeTestRule.onNodeWithText("Score").performTextInput("72")
composeTestRule.onNodeWithText("Grade: A").assertExists()
}
}
Run ./gradlew test and you should see the twelve local tests pass. Run ./gradlew connectedAndroidTest with an emulator running to execute the UI test.
What makes this a good suite: every grade boundary appears on both sides (70 and 69, 60 and 59, and so on). Boundaries are where off-by-one mistakes such as writing > instead of >= hide. If you change 70..100 to 71..100, the test gradeFor_70_isA fails immediately and points at the exact bug.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 20 project”.
On github.com click New repository, name it android-lesson-20-automated-tests-for-your-app, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 20 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: What Testing Is
- Setting Up: Folders and Dependencies
- Step-by-Step: Your First Local Unit Tests
- Step-by-Step: Your First Compose UI Test
- Running Tests Like a Professional
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check