Avoid calls to `.onAppear` for snapshot testing?

I have an issue with my project and I'm not sure if there is a workaround or I need to change strategy.

The project is using TCA and I've setup snapshot tests for some of the screen. A lot of screens have multiple states, such as loading, error, and populated. I preview these states using PreviewProviders, creating a preview of the screen with different stores.

struct PetsScreenLoading_Previews: PreviewProvider {

    static var previews: some View {
        PetsScreen(
            store: Store(
                initialState: PetsState(
                    loadingStage: .loading
                ),
                reducer: petsReducer,
                environment: .noop()
            )
        )
        .previewDisplayName("Loading")
    }
}

struct PetsScreenError_Previews: PreviewProvider {

    static var previews: some View {
        PetsScreen(
            store: Store(
                initialState: PetsState(
                    hasError: true
                ),
                reducer: petsReducer,
                environment: .noop()
            )
        )
        .previewDisplayName("Error")
    }
}

Along with viewing the previews while developing, I would like to test each of these states. For each of these previews, I'm taking a snapshot:

func testLoadingSnapshot() {
    assertSnapshots(views: PetsScreenLoading_Previews.previews, named: "PetsScreenLoadingSnapshot")
}

func testErrorSnapshot() {
    assertSnapshots(views: PetsScreenError_Previews.previews, named: "PetsScreenErrorSnapshot")
}

The issue I'm running into is that the loading process is triggered by an .onAppear action that is fired when the screen appears.

.onAppear {
    viewStore.send(.loadPets)
}

This results in the screen reverting to a loading state for each snapshot.

Is there something simple I'm missing, or is this a fundamentally flawed setup? I don't know if there is a way to stop the .onAppear triggering. I can't find a way to stop the action from having an effect, other than adding something hacky like an isTesting property.

I'm not experienced user of SnapshotTesting library but I think you try to test case which is impossible: display view for the first time in an error state. Before error or populated state, there is always loading state, am I right?

I think the correct way would be simulate real user actions and send some action to the store before takich snapshots as it is showed in Pointfree episode 86 and discussed here: TestStore and snapshot testing · Discussion #885 · pointfreeco/swift-composable-architecture · GitHub
Shared state best practices? · Discussion #879 · pointfreeco/swift-composable-architecture · GitHub

Otherwise I could think about two options to make "onAppear" controllable. I assume that .loadPets it's both mutating some state and fire some side effect. Otherwise then wouldn't be there any problem, because you could control environment in tests already.

  1. Simple one, just don't change state to isLoading if there is an error:
case .loadPets:
  if !state.hasError {
    state.isLoading = true
    return .run { send in 
      send(.updatePets(environment.loadPets()))
    }
  } else { return .none }
  1. More universal option:
    Change onAppear action to only call environment endpoint and from this endpoint communicate that loading started and finished. Then you can control this environment endpoint during test and change to do nothing.

Instead of something like this:

case .loadPets:
  state.isLoading = true
  return .run { send in 
    send(.updatePets(environment.loadPets()))
  }

Try something like this:

case .onAppear:
  return .run { send in
    for await status in environment.loadPets() {  // AsyncStream which returns loading statuses.
      send(.processLoading(status))
    }
  }

case .processLoading(.loadingStarted):
  state.isLoading = true
  return .none

case let .processLoading(.finished(pets)):
  state.isLoading = false
  state.pets = pets
  return .none

Thanks for the response!

I don't believe you can send actions into the store like that anymore unless it's a test store. We recently updated the TCA version in our project to 0.47.2 and we ran into issues in these tests where we were previously doing that.

I think your #2 solution is a good approach. I've gone with something slightly simpler for now, which may not work in all our scenarios.

Most of our screens have a few different states, including a logged out. When the screen loads we start by loading the logged in/out state of the user, what happens instantaneously. We only load additional data for logged in users. This is a CurrentValueSubject publisher for a Bool value. Like I showed in my original post, in all these previews we're using a .noop() environment, with instances of the services that don't do anything.

struct PetsScreenLoaded_Previews: PreviewProvider {
    static var previews: some View {
        PetsScreen(
            store: Store(
                initialState: PetsState(
                    loading: false,
                    pets: Pet.mocks
                ),
                reducer: petsReducer,
                environment: .noop()
            )
        )
        .previewDisplayName("Pets Loaded")
    }
}

The .noop() environment looks something like this:

extension PetsEnvironment {
    public static var noop = PetsEnvironment(
        mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
        petsAPI: .noop(),
        isAuthorized: CurrentValueSubject<Bool, Failure>(false)
    )
}

Part of what that environment provides is a mainQueue, on which the responses are all returned:

case .onAppear:
    return environment.isAuthorized
        .receive(on: environment.mainQueue)
        .catchToEffect(PetsActions.authorizationChanged)

case .authorizationChanged(.success(let isAuthorized)):
    state.isAuthorized = isAuthorized
    state.loading = true
    return Effect(value: .getPets)

I've found that by switching the DispatchQueue.main to DispatchQueue.test, the events are stored and the .authorizationChanged(Result) is never fired. This also lets us use a test queue during the snapshot tests, which seems more appropriate.

Looking at this again right now, there might be something we can do directly with the isAuthorized property of the .noop() environment to prevent it from returning a true/false value, such as making the Boolean nullable and using compactMap, but I haven't looked into this.

If .onAppear action only call environment, just change this environment to something which "do nothing".

Solution with changing Scheduler is one way. Personally I would just prefer to make it more local and encapsulated in isAuthorized endpoint. When using combine base environment. Most of the time I use () -> Effect<Output, Failure> signature for leaf endpoints (not clients or schedulers). That way you would not be force to use CurrentValueSubject which sends values as soon as subscribed.

So I would use:

struct PetsEnvironment {
  {...}  // other endpoints 
  isAuthorized: {} -> Effect<Bool, Failure>
}

That would give possibility to use:

let noop =  PetsEnvironment {
{...}
isAuthorized: { .none }
}

That way you don't have to change your model by using Bool? and don't have to deal with possibility of some unwanted side effect on other endpoints by changing the Scheduler. Especially most of the time the .immediate scheduler is preferable for Previews and I believe snapshots too.

1 Like