Testing with Swift Testing
The modern test framework — @Test, #expect, parameterised cases — and the judgement of what is worth testing at all.
By the end you will be able to
- Write tests with @Test, #expect and #require, including async and error cases
- Collapse repetitive tests into one parameterised test
- Apply the "would it break silently?" rule to decide what deserves a test
A test fails: #expect(cart.total == 54). What does Swift Testing show you that the older XCTAssertEqual famously did not?
The shape of a modern test
import Testing
@testable import CartFeature
@Test func memberDiscountApplies() {
let cart = CartModel(prices: [10, 20, 30], memberDiscount: 0.1)
#expect(cart.total == 54)
}
@Testmarks any function as a test — no class to subclass, notestname prefix, structs and global functions welcome.#expect(condition)records a failure but keeps the test running, so one test can report several broken things.#requireis the hard version: it stops the test when its condition fails, and it unwraps optionals —let user = try #require(response.user)replaces the force-unwrap that would crash the whole suite.
Tests group into suites — a struct whose init runs fresh for every test, which is the setup story:
struct CartTests {
let cart = CartModel(prices: [10, 20, 30], memberDiscount: 0.1) // fresh per test
@Test func totalAppliesDiscount() { #expect(cart.total == 54) }
@Test func subtotalIgnoresDiscount() { #expect(cart.subtotal == 60) }
}
No shared mutable state between tests means no test-order coupling — the class of flakiness where the suite passes alone and fails on CI evaporates.
What is actually worth testing
The judgement that separates useful suites from theatre. Recall the architecture lesson's sorting rule — logic lives where tests can reach it. The complementary rule: test what would break silently.
| Code | Test it? | Why |
|---|---|---|
| Discount/pricing rules | yes, thoroughly | wrong answers look plausible — nobody notices 8% vs 10% by eye |
| Date/timezone logic | yes | breaks twice a year, at 11pm, in another country |
| Parsing server responses | yes, with ugly fixtures | the sad payloads are exactly what QA never types in |
A computed property returning a + b | briefly | cheap to test, cheap to break visibly |
| "The button is blue" | no | breaks loudly in any screenshot or preview |
| Apple's frameworks | no | you are testing your assumptions, not their code |
The highest-yield tests in a real codebase are the boundary tables: empty input, one element, the exact threshold, one past it, negative, enormous. That shape has a dedicated tool:
Parameterised tests
@Test(arguments: [
(0, "free"), (1, "free"),
(10, "bronze"), (49, "bronze"),
(50, "silver"), (200, "gold"),
])
func tierForPoints(points: Int, expected: String) {
#expect(LoyaltyModel.tier(for: points) == expected)
}
One function, six independent test cases — each reported separately, each re-runnable alone. The lazy alternative (a for loop inside one test) stops at the first failure and reports one opaque red X; the parameterised version tells you "49 and 50 pass, 200 fails", which usually is the diagnosis.
The runnable model
The framework's mechanics — expectations that record rather than abort, boundary tables — run right here:
Change the >= 50 in the model to > 50 and re-run: exactly the (50, "silver") row fails, with both values printed. That precision — the boundary case naming its own bug — is what the table shape buys, and why "the exact threshold and one past it" belongs in every arguments list you write.
Async and errors
Tests speak the language of the code under test:
@Test func refreshPublishesQuotes() async throws {
let model = WatchlistModel(fetcher: FixedQuotes()) // the fake from D1
await model.refresh()
#expect(model.portfolioTotal == 630.75)
}
@Test func withdrawingTooMuchThrows() {
#expect(throws: BankError.insufficientFunds(shortfall: 100)) {
try withdraw(200, from: 100)
}
}
#expect(throws:) asserts an error is thrown and matches — the previously awkward "test the sad path" becomes one line. Every fake you built in the architecture lesson slots directly into the first shape: inject the failing fake, await, assert on the user-facing outcome. That pairing — DI providing the seam, the test exercising it — is the whole system working as designed.
Suites get a fresh instance per test. This model shows why that rule exists — what does it print?
// A suite WITHOUT per-test isolation — one shared instance:
final class SharedSuite {
var cart: [Double] = []
func testAddOne() -> Bool {
cart.append(10)
return cart.count == 1
}
func testStartsEmpty() -> Bool {
return cart.isEmpty
}
}
let shared = SharedSuite()
print("A then B: \(shared.testAddOne()) \(shared.testStartsEmpty())")
let sharedReversed = SharedSuite()
print("B then A: \(sharedReversed.testStartsEmpty()) \(sharedReversed.testAddOne())")Which of these is the strongest candidate for the next test you write, on the "breaks silently" rule?
Find the boundary bug
The validator has an off-by-one bug against its spec. The boundary table below is the test — read the failures it prints, fix isValid, and make the table pass clean.
Test the sad path through a fake
The happy path is tested; the valuable one isn't. Add a FailingGateway fake and a second check proving the model reports a payment failure without corrupting its state.
Take the FSRS scheduler idea from this very platform — "a card reviewed successfully gets a longer interval; a failed card gets a short one". Write the boundary table you would feed @Test(arguments:): at least six (input state, rating) → expected-behaviour rows, including the edges. Which single row, if it regressed, would damage a learner's progress silently?
You can now:
- Write suites with
@Test,#expect,#require, async andthrowsvariants - Turn boundary analysis into parameterised argument tables
- Route sad paths through injected fakes and assert on state and message
- Argue for what not to test
Next up: accessibility — correctness for the users your tests don't represent.