Your first test
A smoke detector checks one thing, all the time, and makes a lot of noise the moment that thing goes wrong. You don’t have to remember to look; it tells you.
A unit test is a smoke detector for one small piece of code (a unit: usually a function or a class). It runs the code with input you chose, compares the result with what it should be, and reports a failure if they differ. You run all of them after every change, in seconds.
Unit tests are fast, deterministic, isolated checks of behavior at the level of a function or class, run on every change; integration and UI tests cover the seams they can’t. The frameworks: Swift Testing (@Test, #expect, #require; XCTest for older code and UI tests), JUnit 5 for Java and Kotlin (often with kotlin.test assertions, AssertJ, or Kotest), and Vitest/Jest for TypeScript. The design questions are the same everywhere: what to test, how to isolate it, and how to make failures informative and impossible to fake.
// Sources/Shop/Pricing.swift
func discountedCents(_ priceCents: Int, percent: Int) -> Int {
priceCents * (100 - percent) / 100
}
// Tests/ShopTests/PricingTests.swift
import Testing
@testable import Shop
@Test func discountIsApplied() {
let price = 3_000 // arrange
let result = discountedCents(price, percent: 10) // act
#expect(result == 2_700) // assert
}// src/main/kotlin/Pricing.kt
fun discountedCents(priceCents: Int, percent: Int): Int = priceCents * (100 - percent) / 100
// src/test/kotlin/PricingTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
class PricingTest {
@Test
fun `discount is applied`() {
val price = 3_000 // arrange
val result = discountedCents(price, percent = 10) // act
assertEquals(2_700, result) // assert: expected first
}
}// src/main/java/shop/Pricing.java
static int discountedCents(int priceCents, int percent) {
return priceCents * (100 - percent) / 100;
}
// src/test/java/shop/PricingTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class PricingTest {
@Test
void discountIsApplied() {
int price = 3_000; // arrange
int result = Pricing.discountedCents(price, 10); // act
assertEquals(2_700, result); // assert: expected first
}
}// src/pricing.ts
export function discountedCents(priceCents: number, percent: number): number {
return Math.floor((priceCents * (100 - percent)) / 100);
}
// src/pricing.test.ts
import { expect, it } from "vitest";
import { discountedCents } from "./pricing";
it("applies the discount", () => {
const price = 3_000; // arrange
const result = discountedCents(price, 10); // act
expect(result).toBe(2_700); // assert
});Every test has the same three steps:
- Arrange: set up the input (here, a price).
- Act: call the code you’re testing, once.
- Assert: check the result against the value it should be.
Tests live in their own folder (Tests/, src/test/, or *.test.ts next to the code), and you run them all with one command or one click: ⌘U in Xcode, the green arrows in Android Studio and IntelliJ, ./gradlew test, npx vitest. A test’s name says what it checks, so a failure tells you what broke without opening the code.
A test that can’t fail
Tap the line with the bug.
func shippingCents(totalCents: Int) -> Int {
totalCents >= 5_000 ? 0 : 499
}
@Test func freeShippingFrom50() {
let total = 4_000
let expected = total >= 5_000 ? 0 : 499
#expect(shippingCents(totalCents: total) == expected)
}fun shippingCents(totalCents: Int) = if (totalCents >= 5_000) 0 else 499
class ShippingTest {
@Test
fun freeShippingFrom50() {
val total = 4_000
val expected = if (total >= 5_000) 0 else 499
assertEquals(expected, shippingCents(total))
}
}static int shippingCents(int totalCents) { return totalCents >= 5_000 ? 0 : 499; }
class ShippingTest {
@Test
void freeShippingFrom50() {
int total = 4_000;
int expected = total >= 5_000 ? 0 : 499;
assertEquals(expected, shippingCents(total));
}
}const shippingCents = (totalCents: number) => (totalCents >= 5_000 ? 0 : 499);
it("free shipping from 50", () => {
const total = 4_000;
const expected = total >= 5_000 ? 0 : 499;
expect(shippingCents(total)).toBe(expected);
});Hint: Product decides free shipping starts above 40 EUR, and someone changes 5_000 to 4_000 in both places. Would this test notice if the new rule were wrong?
The test computes the expected value with the same formula as the code. Whatever the code does, the test agrees with it: change the threshold, introduce an off-by-one, and the test stays green. It only proves the formula equals itself.
The expected value must come from outside the code: a number you worked out by hand from the requirement. And test the interesting cases, the boundary included:
@Test func shippingRules() {
#expect(shippingCents(totalCents: 4_999) == 499)
#expect(shippingCents(totalCents: 5_000) == 0)
}@Test
fun shippingRules() {
assertEquals(499, shippingCents(4_999))
assertEquals(0, shippingCents(5_000))
}@Test
void shippingRules() {
assertEquals(499, shippingCents(4_999));
assertEquals(0, shippingCents(5_000));
}it("charges below 50 and is free from 50", () => {
expect(shippingCents(4_999)).toBe(499);
expect(shippingCents(5_000)).toBe(0);
});A good habit: when you write a new test, break the code on purpose for a second and watch the test fail. A test you’ve never seen fail is a test you can’t trust.
Test design that pays off
- Test behavior through the public API, not private methods or internal state; tests that mirror the implementation break on every refactor and catch nothing.
- One reason to fail per test. A failure name should tell you what’s broken; a 40-assertion test tells you something is.
- Fast and deterministic: milliseconds, no network, no real clock, no shared files. A suite that takes ten minutes stops being run.
- Mutation testing (PIT for the JVM, Stryker for TypeScript, Muter for Swift) changes your code automatically (flips
>=to>, removes a line) and reports the mutants your tests didn’t catch: the objective version of “watch it fail”. - Coverage shows which lines ran, not whether anything was checked; the test above has 100% coverage of
shippingCents.
- Frameworks: Swift Testing (
@Test,#expect,#require; XCTest for legacy/UI), JUnit 5 for Java and Kotlin (kotlin.test, AssertJ, Kotest), Vitest/Jest for TypeScript. - Arrange-act-assert, one behavior per test, through the public API. Expected values come from the requirement, never recomputed with the code’s own logic.
- Verify tests can fail (break the code once; mutation testing at scale). Coverage measures execution, not verification.
Check this part
What are the three steps of a unit test?
A test computes its expected value with the same formula as the code. What's wrong?
A function has 100% line coverage. What does that guarantee?