How to test a repeating effect

Hi guys.

I have a case where i want to retry an api call fetchPrice if it fails, until a success is returned. This is how the reducer is so far:

public let prePaymentReducer:
Reducer<PrePaymentState,PrePaymentAction,SystemEnvironment<PrePaymentEnvironment>> = Reducer { state, action, environment in
    switch action {
    case .onAppear:
        return Effect(value: PrePaymentAction.fetchPrice)
    case .onRecievePrice(.success(let response)):
        state.price = response.price
        return .none
    case .onRecievePrice(.failure(let error)):
        return Effect(value: PrePaymentAction.fetchPrice)
    case .fetchPrice:
        return environment.apiClient.getPrice()
            .receive(on: environment.mainQueue)
            .catchToEffect()
            .map(PrePaymentAction.onRecievePrice)
    }
}

It seems to work in the app, however I have trouble setting up a proper unit test for the retry behavior that i would like. This is what I have so far:

    func testOnAppearFetchPriceError() {
        let priceResponse = GetPriceResponse.mock
        let errorReturned = APIError.mock
        var env = PrePaymentEnvironment(apiClient: .mock)
        // Setting the env to return error as default
        env.apiClient.getPrice = { .init(error: .apiError(errorReturned)) }
        let store = TestStore(
                    initialState: PrePaymentState(),
                    reducer: prePaymentReducer,
                    environment: SystemEnvironment.testDK(environment: env, scheduler: immediateScheduler.eraseToAnyScheduler())
                )
        store.send(.onAppear)
        store.receive(.fetchPrice)
        store.receive(.onRecievePrice(.failure(.apiError(errorReturned))))
        // Setting the env to return success
        store.environment.apiClient.getPrice = {
            .init(value: priceResponse)
        }
        store.receive(.fetchPrice)
        store.receive(.onRecievePrice(.success(priceResponse))) {
            $0.price = priceResponse.price
        }
    }

So i expect .onRecievePrice(.failure(.apiError(errorReturned))) to be returned the first time fetchPrice is executed. After the first response the environment is set to return success instead, therefore I expect .onRecievePrice(.success(priceResponse)).

However when I run this test it just keeps loading, i don't get a failing / succeeding test, so it seems like fetchPrice gets called again and again and again in a cycle..
image

Can anyone please explain what I'm missing here? :smiley: I should probably implement the reducer in another way..

Hi @Nicolaidam, you technically have an infinite loop in your code. It isn't a problem when running the app because you are just retrying an API request over and over.

But in tests, when using an immediate scheduler, you are never getting an opportunity to perform logic after the .onAppear because an effect is synchronously executed that fails, which triggers a failure action, which then synchronously executes another effect that fails, which triggers another failure action, on and on and on...

This is only happening because you are using an immediate scheduler. If you use a test scheduler you can wiggle your way between the effect failing and a new effect starting up so that you can update the environment.

Should be as simple as constructing a scheduler:

let scheduler = DispatchQueue.test

Using it in your environment:

SystemEnvironment.testDK(environment: env, scheduler: scheduler.eraseToAnyScheduler)

And then your test store's sequence of actions can look like this:

// Kick off process
store.send(.onAppear)
// Update the environment to return success
store.environment.apiClient.getPrice = {
  .init(value: priceResponse)
}

// Tick the scheduler to allow the effect to do its work and fail
scheduler.advance()
store.receive(.fetchPrice)
store.receive(.onRecievePrice(.failure(.apiError(errorReturned))))

// Tick the scheduler to allow the effect to do its work and succeed
scheduler.advance()
store.receive(.fetchPrice)
store.receive(.onRecievePrice(.success(priceResponse))) {
  $0.price = priceResponse.price
}

I would also like to mention that another alternative to this is to use Combine's .retry operator. Since Effect conforms to Publisher you get access to all of its operators, and that could help you eliminate some of the ping-ponging you are doing with sending .fetchPrice, getting .onReceivePrice, and then sending .fetchPrice again.

1 Like

Hey @mbrandonw

Thank you so much! it makes sense

I actually came up with my own workaround with the immediate scheduler by changing the env return from error to success after the first iteration of the loop. However your solution is much cleaner so I'll definitely use that instead.

And thanks for the combine retry tip also!!

    func testOnAppearFetchPriceError() {
        let priceResponse = GetPriceResponse.mock
        let errorReturned = APIError.mock
        var env = PrePaymentEnvironment(apiClient: .mock)
        var firstTime = true
        //Changing env return from error to succes after first iteration
        env.apiClient.getPrice = {
            if firstTime {
                firstTime = false
                return Effect(error: .apiError(errorReturned))
            } else {
                return Effect(value: priceResponse)
            }
        }
        let store = TestStore(
                    initialState: PrePaymentState(),
                    reducer: prePaymentReducer,
                    environment: SystemEnvironment.testDK(environment: env, scheduler: immediateScheduler.eraseToAnyScheduler()))

        store.send(.onAppear)
        store.receive(.fetchPrice)
        store.receive(.onRecievePrice(.failure(.apiError(errorReturned))))
        store.receive(.fetchPrice)
        store.receive(.onRecievePrice(.success(priceResponse))) {
            $0.price = priceResponse.price
        }
    }

:+1: that's a great solution too! We use that trick in some of our case study tests to change the behavior of a dependency over time.

1 Like

Nice๐Ÿ‘Œ I need to look more into that