Phase 6: Tested Tip Calculator
Calculate a tip for one bill using three percentages. A small pure Kotlin calculator, seven deterministic unit tests, and one Compose user-flow test make testing concrete and easy to lecture.
Guided workshop: 2–3 hours
Before you start
- Handling User Input
- Testing Compose Apps
Application structure and responsibilities
- One screen with one main task. Finish the small working version before adding optional features.
- All required source files, Gradle configuration, manifest and resources are included in the downloadable project.
Concepts to explain: Pure Kotlin business logic · BigDecimal and explicit rounding · Input validation · JUnit tests · Compose UI testing · Small dependency boundaries
See the concept, then explain the implementation
Follow one concrete interaction through the app. Watch which responsibility is active, inspect the state snapshot, and pause to predict what happens next. Open the Kotlin excerpt to connect each visual frame to the implementation.
Explain a half-cent before testing the UI
Input → decimal arithmetic → explicit rounding → separate test evidence
Stores the bill string and percentage
Validates and calculates using BigDecimal
Checks arithmetic without an Android UI
Displays results and supports UI interaction tests
Keep input as text until validation
The text field owns a string, so the student can edit an amount. Selecting 10% changes the integer percentage. The screen calls the same pure calculator function with those two current inputs.
- billInput
- "0.05"
- tipPercent
- 10
- currency
- USD
Does a decimal keyboard guarantee that every input is a valid bill?
No. The calculator must still validate the string. Empty, malformed, non-positive, or out-of-range amounts return InvalidInput.
Connect this frame to the Kotlin source
app/src/main/java/com/mastercoding/phase6/MainActivity.kt
var billInput by rememberSaveable { mutableStateOf("") }
var tipPercent by rememberSaveable { mutableIntStateOf(15) }
val result = calculator.calculate(billInput, tipPercent)Misconception: Money calculations can rely on displaying two decimal places at the end.
Define the arithmetic and rounding rule explicitly. This app uses BigDecimal and rounds the tip before adding the total.
Misconception: A unit test verifies that the screen's buttons are connected.
A calculator unit test bypasses Compose. A UI test exercises input, interaction, and displayed output.
Misconception: The animated walkthrough runs JUnit.
It presents a source-based explanation. Run the downloadable project's tests to obtain execution evidence.
Pause, step, or scrub through the interaction. This teaching model explains the included source; it does not execute Android or run tests in the browser. Verify the behavior with the downloaded app.
Download the complete project and run it
Each ZIP includes the application source, build scripts, Gradle wrapper, manifest, resources and README. The full solution is also readable below; no feature is left as an unfinished TODO.
- Extract the ZIP. In Android Studio, open the project folder containing
settings.gradle.kts. - Use JDK 17 or 21 and install Android SDK Platform 36 plus the Build Tools requested by Android Studio.
- Allow the first Gradle sync to download dependencies. No backend account or API key is needed.
- Select an emulator or device running API 23 or newer, then run the
appconfiguration. - Start with the expected results below. Open
README.mdandTOOLCHAIN.mdfor the exact project instructions.
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTestKeep the first version small
Implementation roadmap
Tested Tip Calculator: build it one step at a time
Follow the implementation in order. Predict the result of each step, then explain the change in your own words.
Agree on examples and the rounding rule
50.00 at 20% produces 10.00 and 60.00. A 0.05 bill at 10% produces a 0.01 tip and 0.06 total.
Step-by-step implementation and lecture solution
Follow these steps in order. Each code window shows the relevant part of a real project file; the complete, unabridged files are in the source browser and ZIP. Pause at the prediction prompt before running the next behavior.
Step 1: Agree on examples and the rounding rule
The core accepts a positive USD bill of at most 1000000.00 and a tip of 10, 15, or 20 percent. Input uses a dot and at most two decimal places. Round the tip to cents using HALF_UP, then add that rounded tip to the bill.
Open: app/src/main/java/com/mastercoding/phase6/TipCalculator.kt · app/src/test/java/com/mastercoding/phase6/TipCalculatorTest.kt
@Test
fun fractionalCentRoundsHalfUpBeforeAddingTotal() {
val result = calculator.calculate("0.05", 10) as TipResult.Success
assertEquals(BigDecimal("0.01"), result.tip)
assertEquals(BigDecimal("0.06"), result.total)
}
@Test
fun decimalInputDoesNotUseBinaryFloatingPoint() {
val result = calculator.calculate("19.99", 15) as TipResult.Success
assertEquals(BigDecimal("3.00"), result.tip)
assertEquals(BigDecimal("22.99"), result.total)
}
@Test
fun whitespaceAroundAValidAmountIsAccepted() {
val result = calculator.calculate(" 12.5 ", 10) as TipResult.Success
assertEquals(BigDecimal("12.50"), result.bill)Expected behavior
Pause and explain this step
Have students calculate both examples before opening the UI. The fractional-cent example makes the rounding policy observable rather than accidental.
Step 2: Implement a pure, validated calculator
calculate trims and validates the string before converting it directly to BigDecimal. Invalid values return TipResult.InvalidInput; valid values return bill, tip, and total as TipResult.Success. No Android objects, clock, network, or mutable external state are involved.
Open: app/src/main/java/com/mastercoding/phase6/TipCalculator.kt
class TipCalculator {
fun calculate(billInput: String, tipPercent: Int): TipResult {
val input = billInput.trim()
if (input.isEmpty()) return TipResult.InvalidInput("Enter a bill amount.")
if (!Regex("\\d{1,7}(\\.\\d{1,2})?").matches(input)) {
return TipResult.InvalidInput("Use digits and up to 2 decimal places, for example 12.50.")
}
val bill = input.toBigDecimal()
if (bill <= BigDecimal.ZERO || bill > BigDecimal("1000000.00")) {
return TipResult.InvalidInput("Enter an amount greater than 0 and no more than 1000000.00.")
}
if (tipPercent !in listOf(10, 15, 20)) {
return TipResult.InvalidInput("Choose 10%, 15%, or 20%.")
}
val roundedBill = bill.setScale(2)
val tip = bill.multiply(BigDecimal(tipPercent))
.divide(BigDecimal(100))
.setScale(2, RoundingMode.HALF_UP)Expected behavior
Pause and explain this step
Trace validation in order, then the arithmetic. Explain why parsing a decimal string preserves its value and why converting from Double would weaken that guarantee.
Step 3: Prove the calculation with local unit tests
TipCalculatorTest checks ordinary values, a half-cent boundary, decimal precision, trimming, invalid input, unsupported percentages, and the maximum amount. Run :app:testDebugUnitTest without an emulator. Exact BigDecimal expectations also verify that displayed money has two decimal places.
Open: app/src/test/java/com/mastercoding/phase6/TipCalculatorTest.kt · app/src/main/java/com/mastercoding/phase6/TipCalculator.kt · README.md
@Test
fun invalidAmountsReturnAValidationResult() {
listOf("", " ", "hello", "-1", "0", "12.345", "1,50", "NaN", "1000000.01")
.forEach { input ->
assertTrue("Expected invalid input: $input", calculator.calculate(input, 15) is TipResult.InvalidInput)
}
}
@Test
fun unsupportedTipPercentageIsRejected() {
assertTrue(calculator.calculate("10.00", 99) is TipResult.InvalidInput)
}
@Test
fun largestAllowedBillIsAccepted() {
val result = calculator.calculate("1000000.00", 20) as TipResult.Success
assertEquals(BigDecimal("200000.00"), result.tip)
assertEquals(BigDecimal("1200000.00"), result.total)Expected behavior
Pause and explain this step
Ask what each test protects. Change one business rule briefly to demonstrate a useful failure, then restore it. Avoid testing private implementation details.
Step 4: Connect a small stateful form to the pure function
The Activity supplies one TipCalculator instance to TipCalculatorScreen. rememberSaveable owns bill text and the selected percentage. The screen derives a fresh result on each input change and renders the typed success or validation branch.
Open: app/src/main/java/com/mastercoding/phase6/MainActivity.kt · app/src/main/java/com/mastercoding/phase6/TipCalculator.kt
var billInput by rememberSaveable { mutableStateOf("") }
var tipPercent by rememberSaveable { mutableIntStateOf(15) }
val result = calculator.calculate(billInput, tipPercent)
Column(
modifier = modifier.fillMaxSize().imePadding()
.verticalScroll(rememberScrollState()).padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Tip Calculator", style = MaterialTheme.typography.headlineMedium)
Text("Enter a bill in USD. Choose a percentage to see the tip and total.")
OutlinedTextField(
value = billInput,
onValueChange = { billInput = it },
label = { Text("Bill amount (USD)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
isError = billInput.isNotBlank() && result is TipResult.InvalidInput,
modifier = Modifier.fillMaxWidth().testTag("billInput")Expected behavior
Pause and explain this step
Trace input → state → calculate → result → UI. Explain that this tiny dependency can be supplied directly; Hilt and additional architecture layers are optional later lessons.
Step 5: Add predictable reset and lifecycle behavior
The three chips change only tipPercent. Reset clears billInput and restores the 15% default. Small inputs use rememberSaveable so rotation restores them. Scaffold insets, keyboard padding, and scrolling keep the form reachable.
Open: app/src/main/java/com/mastercoding/phase6/MainActivity.kt
}
Button(
onClick = { billInput = ""; tipPercent = 15 },
modifier = Modifier.testTag("resetButton")
) { Text("Reset") }
Text("Tips round to the nearest cent using HALF_UP. Use a dot for decimals.")
}
}Expected behavior
Pause and explain this step
Before pressing Reset, ask students to name every value that should change. Then rotate and explain the distinction between form restoration and permanent storage.
Step 6: Verify the user's complete flow with a Compose test
TipCalculatorUiTest uses createAndroidComposeRule<MainActivity> to launch the app. It locates semantics nodes, enters 50.00, selects 20%, checks both amounts, scrolls to Reset, and verifies the restored default. Run :app:connectedDebugAndroidTest with a device or emulator.
Open: app/src/androidTest/java/com/mastercoding/phase6/TipCalculatorUiTest.kt · app/src/main/java/com/mastercoding/phase6/MainActivity.kt · README.md
@Test
fun enteringBillChoosingTipAndResetting() {
compose.onNodeWithTag("billInput").performTextInput("50.00")
compose.onNodeWithText("20%").performClick()
compose.onNodeWithTag("tipResult").assertTextEquals("Tip: USD 10.00")
compose.onNodeWithTag("totalResult").assertTextEquals("Total: USD 60.00")
compose.onNodeWithTag("resetButton").performScrollTo().performClick()
compose.onNodeWithTag("validation").assertTextEquals("Enter a bill amount.")
compose.onNodeWithText("15%").assertIsSelected()
}
}Expected behavior
Pause and explain this step
Compare the UI test with a unit test: the former proves wiring, while the latter makes numerical edge cases cheap and precise. Neither needs arbitrary sleeps or a server.
Complete source code — every file
Start with MainActivity.kt, then follow the file guide. The selector also contains the tests, manifest, resources and complete Gradle configuration. The binary Gradle wrapper JAR is included in the download.
| File | What to explain |
|---|---|
| app/src/main/java/com/mastercoding/phase6/TipCalculator.kt | The complete Android-independent calculation with input checks, a typed result, and a single explicit rounding rule. |
| app/src/main/java/com/mastercoding/phase6/MainActivity.kt | One Compose form, three percentage chips, calculated results, validation guidance, and Reset. |
| app/src/test/java/com/mastercoding/phase6/TipCalculatorTest.kt | Seven JUnit tests for normal calculation, rounding, precision, spaces, invalid input, invalid percentages, and the bill limit. |
| app/src/androidTest/java/com/mastercoding/phase6/TipCalculatorUiTest.kt | One Compose test launches the real Activity, enters a bill, selects a percentage, checks both amounts, and resets. |
| README.md | Exact build, install, local-test and emulator-test commands plus lecture checkpoints and manual checks. |
One Compose form, three percentage chips, calculated results, validation guidance, and Reset.
18 complete text files. Copy the selected file with the code panel button, or download the full project above.
package com.mastercoding.phase6
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
private val calculator = TipCalculator()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
Scaffold { innerPadding ->
TipCalculatorScreen(calculator, Modifier.padding(innerPadding).consumeWindowInsets(innerPadding))
}
}
}
}
}
@Composable
fun TipCalculatorScreen(calculator: TipCalculator, modifier: Modifier = Modifier) {
var billInput by rememberSaveable { mutableStateOf("") }
var tipPercent by rememberSaveable { mutableIntStateOf(15) }
val result = calculator.calculate(billInput, tipPercent)
Column(
modifier = modifier.fillMaxSize().imePadding()
.verticalScroll(rememberScrollState()).padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Tip Calculator", style = MaterialTheme.typography.headlineMedium)
Text("Enter a bill in USD. Choose a percentage to see the tip and total.")
OutlinedTextField(
value = billInput,
onValueChange = { billInput = it },
label = { Text("Bill amount (USD)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
isError = billInput.isNotBlank() && result is TipResult.InvalidInput,
modifier = Modifier.fillMaxWidth().testTag("billInput")
)
Text("Tip percentage", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
listOf(10, 15, 20).forEach { percentage ->
FilterChip(
selected = tipPercent == percentage,
onClick = { tipPercent = percentage },
label = { Text("$percentage%") }
)
}
}
when (result) {
is TipResult.InvalidInput -> Text(
result.message,
modifier = Modifier.testTag("validation"),
color = if (billInput.isBlank()) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.error
)
is TipResult.Success -> {
Text("Tip: USD ${result.tip.toPlainString()}", Modifier.testTag("tipResult"))
Text(
"Total: USD ${result.total.toPlainString()}",
modifier = Modifier.testTag("totalResult"),
style = MaterialTheme.typography.titleLarge
)
}
}
Button(
onClick = { billInput = ""; tipPercent = 15 },
modifier = Modifier.testTag("resetButton")
) { Text("Reset") }
Text("Tips round to the nearest cent using HALF_UP. Use a dot for decimals.")
}
}Definition of done
Check an item only after demonstrating it. Progress is saved in this browser.
Try the reference solution yourself
Run each action and compare the result. Record the device, action and observed outcome. For the testing phase, run the included unit tests and the Compose test separately: a JVM test does not prove on-device UI behavior.
| Action | Expected result |
|---|---|
| Enter 50.00 and select 20%. | Tip: USD 10.00 and Total: USD 60.00. |
| Enter 19.99 and select 15%. | Tip: USD 3.00 and Total: USD 22.99. |
| Enter 0.05 and select 10%. | Tip: USD 0.01 and Total: USD 0.06; the half-cent rounds up. |
| Paste -1, 12.345, or 1,50 after a valid amount. | Validation guidance replaces the old amounts. The app does not crash or show a stale total. |
| Tap Reset after calculating at 20%. | The bill clears, the 15% chip is selected, and Enter a bill amount appears. |
| Rotate with a valid bill and 20% selected. | Bill text, percentage, and the derived result remain. |
| Run :app:testDebugUnitTest, then :app:connectedDebugAndroidTest with an emulator. | Seven local numerical/validation tests and one Compose user-flow test pass; reports are generated under app/build/reports. |
Common mistakes and repairs
- Creating BigDecimal from a Double introduces binary floating-point values. Construct it from the validated input string.
- Rounding the total instead of the tip can violate the stated rule. Round the tip once, then add it to the bill.
- A decimal keyboard is a convenience, not validation. Pasted input still needs checks.
- Do not retain the last successful result after invalid input. Render the current typed result on every input change.
- Local tests run on the JVM; the Compose test needs an Android device/emulator and the instrumentation runner supplied in the project.
- A DI framework is not required to pass one calculator into one screen. Add layers only when they clarify a real requirement.
Instructor lecture plan
| Minutes | Classroom activity |
|---|---|
| 0–10 | Run the finished app first. Ask students to describe its input, state and expected output. |
| 10–30 | Walk through the source files in the solution order. Pause at each named function and predict the next change. |
| 30–50 | Rebuild the main feature together using the guided steps; compare behavior with the complete reference source. |
| 50–65 | Pairs reproduce an edge case and explain one callback or data-flow path to the class. |
Instructor discussion and answer notes
Trace validation in order, then the arithmetic. Explain why parsing a decimal string preserves its value and why converting from Double would weaken that guarantee.
calculate trims and validates the string before converting it directly to BigDecimal. Invalid values return TipResult.InvalidInput; valid values return bill, tip, and total as TipResult.Success. No Android objects, clock, network, or mutable external state are involved. Expected result: The same input always gives the same result. Empty, negative, malformed, overly precise, or out-of-range values produce guidance without throwing during parsing.
Ask what each test protects. Change one business rule briefly to demonstrate a useful failure, then restore it. Avoid testing private implementation details.
TipCalculatorTest checks ordinary values, a half-cent boundary, decimal precision, trimming, invalid input, unsupported percentages, and the maximum amount. Run :app:testDebugUnitTest without an emulator. Exact BigDecimal expectations also verify that displayed money has two decimal places. Expected result: All seven unit tests pass. Temporarily changing HALF_UP to DOWN fails the fractional-cent test; restoring the rule makes it pass again.
Trace input → state → calculate → result → UI. Explain that this tiny dependency can be supplied directly; Hilt and additional architecture layers are optional later lessons.
The Activity supplies one TipCalculator instance to TipCalculatorScreen. rememberSaveable owns bill text and the selected percentage. The screen derives a fresh result on each input change and renders the typed success or validation branch. Expected result: Entering 19.99 with 15% shows a 3.00 tip and 22.99 total. Replacing that with invalid text removes the old amounts and shows guidance.
Assessment rubric
| Criterion | Points | Evidence |
|---|---|---|
| Working core behavior | 40 | Demonstrate the project's acceptance checklist and expected scenarios. |
| Understand the implementation | 30 | Explain the named state owner, event callback and source files without reading a copied script. |
| Edge cases and verification | 20 | Reproduce the listed boundary/error cases and record the observed outcome. |
| Clear classroom demonstration | 10 | Show the app, trace one action and describe one small extension. |
Total: 100 points. Ask students to show the working app, explain one complete event/data path, reproduce an edge case, and make one small change themselves. Keep any limitations explicit.
Optional extensions after the core works
- Add a people count and deterministic tests for splitting the total.
- Move text into string resources and introduce locale-aware parsing and currency formatting as a separate feature.
- Add a calculator interface and a fake implementation to demonstrate dependency substitution without a DI framework.
After the intervening lessons, continue with Pocket Tasks.
For deeper study, use the official Compose course and Android architecture guide.