| name | swift-tdd |
| user-invocable | false |
| description | Internal skill invoked by /programming chain. Use when implementing any feature or bugfix in Swift, before writing implementation code. Use when writing Swift tests with @Test, |
Test-Driven Development
TDD in Swift: identify test cases via boundary value analysis, then red-green-refactor through them. Heavy reference material is indexed in the programming-skill-references collection in statement-mcp.
When to Use
- Implementing any feature or bugfix in a Swift project
- Writing new tests (use Swift Testing, not XCTest)
- Identifying what to test / edge cases / coverage gaps
- Testing async/await, actors, @MainActor code
- Dependency injection for testability
- Snapshot or XCUITest for UI features
When NOT to Use
- Performance benchmarks (use XCTest
measure {})
- Objective-C test code (use XCTest)
- Source code review or debugging
Phase 1: Boundary Value Analysis
Run BEFORE writing tests. Produces a prioritized test case list for TDD's RED phase.
BVA Algorithm
Step 1: Enumerate Inputs
Everything the function reads: explicit parameters, self properties, closure captures, globals, environment, external data. Do not skip implicit inputs.
Step 2: Classify Input Types
Numeric, String, Collection, Nullable, Boolean, Enum/Union, Date/Time, Float, Composite. Each type class has specific boundary values — see boundary-catalog in programming-skill-references collection.
Step 3: Scan for Decision Points
Every code construct that partitions input space: comparisons, switch/match, loop conditions, guard clauses, try/catch, type narrowing, null checks, .count comparisons, ternaries.
Step 4: Extract Boundary Values
For each decision point, select three test points: on-boundary, off-below, off-above. See references/boundary-catalog.md for the operator table and increment rules.
Step 5: Handle Compound Conditions
AND (A && B): Use MC/DC — N+1 tests where each condition independently flips the outcome.
OR (A || B): Same MC/DC approach.
Nested/complex: Decompose into decision table.
Step 6: Derived and State-Dependent Boundaries
- Derived:
if (a + b > limit) → boundary is a + b = limit, test at limit±1
- State-dependent: identify distinct states, test each state × boundary combination
Step 7: Produce Test Case List
Order by priority: degenerate/empty → minimal valid → nominal → upper boundaries → invalid → type-specific edges → compound interactions → state × boundary.
Output format per case:
N. test_name: description
Input: { param1: value, param2: value }
Expected: outcome
Boundary: which boundary this tests
Phase 2: TDD Cycle (Swift)
RED: Write the Failing Test
import Testing
@Suite("Cart")
struct CartTests {
let sut: Cart
init() {
sut = Cart(catalog: MockCatalog())
}
@Test("Rejects negative quantity")
func rejectNegativeQuantity() throws {
#expect(throws: CartError.invalidQuantity) {
try sut.add(item: .widget, quantity: -1)
}
}
}
Rules: @Test with display name. One behavior per test. #expect for soft assertions, #require for preconditions. Struct suite with init(). Inject dependencies via initializer.
Verify RED
swift test --filter "CartTests/rejectNegativeQuantity"
xcodebuild test -scheme MyApp -destination 'platform=macOS' \
-only-testing 'MyAppTests/CartTests/rejectNegativeQuantity'
Confirm: test fails (not errors), failure matches expected behavior, fails because feature is missing.
GREEN: Minimal Implementation
Simplest code that makes the test pass. Don't add features or refactor.
Verify GREEN
swift test
xcodebuild test -scheme MyApp ...
All tests pass, no warnings.
REFACTOR
After green only. Remove duplication, improve names. Keep all tests green. Don't add behavior.
Repeat
Next failing test from the BVA list.
Testing Async, Actors, @MainActor
@Test func fetchUser() async throws {
let user = try await api.fetchUser(id: "123")
#expect(user.name == "Alex")
}
@Test func counterIncrements() async {
let counter = Counter()
await counter.increment()
#expect(await counter.value == 1)
}
@Test @MainActor func viewModelLoads() async {
let vm = ViewModel()
await vm.loadData()
#expect(vm.title == "Loaded")
}
Mock objects in concurrent tests must be Sendable — use actor mocks. Swift Testing runs tests in parallel by default. Use .serialized only when tests share external state that cannot be isolated.
Dependency Injection
Swift has no runtime mock generation. Every seam must be explicit.
Protocol-based (primary): define protocol, implement live + mock versions, inject via init.
Closure injection (lighter): var fetchUser: (String) async throws -> User = LiveAPI().fetchUser
Default parameter: init(api: APIClient = LiveAPIClient())
Test data factories: extension User { static func fixture(name: String = "Test") -> User { ... } }
XCTest Migration
| XCTest | Swift Testing |
|---|
XCTAssertEqual(a, b) | #expect(a == b) |
XCTAssertNil(x) | #expect(x == nil) |
try XCTUnwrap(x) | try #require(x) |
setUpWithError() | init() throws |
XCTestExpectation | await confirmation(...) |
func testFoo() | @Test func foo() |
XCTSkip | .disabled("reason") |
Both frameworks coexist. Do not subclass XCTestCase for Swift Testing tests.
Swift TDD: What's Different
- Value types reduce mocking: pure functions on structs need no mocks
- Type system replaces tests: don't test what the compiler enforces (type mismatch, exhaustive switch, non-optional, Sendable)
- Parallel by default: fix shared state, don't reach for
.serialized
- No runtime mocking: every dep needs protocol/closure seam. Code-gen tools (Mockolo, Sourcery) generate mocks at build time
Layered Testing
All units must be tested, and all runtime paths tested from unit outward. N layers between unit and user behavior = N layers of tests.
| Layer | Proves | Test type |
|---|
| 1 | Evaluator produces correct output | Unit test |
| 2 | Model delivers result to display state | Integration test |
| 3 | View receives and renders | Snapshot/UI test |
Rules: passing unit test proves the unit works, NOT the feature. Each layer tests real composition (not mocks of the layer below). No untested layer boundaries.
UI Feature TDD (Outside-In Double Loop)
Outer acceptance test (snapshot/XCUITest) stays red while inner unit tests drive implementation.
- Write outer test (RED) — snapshot or XCUITest for completed feature
- Identify components (VM properties, model methods, view elements)
- Inner loop: unit test → implement → refactor for each VM behavior
- Implement view last (GREEN outer)
Testing pyramid: ~70% unit / ~20% snapshot / ~10% XCUITest
| Question | Test Type |
|---|
| Logic with no UI dependency? | Unit |
| View renders correct elements? | Snapshot |
| Dark mode / large text correct? | Snapshot |
| Multi-screen flow works end-to-end? | XCUITest |
| System permissions/alerts work? | XCUITest |
Snapshot Testing
import Testing
import SnapshotTesting
@Test func lightAndDark() {
let view = ItemListView(viewModel: .init(items: Item.samples))
assertSnapshot(of: view, as: .image(layout: .device(config: .iPhone13)))
assertSnapshot(of: view, as: .image(
layout: .device(config: .iPhone13),
traits: UITraitCollection(userInterfaceStyle: .dark)
), named: "dark")
}
Determinism: pin Xcode + simulator in CI, perceptualPrecision: 0.98, disable animations, fix locale/timezone.
XCUITest
Reserve for 5-10 critical journeys. Page Object pattern mandatory.
struct LoginScreen {
let app: XCUIApplication
var emailField: XCUIElement { app.textFields["emailField"] }
var loginButton: XCUIElement { app.buttons["loginButton"] }
@discardableResult
func login(email: String, password: String) -> HomeScreen {
emailField.tap(); emailField.typeText(email)
loginButton.tap()
return HomeScreen(app: app)
}
}
Rules: .accessibilityIdentifier() for element identification. waitForExistence(timeout:) for waits — never sleep(). Each test resets state via launch arguments.
Common Mistakes
| Mistake | Fix |
|---|
| Using XCTest for new unit tests | Swift Testing (@Test, #expect) |
Missing await on actor assertions | #expect(await counter.value == 1) |
| Non-Sendable mock in concurrent tests | Actor mock or @unchecked Sendable with lock |
| Tests depend on execution order | Make independent, or .serialized |
#expect where #require needed | Preconditions need #require |
| Modifying test to match buggy output | Fix the implementation, not the test |
| 2-3 test cases for function with branches | Run BVA algorithm — almost certainly incomplete |
| Only testing explicit parameters | Check implicit state, closures, globals |
| Skipping derived boundaries | a + b > limit has boundary even if no single param does |
Red Flags
- Writing
import XCTest for a new unit test
- Test passes immediately (testing existing behavior)
- Mock type isn't
Sendable but tests run in parallel
- "This function is too simple to analyze" — it has boundaries
- "I'll just test happy path and one error" — that's not BVA
- Using
accessibilityIdentifier without verifying it exists in view source