swift-observation-testing

0.1.0

Timeline-based testing utility for Swift's Observation framework
fuziki/swift-observation-testing

What's New

0.1.0

2026-03-13T11:05:49Z

What's New

Initial release of swift-observation-testing — a timeline-based testing utility for Swift's Observation framework and Combine.

Features

  • @Observable support — record property changes with virtual timestamps via timeline.observe(vm.title)
  • ObservableObject support — observe @Published properties via timeline.observe(vm, vm.title)
  • Publisher support — record Combine Publisher events via timeline.observe(vm.showDialog)
  • distinctEvents — filter consecutive duplicate values from recorded events (Value: Equatable)
  • Virtual time control — schedule actions at precise times with TestTimeline and TestClock

swift-observation-testing

A testing utility for Swift's Observation framework and Combine's ObservableObject. It lets you record how observed values change over virtual time, making timeline-based assertions straightforward.

Requirements

  • Swift 6.0+
  • iOS 17+ / macOS 14+ / tvOS 17+ / watchOS 10+

Installation

Add the package via Swift Package Manager:

.package(url: "https://github.com/fuziki/swift-observation-testing", from: "0.1.0")

Then add ObservationTesting to your test target's dependencies.

Usage

Observing @Observable properties

import Testing
import Observation
import ObservationTesting

@Observable
final class ViewModel {
    var title = "A"
    private let clock: AnyClock<Duration>

    init(clock: AnyClock<Duration>) { self.clock = clock }

    func onTap() async {
        title = "B"
        try? await clock.sleep(for: .seconds(1))
        title = "C"
    }
}

@Test @MainActor
func example() async {
    let timeline = TestTimeline()
    let vm = ViewModel(clock: timeline.anyClock)

    let title = timeline.observe(vm.title)

    timeline.schedule(at: .seconds(1)) { await vm.onTap() }

    await timeline.advance(by: .seconds(5))

    #expect(title.events == [
        .next(.zero,       "A"),
        .next(.seconds(1), "B"),
        .next(.seconds(2), "C"),
    ])
}

Observing ObservableObject properties

Pass the object and an expression together. The expression is re-evaluated on every objectWillChange notification.

import Testing
import Combine
import ObservationTesting

final class ViewModel: ObservableObject {
    @Published var title = "A"
    private let clock: AnyClock<Duration>

    init(clock: AnyClock<Duration>) { self.clock = clock }

    func onTap() async {
        title = "B"
        try? await clock.sleep(for: .seconds(1))
        title = "C"
    }
}

@Test @MainActor
func example() async {
    let timeline = TestTimeline()
    let vm = ViewModel(clock: timeline.anyClock)

    let title = timeline.observe(vm, vm.title)

    timeline.schedule(at: .seconds(1)) { await vm.onTap() }

    await timeline.advance(by: .seconds(5))

    #expect(title.events == [
        .next(.zero,       "A"),
        .next(.seconds(1), "B"),
        .next(.seconds(2), "C"),
    ])
}

Observing Combine Publishers

timeline.observe also accepts any Publisher with Failure == Never. Events are recorded with the virtual time at which they are emitted. No initial event is recorded.

import Testing
import Combine
import ObservationTesting

final class ViewModel {
    var showDialog = PassthroughSubject<Void, Never>()
    private let clock: AnyClock<Duration>

    init(clock: AnyClock<Duration>) { self.clock = clock }

    func onTap() async {
        try? await clock.sleep(for: .seconds(1))
        showDialog.send()
    }
}

@Test @MainActor
func example() async {
    let timeline = TestTimeline()
    let vm = ViewModel(clock: timeline.anyClock)

    let showDialog = timeline.observe(vm.showDialog)

    timeline.schedule(at: .seconds(1)) { await vm.onTap() }

    await timeline.advance(by: .seconds(5))

    #expect(showDialog.events.map(\.time) == [
        .seconds(2),
    ])
}

Filtering consecutive duplicates with distinctEvents

distinctEvents removes consecutive events with the same value, keeping only the first occurrence. This is useful when you only care about actual state transitions.

// events:        [false(0s), false(2s), true(3s)]
// distinctEvents: [false(0s),            true(3s)]
#expect(isTitleC.distinctEvents == [
    .next(.zero,       false),
    .next(.seconds(3), true),
])

API

TestTimeline

Manages virtual time and schedules actions.

Method Description
observe(_ expression:) Starts observing an @Observable expression and returns a TestObserver
observe(_ object:_ expression:) Starts observing an ObservableObject expression and returns a TestObserver
observe(_ publisher:) Starts observing a Publisher (Failure == Never) and returns a TestObserver
schedule(at:action:) Schedules an action at an absolute time from test start
advance(by:) Advances virtual time and executes scheduled actions
anyClock A type-erased clock for injection into production code

TestObserver<Value>

Holds the recorded history of an observed expression.

Property Description
events: [Recorded<Value>] All recorded values with their timestamps
distinctEvents: [Recorded<Value>] Events with consecutive duplicate values removed (Value: Equatable)

Recorded<Value>

Represents a single recorded event.

.next(Duration, Value)
Property Description
time: Duration Virtual time at which the event was recorded
value: Value The recorded value

Description

  • Swift Tools 6.0.0
View More Packages from this Author

Dependencies

Last updated: Thu Apr 09 2026 10:22:22 GMT-0900 (Hawaii-Aleutian Daylight Time)