Skip to content

Latest commit

 

History

History
169 lines (117 loc) · 8.29 KB

File metadata and controls

169 lines (117 loc) · 8.29 KB

← Back to README

Testing

Because SwiftModel owns and tracks all of a model's state, events, and async tasks, tests are exhaustive by default: any side effect you don't explicitly assert fails the test. This catches the regressions ordinary unit tests miss — a loading flag that flickered, an error that appeared and cleared, a task left running.

Setup

Add the .modelTesting trait, then anchor and drive the model as usual. Override dependencies in the withAnchor closure:

import Testing
import SwiftModel

@Test(.modelTesting) func testAddCounter() async {
    let model = AppModel().withAnchor {
        $0.factClient.fetch = { "\($0) is a good number." }
    }

    model.addButtonTapped()

    await expect(model.counters.count == 1)
}

Assertions await because state and event propagation are asynchronous.

On Swift 6.0 the .modelTesting trait isn't available (it needs 6.1+); wrap the body in await withModelTesting { … } instead.

One anchored root per scope

A test scope tracks exactly one anchored model: expect { } wakes on that model's activity, and exhaustivity is checked against its tree. A second withAnchor() in the same scope is reported as an issue — it cannot be connected, and the model it returns is torn down immediately.

If the test needs a second model:

  • Make it a child of the anchored root. One tree per scope is the supported shape, and expect { } then covers both models — this is usually the right answer even when the two models look like peers in production.
  • Keep it live but untracked with returningAnchor(), holding the returned anchor yourself. This is the fit for peers that must run at the same time — two backends wired to each other, say — where the test drives one through the other and asserts only through the anchored root. expect { } will not wake on the untracked model's activity; that is the whole trade.
  • Give it a scope of its own with a nested await withModelTesting { … }. Scopes nest sequentially, so this fits phases that follow one another — snapshot from one root, restore into a fresh one — rather than roots that must be live together. The inner model is torn down when the closure returns, and expect { } inside it sees only that model.

Asserting state, callbacks, and events

expect { } accepts any number of Bool predicates and waits for them all to become true; == gives a pretty-printed diff on failure. Use require to wait for an optional child to appear before interacting with it:

await expect {
    model.count == 42
    model.isLoading == false
}

let row = try await require(model.counters.first)
row.counter.incrementTapped()

Pass a TestProbe wherever the model expects a callback closure — invocations are tracked automatically and asserted with wasCalled. Events sent via node.send are asserted with didSend inside an expect block:

let onFact = TestProbe()
let model = CounterModel(count: 2, onFact: onFact.call).withAnchor {  }
model.factButtonTapped()

await expect {
    onFact.wasCalled(with: 2, "2 is a good number.")
    model.didSend(.startMeeting)
}

Exhaustivity

By default the trait enforces exhaustivity across these categories — anything unasserted fails the test at the end:

Category Must be consumed by
.state an expect predicate reading the property (a private/fileprivate property is excluded — tests can't read it)
.events didSend(_:)
.tasks completing or being cancelled before the test ends
.probes wasCalled
.local / .environment / .preference an expect predicate
.transitions (opt-in) sequential expect blocks matching recorded writes in order

State failures show the full change chain from the baseline — including round trips that returned to the original value, which is how fire-and-forget mutations get caught:

Modifications not asserted:

    SearchModel.error: nil → NetworkError.timeout → nil

Scope a test to fewer categories with an absolute set or a relative modifier, at the suite, test, or block level:

@Test(.modelTesting(exhaustivity: [.state, .events]))   // only these
@Suite(.modelTesting(.removing(.events)))               // composes with enclosing suite

await withExhaustivity(.off) { model.triggerSideEffects() }  // for part of a body

Scopes nest, and a scope that states no opinion inherits the enclosing one — the default is .inherited, so a bare withModelTesting { } or @Test(.modelTesting) inside a @Suite(.modelTesting(exhaustivity: .off)) runs at .off too. Absolute presets (.full, .off, .state, …) override what they inherit; relative modifiers (.adding, .removing) compose with it. With no enclosing scope the base is .full.

Settling

A model that does async work during activation — loading in onActivate(), subscribing to streams — may not be ready when withAnchor() returns, and asserting every intermediate activation change is brittle. settle() waits for activation to quiesce, then resets the exhaustivity baseline so your test covers only what happens after:

@Test(.modelTesting) func testRefresh() async {
    let model = DashboardModel().withAnchor()
    await settle()   // let activation finish; clear the baseline

    model.refresh()
    await expect { model.lastSyncDate != nil }
}

Pass a predicate (settle { model.isReady }) to also wait on a condition, or resetting: to keep some categories visible — e.g. settle(resetting: .full.removing(.events)) lets events sent during activation still be asserted afterward.

Time control

Inject a clock to test timers without real delays. A TestClock (from swift-clocks) advances explicitly; an ImmediateClock fires everything synchronously when you only care about the end state:

let clock = TestClock()
let model = TimerModel().withAnchor { $0.continuousClock = clock }

await clock.advance(by: .seconds(1))
await expect(model.secondsElapsed == 1)

Refactor-resilient tests

SwiftModel tests assert final state, not the sequence of actions or effects that produced it. There is no action enum to enumerate and no send/receive script to keep in sync — you call a method and assert the outcome:

// Rename factButtonTapped(), split it into two, move work to a helper —
// the test keeps passing as long as model.fact ends up correct.
model.factButtonTapped()
await expect { model.fact == "42 is a great number" }

So you can freely restructure model internals and existing tests keep passing as long as the observable outcome is unchanged. The exhaustivity guarantee is undiminished — any state change you didn't assert is still a failure — the test is simply decoupled from how the model got there.

When you do want step-by-step fidelity — asserting each transition in order — opt into .adding(.transitions) and use sequential expect blocks, each matched against the next recorded write:

@Test(.modelTesting(.adding(.transitions))) func testFact() async {
    model.factButtonTapped()
    await expect { model.isLoading == true }     // loading starts
    await expect {
        model.isLoading == false                 // loading completes
        model.fact == "42 is a great number"
    }
}

Architectures that test against an ordered action sequence (such as TCA's send/receive) make this trade-off everywhere on purpose: encoding each step gives precise control, at the cost of tests that must change when those steps are refactored. With SwiftModel it's opt-in, per test.

Xcodeproj Setup

App target prerequisite: any Xcode app target using SwiftModel must add OTHER_LDFLAGS = $(inherited) -weak_framework Testing to its build settings for the app to launch, independently of whether you have tests. See Install.

If your test target uses BUNDLE_LOADER (the Xcode default when testing an app binary), add one more setting on the app target:

ENABLE_TESTING_SEARCH_PATHS = YES

This makes the testing APIs available inside the app binary, which the test bundle inherits via BUNDLE_LOADER. Do not also add SwiftModel to the test target's Frameworks — the bundle gets all symbols from the app, and the extra link causes duplicate-symbol errors.