Skip to content
Commitwise
Menu
Programming

Unit testing

Write fast, reliable unit tests with Swift Testing, JUnit 5 and Vitest, choose the cases worth testing, replace slow or random dependencies with fakes, and avoid the tests that can't fail or fail at random.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

You change one line in the pricing code. Did it break the coupon logic, the free-shipping rule, or the receipt total? Without tests, you find out from a customer. With tests, you find out in two seconds, every time you change anything. Unit tests are small programs that check your program, and the hard part isn’t writing them; it’s writing ones that would actually fail when the code is wrong.

New to some of these? Open what you need, or skip ahead.

Basics:What are functions?

A function is a named piece of code that takes parameters and can return a result: func add(_ a: Int, _ b: Int) -> Int (Swift), fun add(a: Int, b: Int): Int (Kotlin), int add(int a, int b) (Java), function add(a: number, b: number): number (TypeScript). A function that belongs to a class is called a method. The Functions tutorial covers them in detail.

Basics:What is an interface (or protocol)?

An interface (Kotlin, Java) or protocol (Swift) is a list of things a type promises it can do, without saying how. For example, “anything that is a PaymentMethod has a pay(amount) function”.

Different classes can implement the same interface in different ways (card, PayPal, cash), and code that only knows the interface can use any of them. That’s what lets you swap one implementation for another, for example a fake one in tests.

Basics:What are annotations and attributes?

An annotation (Kotlin, Java: @Test) or attribute/macro (Swift: @Test, #expect) attaches extra information to code. It doesn’t run anything by itself; a tool reads it. Test frameworks use them to find which functions are tests and to report what they checked.

Part Not started

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:

  1. Arrange: set up the input (here, a price).
  2. Act: call the code you’re testing, once.
  3. 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

Spot the bugTests that are always green

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);
});

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

  1. What are the three steps of a unit test?

  2. A test computes its expected value with the same formula as the code. What's wrong?

  3. A function has 100% line coverage. What does that guarantee?

A unit test calls one piece of code with known input and checks the output: arrange, act, assert. Tests must be able to fail: compare with an expected value you worked out yourself, never with the code's own result.

Part Not started

What to test

You can’t test every input. Pick the ones most likely to find bugs:

Kind of caseExample for “passwords need 8+ characters and a digit”
A normal valid case"sunshine1" → valid
Boundaries7 characters with a digit → invalid; exactly 8 → valid
Each rule broken on its own10 letters, no digit → invalid
Empty and unusual input"", spaces only, emoji
ErrorsDoes it throw, return false, or crash? Whatever it should do, test it.
func isValidPassword(_ password: String) -> Bool {
    password.count >= 8 && password.contains(where: \.isNumber)
}

@Test(arguments: [
    ("sunshine1", true),
    ("short1", false),        // too short
    ("1234567", false),       // 7 characters
    ("12345678", true),       // exactly 8
    ("sunshinesun", false),   // no digit
    ("", false),
])
func passwordRules(password: String, expected: Bool) {
    #expect(isValidPassword(password) == expected)
}
fun isValidPassword(password: String) = password.length >= 8 && password.any { it.isDigit() }

class PasswordTest {
    @ParameterizedTest
    @CsvSource(
        "sunshine1, true",
        "short1, false",          // too short
        "1234567, false",         // 7 characters
        "12345678, true",         // exactly 8
        "sunshinesun, false",     // no digit
        "'', false",
    )
    fun `password rules`(password: String, expected: Boolean) {
        assertEquals(expected, isValidPassword(password))
    }
}
static boolean isValidPassword(String password) {
    return password.length() >= 8 && password.chars().anyMatch(Character::isDigit);
}

class PasswordTest {
    @ParameterizedTest
    @CsvSource({
        "sunshine1, true",
        "short1, false",          // too short
        "1234567, false",         // 7 characters
        "12345678, true",         // exactly 8
        "sunshinesun, false",     // no digit
        "'', false",
    })
    void passwordRules(String password, boolean expected) {
        assertEquals(expected, isValidPassword(password));
    }
}
export const isValidPassword = (password: string): boolean =>
  password.length >= 8 && /[0-9]/.test(password);

it.each([
  ["sunshine1", true],
  ["short1", false],          // too short
  ["1234567", false],         // 7 characters
  ["12345678", true],         // exactly 8
  ["sunshinesun", false],     // no digit
  ["", false],
])("password %j is valid: %s", (password, expected) => {
  expect(isValidPassword(password)).toBe(expected);
});

A parameterized test runs the same check once per row, and the report shows each row separately, so you see exactly which input failed. Adding a case is one line. Notice the rows that sit right at the boundary (7 and 8 characters): that’s where off-by-one mistakes (> instead of >=) hide.

Choose cases by equivalence classes (inputs the code should treat the same) and boundaries between them, plus representative invalid inputs. For logic with a rich input space (parsers, serializers, money math), property-based testing (swift-testing has no built-in, use SwiftCheck; jqwik/Kotest property tests on the JVM; fast-check in TypeScript) generates hundreds of inputs and checks invariants like “decode(encode(x)) == x” or “total never negative”, then shrinks a failure to the minimal example.

Testing errors

When a function should throw for bad input, test that too, and check which error:

enum ParseError: Error { case notANumber }

func parseQuantity(_ text: String) throws -> Int {
    guard let n = Int(text), n >= 0 else { throw ParseError.notANumber }
    return n
}

@Test func rejectsText() {
    #expect(throws: ParseError.notANumber) { try parseQuantity("abc") }
}
fun parseQuantity(text: String): Int =
    text.toIntOrNull()?.takeIf { it >= 0 } ?: throw IllegalArgumentException("not a number: $text")

@Test
fun `rejects text`() {
    assertFailsWith<IllegalArgumentException> { parseQuantity("abc") }
}
static int parseQuantity(String text) {
    try {
        int n = Integer.parseInt(text);
        if (n < 0) throw new IllegalArgumentException("negative");
        return n;
    } catch (NumberFormatException e) {
        throw new IllegalArgumentException("not a number: " + text, e);
    }
}

@Test
void rejectsText() {
    assertThrows(IllegalArgumentException.class, () -> parseQuantity("abc"));
}
export function parseQuantity(text: string): number {
  if (!/^[0-9]+$/.test(text)) throw new RangeError(`not a number: ${text}`);
  return Number(text);
}

it("rejects text", () => {
  expect(() => parseQuantity("abc")).toThrow(RangeError);
});
Exerciseeasy

Test a discount code validator

Write tests (not the implementation) for isValidCode(code), with these rules: exactly 8 characters, uppercase letters and digits only, and it must start with a letter. Use a parameterized test, and list each case with the reason it’s there.

›

For each rule, have one case that breaks only that rule, so a failure points to one rule. Then add the boundaries (7 and 9 characters) and empty input.

SolutionTry it yourself first
@Test(arguments: [
    ("SPRING26", true),     // valid
    ("A1B2C3D4", true),     // valid, mixed letters and digits
    ("SPRING2", false),     // 7 characters
    ("SPRING266", false),   // 9 characters
    ("spring26", false),    // lowercase
    ("SPRING-6", false),    // other character
    ("1SPRING6", false),    // starts with a digit
    ("", false),            // empty
])
func codeRules(code: String, expected: Bool) {
    #expect(isValidCode(code) == expected)
}
@ParameterizedTest
@CsvSource(
    "SPRING26, true",       // valid
    "A1B2C3D4, true",       // valid, mixed letters and digits
    "SPRING2, false",       // 7 characters
    "SPRING266, false",     // 9 characters
    "spring26, false",      // lowercase
    "SPRING-6, false",      // other character
    "1SPRING6, false",      // starts with a digit
    "'', false",            // empty
)
fun `code rules`(code: String, expected: Boolean) {
    assertEquals(expected, isValidCode(code))
}
@ParameterizedTest
@CsvSource({
    "SPRING26, true",       // valid
    "A1B2C3D4, true",       // valid, mixed letters and digits
    "SPRING2, false",       // 7 characters
    "SPRING266, false",     // 9 characters
    "spring26, false",      // lowercase
    "SPRING-6, false",      // other character
    "1SPRING6, false",      // starts with a digit
    "'', false",            // empty
})
void codeRules(String code, boolean expected) {
    assertEquals(expected, isValidCode(code));
}
it.each([
  ["SPRING26", true],       // valid
  ["A1B2C3D4", true],       // valid, mixed letters and digits
  ["SPRING2", false],       // 7 characters
  ["SPRING266", false],     // 9 characters
  ["spring26", false],      // lowercase
  ["SPRING-6", false],      // other character
  ["1SPRING6", false],      // starts with a digit
  ["", false],              // empty
])("code %j is valid: %s", (code, expected) => {
  expect(isValidCode(code)).toBe(expected);
});

Eight rows cover every rule from both sides and both length boundaries. Writing the tests before the implementation, from the rules alone, is test-driven development (TDD): the list above is also a precise spec you can show a product manager.

Tests that can’t fail, async edition

Spot the bugAn async test that's always green

Tap the line with the bug.

@Test func rejectsExpiredCoupon() {
    Task {
        let result = try await api.apply(coupon: "OLD2020")
        #expect(result == .expired)
    }
}
@Test
fun rejectsExpiredCoupon() {
    GlobalScope.launch {
        val result = api.apply("OLD2020")
        assertEquals(Result.EXPIRED, result)
    }
}
@Test
void rejectsExpiredCoupon() {
    api.apply("OLD2020").thenAccept(result ->
        assertEquals(Result.EXPIRED, result)
    );
}
it("rejects an expired coupon", () => {
  api.apply("OLD2020").then((result) => {
    expect(result).toBe("expired");
  });
});

  • Cases: equivalence classes, boundaries on both sides, invalid and empty input, error paths with the specific error type.
  • Parameterized: Swift Testing @Test(arguments:), JUnit 5 @ParameterizedTest + @CsvSource/@MethodSource, Vitest it.each. Property-based for rich input spaces (fast-check, jqwik, Kotest, SwiftCheck).
  • Async tests must await: Swift async throws tests, Kotlin runTest, Java get(timeout), TypeScript async + await expect(...).resolves. Fire-and-forget assertions pass vacuously.

Check this part

  1. Passwords need at least 8 characters. Which inputs test the boundary?

  2. What does a parameterized test do?

  3. A test wraps its work in Task { … #expect(…) } without awaiting. What happens?A test launches the work in GlobalScope without waiting. What happens?A test asserts inside thenAccept without waiting for the future. What happens?A test asserts inside .then() without returning or awaiting the promise. What happens?

Test the normal case, the boundaries, empty and invalid input, and errors. Parameterized tests run one test over many inputs. Each test should check one behavior, so its name says what broke.

Part Not started

Fakes and isolation

Pilots train in flight simulators: the cockpit is real, but the weather, the engine failure and the runway are controlled, so every lesson can be repeated exactly. Nobody learns emergency landings with a real engine fire.

A unit test does the same for code that talks to the outside world. The class you’re testing is real, but its dependencies (the payment provider, the server, the clock) are replaced with fakes you control: they answer instantly, the same way every time, and can pretend to fail.

Test doubles: a fake has a working simplified implementation (in-memory repository), a stub returns canned answers, a spy records calls, a mock verifies expected interactions. Prefer fakes and stubs with state-based assertions (“the order was saved”); interaction-heavy mocking (“save was called once with these args”) couples tests to implementation. Isolation needs a seam: constructor injection of interfaces, which is the testing payoff of dependency injection. Swift and TypeScript fakes are usually handwritten; Mockito/MockK on the JVM and vi.fn() in Vitest generate them when needed.

protocol PaymentGateway {
    func charge(cents: Int) async throws -> String
}

final class Checkout {
    private let payments: any PaymentGateway
    init(payments: any PaymentGateway) { self.payments = payments }

    func pay(cents: Int) async -> String {
        do { return "Paid: \(try await payments.charge(cents: cents))" }
        catch { return "Payment failed" }
    }
}

final class FakeGateway: PaymentGateway {
    var shouldFail = false
    private(set) var charged: [Int] = []
    func charge(cents: Int) async throws -> String {
        if shouldFail { throw URLError(.notConnectedToInternet) }
        charged.append(cents)
        return "test-1"
    }
}

@Test func showsFailureWhenPaymentFails() async {
    let gateway = FakeGateway()
    gateway.shouldFail = true
    let checkout = Checkout(payments: gateway)

    #expect(await checkout.pay(cents: 1_000) == "Payment failed")
    #expect(gateway.charged.isEmpty)
}
interface PaymentGateway {
    suspend fun charge(cents: Int): String
}

class Checkout(private val payments: PaymentGateway) {
    suspend fun pay(cents: Int): String =
        try { "Paid: ${payments.charge(cents)}" } catch (e: IOException) { "Payment failed" }
}

class FakeGateway : PaymentGateway {
    var shouldFail = false
    val charged = mutableListOf<Int>()
    override suspend fun charge(cents: Int): String {
        if (shouldFail) throw IOException("offline")
        charged += cents
        return "test-1"
    }
}

class CheckoutTest {
    @Test
    fun `shows failure when payment fails`() = runTest {
        val gateway = FakeGateway().apply { shouldFail = true }
        val checkout = Checkout(gateway)

        assertEquals("Payment failed", checkout.pay(1_000))
        assertTrue(gateway.charged.isEmpty())
    }
}
interface PaymentGateway {
    String charge(int cents) throws IOException;
}

final class Checkout {
    private final PaymentGateway payments;
    Checkout(PaymentGateway payments) { this.payments = payments; }

    String pay(int cents) {
        try { return "Paid: " + payments.charge(cents); }
        catch (IOException e) { return "Payment failed"; }
    }
}

final class FakeGateway implements PaymentGateway {
    boolean shouldFail = false;
    final List<Integer> charged = new ArrayList<>();
    public String charge(int cents) throws IOException {
        if (shouldFail) throw new IOException("offline");
        charged.add(cents);
        return "test-1";
    }
}

class CheckoutTest {
    @Test
    void showsFailureWhenPaymentFails() {
        FakeGateway gateway = new FakeGateway();
        gateway.shouldFail = true;
        Checkout checkout = new Checkout(gateway);

        assertEquals("Payment failed", checkout.pay(1_000));
        assertTrue(gateway.charged.isEmpty());
    }
}
export interface PaymentGateway {
  charge(cents: number): Promise<string>;
}

export class Checkout {
  constructor(private readonly payments: PaymentGateway) {}

  async pay(cents: number): Promise<string> {
    try { return `Paid: ${await this.payments.charge(cents)}`; }
    catch { return "Payment failed"; }
  }
}

class FakeGateway implements PaymentGateway {
  shouldFail = false;
  readonly charged: number[] = [];
  async charge(cents: number): Promise<string> {
    if (this.shouldFail) throw new Error("offline");
    this.charged.push(cents);
    return "test-1";
  }
}

it("shows a failure when payment fails", async () => {
  const gateway = new FakeGateway();
  gateway.shouldFail = true;
  const checkout = new Checkout(gateway);

  expect(await checkout.pay(1_000)).toBe("Payment failed");
  expect(gateway.charged).toEqual([]);
});

The trick is in Checkout’s constructor: it asks for any PaymentGateway, not a specific provider. The app passes the real one; the test passes FakeGateway, which never touches the network, can be told to fail, and remembers what it was asked to charge. That lets you test the failure path, which is almost impossible to trigger reliably with a real payment provider.

Practice: things that make tests unreliable

  1. The network or a database: slow, and fails for reasons unrelated to your code. Replace with a fake behind an interface.
  2. The current time: a test of “expires in 1 hour” that reads the real clock fails at midnight, on the last day of the month, or in another time zone. Pass a clock (or “now”) in.
  3. Randomness: pass in the random generator, or a fixed seed.
  4. Shared state between tests: a static variable, a singleton, a file one test leaves behind. Each test should create everything it needs, fresh.
  5. Order: run tests in random order (most frameworks can); a test that only passes after another one has a hidden dependency.
Exerciseeasy

A test that depends on the clock

isExpired(coupon) compares coupon.expiresAt with the current time, read inside the function. Change the design so you can test “one second before expiry → not expired” and “exactly at expiry → expired” without waiting and without depending on when the test runs.

›

Don’t read the clock inside the function. Take “now” as a parameter, or take a clock object that the app sets to the real clock and the test sets to a fixed time.

SolutionTry it yourself first
struct Coupon { let expiresAt: Date }

func isExpired(_ coupon: Coupon, now: Date = .now) -> Bool {
    now >= coupon.expiresAt
}

@Test func expiryBoundary() {
    let expiry = Date(timeIntervalSince1970: 1_800_000_000)
    let coupon = Coupon(expiresAt: expiry)
    #expect(!isExpired(coupon, now: expiry.addingTimeInterval(-1)))
    #expect(isExpired(coupon, now: expiry))
}
data class Coupon(val expiresAt: Instant)

fun isExpired(coupon: Coupon, clock: Clock = Clock.systemUTC()): Boolean =
    !Instant.now(clock).isBefore(coupon.expiresAt)

@Test
fun `expiry boundary`() {
    val expiry = Instant.parse("2027-01-15T08:00:00Z")
    val coupon = Coupon(expiry)
    assertFalse(isExpired(coupon, Clock.fixed(expiry.minusSeconds(1), ZoneOffset.UTC)))
    assertTrue(isExpired(coupon, Clock.fixed(expiry, ZoneOffset.UTC)))
}
record Coupon(Instant expiresAt) {}

static boolean isExpired(Coupon coupon, Clock clock) {
    return !Instant.now(clock).isBefore(coupon.expiresAt());
}

@Test
void expiryBoundary() {
    Instant expiry = Instant.parse("2027-01-15T08:00:00Z");
    Coupon coupon = new Coupon(expiry);
    assertFalse(isExpired(coupon, Clock.fixed(expiry.minusSeconds(1), ZoneOffset.UTC)));
    assertTrue(isExpired(coupon, Clock.fixed(expiry, ZoneOffset.UTC)));
}
interface Coupon { expiresAt: Date }

export function isExpired(coupon: Coupon, now: Date = new Date()): boolean {
  return now.getTime() >= coupon.expiresAt.getTime();
}

it("expires exactly at expiresAt", () => {
  const expiry = new Date("2027-01-15T08:00:00Z");
  const coupon = { expiresAt: expiry };
  expect(isExpired(coupon, new Date(expiry.getTime() - 1000))).toBe(false);
  expect(isExpired(coupon, expiry)).toBe(true);
});

The production call stays isExpired(coupon) thanks to the default; the test supplies a fixed moment. The boundary (“exactly at expiry”) is now a deliberate, tested decision instead of whatever the code happened to do. Vitest also offers vi.useFakeTimers() and vi.setSystemTime(...) for code you can’t change.

Production puzzles

Spot the bugPasses alone, fails in the suite

Tap the line with the bug.

enum Analytics {
    static var events: [String] = []
    static func track(_ name: String) { events.append(name) }
}

@Test func checkoutTracksPurchase() async {
    await Checkout(payments: FakeGateway()).complete()
    #expect(Analytics.events == ["purchase"])
}
object Analytics {
    val events = mutableListOf<String>()
    fun track(name: String) { events += name }
}

@Test
fun checkoutTracksPurchase() = runTest {
    Checkout(FakeGateway()).complete()
    assertEquals(listOf("purchase"), Analytics.events)
}
final class Analytics {
    static final List<String> EVENTS = new ArrayList<>();
    static void track(String name) { EVENTS.add(name); }
}

@Test
void checkoutTracksPurchase() {
    new Checkout(new FakeGateway()).complete();
    assertEquals(List.of("purchase"), Analytics.EVENTS);
}
export const analytics = {
  events: [] as string[],
  track(name: string) { this.events.push(name); },
};

it("checkout tracks purchase", async () => {
  await new Checkout(new FakeGateway()).complete();
  expect(analytics.events).toEqual(["purchase"]);
});

Exercisemedium

Fix a flaky test suite

A mobile team’s unit suite fails about 1 run in 15 on CI, always passes locally, and the team now re-runs failures automatically. The failing tests vary. You find: tests that call the real Date()/Instant.now(), a UserDefaults/SharedPreferences instance shared by tests, a fake server with a 100 ms sleep before responses, and tests asserting on the order of items returned from a Set/HashMap. Lay out a plan.

›

Group the causes: time, shared state, real waiting, unspecified order. Each has a standard fix. Then decide what to do with the automatic retries.

SolutionTry it yourself first
  1. Stop hiding it: remove automatic retries (or make them report “flaky” loudly). Record which tests fail together; turn on random ordering locally so developers see it too.
  2. Time: inject a clock (Clock, a now parameter, a DateProvider protocol) everywhere business logic reads the time; tests use fixed instants in UTC. CI machines run in a different time zone and are slower; both expose clock reads.
  3. Shared persistent state: give each test its own store (UserDefaults(suiteName: UUID().uuidString), an in-memory DataStore/SharedPreferences fake, a temp directory created per test) and inject it.
  4. Real waiting: replace sleeps with controllable schedulers: Kotlin runTest virtual time and StandardTestDispatcher, Swift Clock-based APIs with a test clock (swift-clocks) or direct async calls, Vitest fake timers. CI’s slower machines make “100 ms should be enough” false.
  5. Unspecified order: compare sets as sets, sort before comparing lists from hash-based collections, or assert on content (containsExactlyInAnyOrder).
  6. Prevent regressions: a lint rule against Date()/Instant.now() in business modules, a CI job that runs the suite 20× in random order nightly and files issues for any failure, and a quarantine list with an owner and a deadline rather than permanent retries.
  • Doubles: fakes and stubs with state assertions over interaction-heavy mocks. Seams via constructor-injected interfaces (handwritten fakes in Swift/TS; MockK, Mockito, vi.fn() when needed).
  • Isolate nondeterminism: inject clocks (Clock.fixed, now parameters), random sources and stores; virtual time (runTest, fake timers) instead of sleeps.
  • No shared global state between tests (singletons, statics, UserDefaults.standard); run randomized and parallel to surface order dependence.
  • Never paper over flakiness with automatic retries; quarantine with an owner.

Check this part

  1. Why does Checkout take a PaymentGateway in its constructor instead of creating one?

  2. A test of "expires in one hour" reads the real clock. What can go wrong?

  3. A test passes alone but fails in the full suite. Most likely cause?

  4. The team re-runs failing tests automatically. Good idea?

Replace slow, random or external dependencies (network, database, clock) with fakes you control, passed in through the constructor. Each test gets fresh state, so tests pass alone and together, in any order, at any time of day.

Key takeaways

  • A unit test arranges input, acts once, and asserts against a value you worked out from the requirement.
  • Test normal cases, boundaries, empty input and errors; parameterized tests make many cases cheap.
  • Replace the network, the database and the clock with fakes passed in through the constructor.
  • Make sure every test can fail: break the code once and watch it go red.
  • Test behavior through public APIs; coverage isn’t verification, mutation testing is closer.
  • Async tests must await; fire-and-forget assertions pass vacuously.
  • Isolate time, randomness, storage and global state; prefer fakes with state assertions over interaction mocks.
  • Treat flakiness as a bug: randomize order, run in parallel, remove retries, fix the cause.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.