pointfreeco / swift-clocks

⏰ A few clocks that make working with Swift concurrency more testable and more versatile.
MIT License
266 stars 17 forks source link

Store with TestClock not awaiting when using SnapshotTesting #35

Closed lucianolang closed 4 months ago

lucianolang commented 4 months ago

Description

There seems to be an issue with the Store when using a TestClock, the view doesn't react to timer ticks unless we assert for state mutation and recreate the view and the store.

Current versions: swift-clocks 1.0.2 swift-composable-architecture 1.10.4 swift-snapshot-testing 1.16.0

Checklist

Expected behavior

These tests should pass

@MainActor // ❌
  func testExampleFailing() async {

    let clock = TestClock()
    let store = Store(initialState: .init(), reducer: { ContentViewFeature()}, withDependencies: { $0.continuousClock = clock })
    let sut = ContentView(store: store)

    await store.send(.startTimer).finish()

    await clock.advance(by: .seconds(1))
    // Test ends with unexpected result, timer doesn't advance
    assertSnapshot(matching: UIHostingController(rootView: sut), as: .image)

    // Same here
    await clock.advance(by: .seconds(4))
    assertSnapshot(matching: UIHostingController(rootView: sut), as: .image)
  }

  @MainActor // ❌
  func testExampleFailing2() async {

    let clock = TestClock()
    let store = TestStore(initialState: .init(), reducer: { ContentViewFeature()}, withDependencies: { $0.continuousClock = clock })
    store.exhaustivity = .off
    let sut = ContentView(store: store.snapshotStore)

    let task = await store.send(.startTimer)

    // Test ends with unexpected result, timer doesn't advance
    await clock.advance(by: .seconds(1))
    assertSnapshot(matching: UIHostingController(rootView: sut), as: .image)

    // Test ends with unexpected result, timer doesn't advance
    await clock.advance(by: .seconds(4))
    assertSnapshot(matching: UIHostingController(rootView: sut), as: .image)

    await task.cancel()
  }

Actual behavior

These tests are the only ones that I'm able to make work

  @MainActor // ✅
  func testExampleMutatingState() async {
    let clock = TestClock()
    let store = Store(initialState: .init(), reducer: { ContentViewFeature()}, withDependencies: { $0.continuousClock = clock })

    await store.send(.startTimer).finish()

    // State does get mutated
    await clock.advance(by: .seconds(1))
    XCTAssertEqual(store.secondsElapsed, 1)

    await clock.advance(by: .seconds(4))
    XCTAssertEqual(store.secondsElapsed, 5)
  }

  @MainActor // ✅
  func testExampleWithUISnapshot() async {
    let clock = TestClock()
    let store = TestStore(
      initialState: ContentViewFeature.State(),
      reducer: { ContentViewFeature() },
      withDependencies: { $0.continuousClock = clock }
    )
    let task = await store.send(.startTimer)

    let sut1 = ContentView(store: store.snapshotStore)
    assertSnapshot(matching: UIHostingController(rootView: sut1), as: .image)

    await clock.advance(by: .seconds(1))
    // By asserting the state mutation we mamage to await
    await store.receive(\.timerTicked) { $0.secondsElapsed = 1 }

    // If we recreate the view and the store with the current state then we get the desired result
    let sut2 = ContentView(store: store.snapshotStore)
    assertSnapshot(matching: UIHostingController(rootView: sut2), as: .image)

    // Same concept here
    await clock.advance(by: .seconds(4))
    for second in 2...5 {
      await store.receive(\.timerTicked) { $0.secondsElapsed = Double(second) }
    }
    let sut3 = ContentView(store: store.snapshotStore)
    assertSnapshot(matching: UIHostingController(rootView: sut3), as: .image)

    await task.cancel()
  }

Steps to reproduce

  1. Create a view with a timer
  2. A store that uses a ContinuousClock
  3. Create a test for a view that injects a TestClock
  4. Use assertSnapshot to assert changes in the view

Find here the project: clockTests.zip

Thanks in advance

swift-clocks version information

1.0.2

Destination operating system

iOS 17.4

Xcode version information

15.3

Swift Compiler version information

swift-driver version: 1.90.11.1 Apple Swift version 5.10 (swiftlang-5.10.0.13 clang-1500.3.9.4)
Target: arm64-apple-macosx14.0
stephencelis commented 4 months ago

@lucianolang The first failing test is suspending here:

await store.send(.startTimer).finish()

This is going to wait till the timer effect finishes before continuing, which means the advancing is never really happening. If you change it to the following:

store.send(.startTimer)

You'll get 2 snapshots that I believe match what you want:

testExampleFailing 1 testExampleFailing 2

Meanwhile, the second failing test is creating a snapshotStore eagerly before sending any actions to the store:

let sut = ContentView(store: store.snapshotStore)

This is a completely inert store/view that is disconnected from the original store, and so it will always snapshot to the state it was when it was created.

Changing the code to instead snapshot things each time seems to fix:

await clock.advance(by: .seconds(1))
assertSnapshot(
  matching: UIHostingController(
    rootView: ContentView(store: store.snapshotStore)
  ),
  as: .image
)

await clock.advance(by: .seconds(4))
assertSnapshot(
  matching: UIHostingController(
    rootView: ContentView(store: store.snapshotStore)
  ),
  as: .image
)

Since this doesn't seem like a bug with the library, I'm going to convert to a discussion.