TDD Is Dead. Long Live TDD.
Test-driven development died of mock depth. In SwiftUI the acronym comes back with a new meaning — and keeps the old promise: the feature locked with tests before implementation, agents writing the rest.
Test-driven development made one promise: the test before the implementation. In OOP the promise kept breaking, and the reason was never discipline — it was mock depth. An OO mock must simulate an object, and an object’s behavior reaches down through its collaborators. Hand a networking service a mock session and the test is really asserting how the service uses that session: which methods, in what order, with what arguments. And the session’s methods return objects that need stubs of their own — depth is transitive, ending only at a type with no methods left to call. Mock depth grows with the object graph, the tests couple to the implementation they were supposed to precede, and the first refactor breaks them. Outside-in TDD — the London school — practiced this honestly, at scale, and paid the cost. Refuse the mocks and you pay the same bill from the other side: the unit cannot be built until the world under it is.
There is another TDD. Top-down development orders work by consumption: the caller exists before anything it calls. In SwiftUI the two TDDs collapse into one, because a view constructed from plain data is a test fixture. The method: lift the state, lift the services, model the feature as a pure core, lock it down with tests that read evidence — then connect it.
Lift everything
In my years of OOP I never once saw business rules exist as a visible, programmable core. Development ran bottom-up: persistence first, then services, then the UI wired on last. Each object stored references to the layer below, so each feature was a mini monolith — rules, storage, and services fused into one reference graph with no surface to cut along. The rules never had a moment alone: finished, testable, lockable. Not a failure of discipline; a constraint of the material. An object UI cannot be constructed until the layers below it exist, so the rules could only grow around the infrastructure. Hexagonal architecture named the ambition; inside the hexagon the objects still held each other, and the mock depth the boundary was supposed to stop grew there instead.
The fusion was doctrine, not accident. OOP defined itself by state hiding — in Kay’s words, “local retention and protection and hiding of state-process.” Parnas had argued for less: hide the design decision — how a thing is represented, how an operation works. Mainstream OOP hid the state too: Kay’s encapsulation without Kay’s messages. Hide the how and you get a boundary; hide the state and you get an object that must be driven into every condition instead of constructed in one. Thirty years of testing pain live in that swap.
SwiftUI changes the material. A view is a value, constructible from literals, needing nothing below it. Apple said it in 2019: every piece of data has a single source of truth, and data flows down from it (Data Flow Through SwiftUI, WWDC19). Services are data too: a save, a fetch, a navigation is a closure, and a closure is a value — so Apple’s advice already covers them. Hand them down: a fact becomes a let, mutable state a @Binding, a service a closure. A binding is a plain value — constructible from a get/set closure pair — so nothing the rules touch needs a render pipeline to exist. Lift the state, lift the services, and what's left is the feature — pure.
Anything an acceptance criterion names must enter the feature as let, @Binding, or closure. Purely presentational state — an isExpanded, a scroll target — stays local. When unsure, lift. Apple's "least common ancestor" presumes you can locate the ancestor up front; requirements move, and today's screen is tomorrow's two. The guess needn't be right, only cheap to fix: over a stateless middle, relocating a source of truth is an initializer refactor the compiler walks you through. With stateful middle layers it is surgery. The price is plumbing you can see — parameters threading through views that never read them — instead of coupling you can't.
The feature is a stateless tree
A feature needs only its data: models and closures, read straight off the UI. The rows are a let — someone above will provide them. The toggle is a @Binding. The destructive swipe is a closure. The screen dictates its own signature; modeling is transcription. Model the feature first, against nothing:
struct Book: Identifiable, Equatable {
let id = UUID()
let title: String
let author: String
}
struct BookShelf: View {
let books: [Book] // a sorted fetch, eventually
@Binding var sortByAuthor: Bool // storage, eventually
let onDelete: (Book) -> Void // a service, eventually
var body: some View {
List {
Toggle("Sort by author", isOn: $sortByAuthor)
ForEach(books) { book in
Text(book.title)
.swipeActions {
Button("Delete") { onDelete(book) }
}
}
}
}
}Nothing behind it exists — the fetch unwritten, the storage undecided, the delete unimplemented — and the feature compiles. A wireframe you can type-check: a complete statement of what data exists and what actions are possible. The compiler enforces the discipline: no singleton, no context, no service locator to reach for — every dependency is named at an initializer or it cannot be used.
What did not happen: injection. Injection, in the object sense, implies a stored reference to a stateful service — a lifetime the consumer manages. The view stores closures, but it is an ephemeral value, recreated on every update; there is no lifetime and nowhere to inject into. Dependencies flow, like everything else. Wlaschin’s taxonomy names both halves. Data at the boundary is dependency rejection: no dependencies, values in, values out. Closures at the boundary are dependency parameterization: behavior handed in as a function. Neither is injection. This is the cut the mini monolith never had: every I/O severed at a closure or a value, and the cut surface is nothing but data.
Business rules in the pure core
The core is nothing more than the computed properties, methods, and body of a view that owns no state: pure transformations of constructor arguments. Ordering is not one of them — the fetch delivers the order, and the toggle only asks for it.
An edge case is a constructor argument — or a driven sequence of them. The empty shelf is books: []. The error is a value handed in. The slow network has no duration — the core is handed the result. An integration suite spends machinery forcing a live system into those states. Here, they are literals:
#Preview("Empty") {
BookShelf(books: [], sortByAuthor: .constant(false), onDelete: { _ in })
}
#Preview("Sorted") {
BookShelf(
books: [
Book(title: "Dune", author: "Herbert"),
Book(title: "Anathem", author: "Stephenson"),
],
sortByAuthor: .constant(true),
onDelete: { _ in })
}If a core cannot be constructed from inline literals, a boundary is missing — visible at the call site, before any test runs.
The stateless feature couples to nothing but data, and that makes the product owner’s words literal code: given names constructor arguments, when names an interaction — a closure call, driven directly or by a real tap — then names boundary events. “Given an empty library, when the user submits a title, then the book is saved” is a construction, a tap, and a log assertion — no translation through services or architecture.
Testing: evidence, not effects
Mocking is construction. A boundary is a value, so the mock is a value: a logging closure records its payload and forwards nowhere; a logging binding records the name and the new value before storing. Drive the core, assert the execution log: the ordered record of boundary events — every write, every call, with its payload. The masterclass argues this is one half of a node’s whole observable behavior; the children it creates are the other, and previews put those on screen. The test wants the evidence, not the effect.
How do you tap the button without a render pipeline? You don’t. A tap calling onDelete is Button's contract — Apple's to test, not yours. Yours is what the closure does with its payload, and that is driven directly. The mock is a scenario — a view that owns the real @State and hands down only logged boundaries:
struct SortScenario: View {
@State private var storage = false
let log: (String) -> Void
var body: some View {
BookShelf(
books: [Book(title: "Dune", author: "Herbert")],
sortByAuthor: Binding(
get: { storage },
set: { log("sortByAuthor \($0)"); storage = $0 }),
onDelete: { log("delete \($0.title)") })
}
}Construct the core against logged boundaries and call them directly, or host it in the scenario and drive it with real taps in a UI test. Two levels, one unit. The children have no API to walk — read them in a preview or a snapshot; the log is what a test asserts.
Even the loading flow — spinner on, fetch, spinner off, error or data — is a rule the core can own. Whether it does, or a shell above it — the view that owns the I/O — is a choice per feature, not a default. Once the core owns the load, the lets it produces turn writable, and the sequence becomes feature logic — a method the .task calls:
struct LoadingShelf: View {
@Binding var books: [Book]
@Binding var isLoading: Bool
@Binding var error: String?
let load: () async throws -> [Book]
var body: some View {
List(books) { Text($0.title) }
.task { await reload() }
}
func reload() async {
isLoading = true
let loaded = try? await load()
guard !Task.isCancelled else { return } // a cancelled flow writes nothing more
isLoading = false
error = loaded == nil ? "offline" : nil
if let loaded { books = loaded }
}
}The test, whole:
@Test @MainActor func offlineIsOneLog() async {
var log: [String] = []
let shelf = LoadingShelf(
books: Binding(get: { [] }, set: { log.append("books \($0.map(\.title))") }),
isLoading: Binding(get: { false }, set: { log.append("isLoading \($0)") }),
error: Binding(get: { nil }, set: { log.append("error \($0 ?? "nil")") }),
load: { log.append("load"); throw URLError(.notConnectedToInternet) })
await shelf.reload()
#expect(log == ["isLoading true", "load", "isLoading false", "error offline"])
}No render pipeline, no .task, no network — the driven sequence the edge-case rule named, asserted in one line. The order is guaranteed, not conventional. View is a @MainActor protocol, so every member of a conforming type — reload() included — inherits its isolation. Nothing can interleave the writes: the log has exactly one possible order because the code does. Assert the order the criterion names; where two writes are interchangeable the log is a set, not a sequence — over-specify and you rebuild, at depth one, the brittleness this method exists to kill. And the log's silence is evidence too: no books entry means the failure wrote nothing.
UIKit could not trade the effect for the evidence, because there the view is physical. A UIView is the retained, on-screen object; to render is to mutate it. The unit under test is the effect itself — its current state is the only record of what it did. Testing converged on inspecting that state: frames, hierarchies, snapshots. In SwiftUI you never hold the object, only data about it; the physical side is the runtime's. And the log is exhaustive by construction: a core captures nothing it was not handed, so its parameters are its only channel outward — instrument them all and no effect escapes unlogged. The effect needs no witness.
Mock depth, constant at one
One level deep, always — however large the feature grows. The mock of onDelete assumes nothing about databases, contexts, or sessions, because a function type has no members to stub: depth has nowhere to descend, and the signature is the whole contract. A mock was never a testing technique; it was a coupling repair. A test can only construct its unit and watch its boundaries — there is no other door. An OO mock converts behavior coupling into data coupling, per test, at the price of simulating everything behind the interface. That price is mock depth. Here the coupling is data before any test exists, and the conversion has nothing left to do. The mock is a value, so it can precede the service without simulating it: it proves the rule, not the service. What the real fetch does with a 304, or save with a conflict, is a separate question with a separate test — one per boundary, at the shell, written once. The feature never asks it again.
The mockist’s rebuttal writes itself: a log assertion is an interaction test, the expectation-checking style that made their suites brittle. True — with one difference. Their expectations named collaborators: internal conversations a refactor is free to reshuffle. The log names only boundary events, the ones the acceptance criterion already names. Refactor the core and the log cannot change; only a changed requirement can. Interaction was never the brittleness — interaction with internals was.
The London school knew. Their 2004 paper is titled “Mock Roles, Not Objects” — mocking objects was ruining the tests, and they said so. Right instinct, wrong material: an OO role still had to be an interface — methods and expectations simulated by a framework, an object behind it. Wlaschin pushed interface segregation to its limit: segregate fully and every interface has one method, which is just a function type. But an OO interface is nothing without a parent object to implement it; the segregation never finishes. The closure finishes it: the role shrunk to a single verb, self-sufficient, ready to be called — and its mock to a value.
The mocks exist before the real services do, so test-first stops being a discipline and becomes a property of the construction order.
Plug in the shell
Integration is the boring part, by design. At the top, in the shells — one wherever the I/O is, not one per view — real property wrappers and services replace the literals:
struct LibraryService: Sendable {
let fetch: @Sendable (_ byAuthor: Bool) async throws -> [Book]
let save: @Sendable ([Book]) async throws -> Void
}
extension EnvironmentValues {
@Entry var library = LibraryService(fetch: { _ in [] }, save: { _ in })
}
struct BookShelfShell: View {
@Environment(\.library) private var library
@AppStorage("sortByAuthor") private var sortByAuthor = false
@State private var books: [Book]?
@State private var pendingSave: [Book]?
var body: some View {
BookShelf(
books: books ?? [],
sortByAuthor: $sortByAuthor,
onDelete: { book in
books?.removeAll { $0.id == book.id }
pendingSave = books
})
.task(id: sortByAuthor) { books = try? await library.fetch(sortByAuthor) }
.task(id: pendingSave) { if let pendingSave { try? await library.save(pendingSave) } }
}
}Nothing below the shell changes. BookShelf is already finished and tested; the shell only decides where its boundaries terminate: the fetch in a .task(id:) keyed on the toggle, so the order comes from the source; the toggle in @AppStorage; the delete in a source-of-truth write the shell persists. Every async job is structured: .task binds the work to the view's lifetime, and .task(id:) restarts it on change, cancelling the stale save at its next suspension point — exactly the semantics saving wants. No free-floating Task, nothing a test can't reach. Hand the shell a logged LibraryService through the environment — the shell is a view, so it tests like one — and it produces a log of its own.
Get Lazar Otasevic’s stories in your inbox
Join Medium for free to get updates from this writer.
Usually the closures come from the environment, at the feature’s entry: the shell reads a capability with @Environment and hands the core its closures. Visibility was never the coupling. Apple exposes everything to every feature through the environment: seeing a capability couples nothing, consuming it does. The core's consumption is named at an initializer; the shell owns the one ambient read. Hiding dependencies mattered in OO because a dependency was a stateful reference — seeing one meant being able to corrupt it. A closure can only be called. A service that holds state — a socket, a token refresher, a model context — is the shell's to hold; the core still sees a closure.
Maintenance is the same operation as building. A requirement changes, so the data changes: a new field, a new closure, a changed signature. The compiler then asks for the refactoring itself — every construction site the change touches is an error, a checklist instead of a hunt. A rule that changes inside the core, boundaries untouched, is smaller still: edit the transformation, re-read the log — the boundaries were the only places anything could hide.
Modularity falls out of the same move. A module that holds state couples every importer to what it holds; a stateless module is algorithms and cores — importable anywhere, tested alone, owned by anyone. The state sits at the top, in the app that composes the modules. The mini monolith could never be extracted; the stateless feature already is a module, waiting for a package to be named after it.
What the modules share is one more module: the data coupling layer — free of behavior, just the models, the closure signatures, and the EnvironmentValues entries that propagate them. The composition root constructs the implementations, features consume them, and the two meet nowhere but in that layer's types. For the shelf: Book, LibraryService, and the @Entry.
In the age of AI, detail is delegable
Top-down development in the age of AI is not a preference — it is a necessity. Agents write code cheaply and endlessly; what keeps a codebase coherent is the part they cannot rewrite: the locked core. Once the feature runs against mocks with every edge case covered and the execution-log tests lock it down, everything remaining is implementation detail: a closure signature to satisfy — the consumer is the test. A detail with a typed contract and a locked spec can be delegated: to a junior, to a code generator, to a low-quality agent — the feature already defines correct. Nothing the delegate writes changes the rules: the core was tested without it, and is not retested when it lands. A bad implementation is a wrong value at one boundary — one test to write.
Delegation is not one-shot. With the core locked, the details are rewritable: an agent can swap a REST client for GraphQL, regenerate persistence, rewrite a service from scratch — and the core does not notice. Same signatures, same rules, same log. Add the per-boundary integration tests and the project becomes hard to break: the rules cannot drift, the boundaries cannot lie, and everything between them is disposable. The scarce skill concentrates where it belongs: modeling the feature. The feature was finished before it was connected — what’s left is detail, and detail is delegable.
The promise, kept
The material was the problem, not the discipline: the unit needed the world built before it could exist, and the mock needed it simulated. Lifting makes the feature constructible from literals, and anything constructible from literals is testable by construction. Every service arrives to find its caller, and its caller’s tests, already written. The promise is kept because development finally runs top-down: one person locks the core, and any number of agents write the rest.
Vocabulary
- mock depth: how many levels of internals a mock must assume; transitive through an object graph, constant at one for a closure
- top-down development: ordering work by consumption: the caller exists before anything it calls
- mini monolith: an OOP feature: rules, storage, and services fused, extractable only whole
- wireframe you can type-check: the feature modeled against nothing: compiles, names every dependency, implements no behavior
- dependency rejection: data at the boundary — the core has no dependencies, values in, values out (Seemann’s term, via Wlaschin)
- dependency parameterization: closures at the boundary — behavior handed in as a function (Wlaschin)
- execution log: the ordered record of boundary events, every write and call with its payload
- scenario: a test-host view owning real
@State, handing down only logged boundaries - shell: the view owning the live property wrappers and services, one wherever the I/O is
- core: the feature modeled as pure transformation: values and closures in, children and boundary events out
- data coupling layer: the one shared module, free of behavior: models, closure signatures, and the
EnvironmentValuesentries that propagate them - the consumer is the test: a boundary’s implementation is judged by the feature that consumes it
References
- Apple, Data Flow Through SwiftUI (WWDC19): lift state up, single source of truth
- Apple, Data Essentials in SwiftUI (WWDC20): sources of truth driving the graph
- Niklaus Wirth, Program Development by Stepwise Refinement (CACM 1971): top-down decomposition, the canonical statement
- Harlan Mills, Top Down Programming in Large Systems (in R. Rustin, Debugging Techniques in Large Systems, 1971): top-down development as practiced at IBM, thirty years before the letters meant tests
- Harold Abelson & Gerald Jay Sussman with Julie Sussman, Structure and Interpretation of Computer Programs (1985; 2nd ed. 1996): programming by wishful thinking — writing the caller against functions that don’t exist yet
- Alistair Cockburn, Crystal Clear (2004): the walking skeleton
- David Parnas, On the Criteria To Be Used in Decomposing Systems into Modules (CACM 1972): information hiding — hide the how; the industry hid the state instead
- Alan Kay, email to Stefan Ram on the meaning of OOP (2003): “hiding of state-process” — where state fused with behavior
- Kent Beck, Test-Driven Development: By Example (2002): the other TDD
- David Heinemeier Hansson, TDD is dead. Long live testing. (2014): the obituary this title answers
- Steve Freeman & Nat Pryce, Growing Object-Oriented Software, Guided by Tests (2009): outside-in TDD in OOP, and the mock depth it costs
- Tim Mackinnon, Steve Freeman, Nat Pryce & Joe Walnes, Mock Roles, Not Objects (OOPSLA 2004): the right instinct in the wrong material — the smallest role is a closure
- Scott Wlaschin, Six approaches to dependency injection (2020): dependency rejection — Mark Seemann’s coinage — and dependency parameterization
- Scott Wlaschin, Moving IO to the edges of your app (NDC London 2024): functional core, imperative shell — I/O at the boundaries
- Scott Wlaschin, Designing with capabilities: services as function values
- Scott Wlaschin, Functional Design Patterns: interface segregation at its limit — a one-method interface is just a function type
- Lazar Otasevic, SwiftUI Data Flow Masterclass: the model this method rests on









