DEV Community

Maxim Smirnov
Maxim Smirnov

Posted on

How to cook reactive programming. Part 4: Testing.

Last time we were talking about different types of modularization for Unidirectional data flow. And this time we are going to talk about the most important topic - testing. I'd say the whole of this journey was about this topic.

This time I’d highly recommend reading all the previous articles, because reading about testing in isolation doesn't make any sense, without understanding the concepts of reactive programming and Unidirectional data flow.

  1. What is Reactive Programming? iOS Edition
  2. How to cook reactive programming. Part 1: Unidirectional architectures introduction.
  3. How to cook reactive programming. Part 2: Side effects.
  4. How to cook reactive programming. Part 3: Modularization.

I think this article will be the shortest one in the whole cooking saga, but as I said above and I want to repeat, it will be the most important one. Let's take for the experiments our old friend:

enum State {
    case initial
    case loading
    case loaded(data: [String])
}

enum Event {
    case dataLoaded(data: [String])
    case loadData
}

extension State {
    static func reduce(state: State, event: Event) -> State {
        var state = state
        switch event {
        case .dataLoaded(let data):
            state = .loaded(data: data)
        case .loadData:
            state = .loading
        }
        return state
    }
}
Enter fullscreen mode Exit fullscreen mode

It’s quite a simple state machine, where every state is represented as an enum case. I don’t think it’s a secret for anyone that unidirectional architecture is highly based on functional programming approaches. That's why we'll start with the most functional thing about all unidirectional architecture - reducer.

Reducer

func reduce(state: State, event: Event) -> State
Enter fullscreen mode Exit fullscreen mode

If you remember, reducer is a pure function. It has two inputs: previous state of the system and event for mutation of this state. As output, there's a resulting state.

extension State {
    static func reduce(state: State, event: Event) -> State {
        var state = state
        switch event {
        case .dataLoaded(let data):
            state = .loaded(data: data)
        case .loadData:
            state = .loading
        }
        return state
    }
}
Enter fullscreen mode Exit fullscreen mode

It should be quite obvious how to write tests for this kind of function. For these inputs, we have this output, zero dependencies, zero surprises. I want to show you one example, just to be sure that everyone stays in sync.

func testAfterDataLoadedEventStateWillBeChangedIntoLoadedWithReceivedData() {
    // Given
    let initialState: State = .initial
    let data = ["first", "second"]
    // When
    let resultState = State.reduce(state: initialState, event: .dataLoaded(data: data))
    // Then
    XCTAssertEqual(resultState, .loaded(data: data))
}
Enter fullscreen mode Exit fullscreen mode

And that's it. I bet it’s one of the most simple tests that you've ever seen. However, there's not only a reducer in the system. Moreover, we have the whole system with side effects and everything else. How do we test all this stuff? Actually I'm going to say that integration tests couldn't be easier as in unidirectional approaches.

Testing the system

Because we have a unidirectional data flow system which is based on events, and can be mutated only with events, testing the whole user flow becomes super easy. Unfortunately, the Combine framework doesn't have a testing SDK yet, so that's why I'll use a little bit of RxSwift code, but don't worry, here I’m going to use the most primitive concepts of RxSwift. Firstly let me show the test of the quite complicated user scenario. The user wants to add an event into the calendar but doesn't have a calendar permission for it.

    // User wants to open calendar event edit.
    // User doesn't have permission for editing a calendar.
    // Request should be sent to the user.
    // If the user authorized the permission.
    // Calendar event edit will be opened.
    // User added new event. -> Calendar State should be saved. And then the scene should be dismissed with Calendar State == nil.
    // After dismissing dismissedScene should be calendar.
    func testCalendarUserStoryWithPermission() {
        // Given
        let authorizedEvents = [PermissionType.events: PermissionStatus.authorized]
        let emptyPermissions: [PermissionType: PermissionStatus] = [:]
        let calendarEvent = CalendarEvent.testEvent

        // When
        let result = TestStore(
            scheduler: scheduler,
            disposeBag: disposeBag,
            permissionService: { .just(.authorized) }
        )
            .start(with: [.showScene(.calendar(event: calendarEvent)),
                          .userFinishedWithCalendarEditor(with: .saved)])

        // Then
        XCTAssertEqual(result[0].sceneForOpen, nil)
        XCTAssertEqual(result[0].permissions, emptyPermissions)

        XCTAssertEqual(result[1].sceneForOpen, .calendar(event: calendarEvent))
        XCTAssertEqual(result[1].permissions, emptyPermissions)

        XCTAssertEqual(result[2].sceneForOpen, .calendar(event: calendarEvent))
        XCTAssertEqual(result[2].permissions, authorizedEvents)

        XCTAssertEqual(result[3].sceneForOpen, nil)
        XCTAssertEqual(result[3].permissions, authorizedEvents)

        XCTAssertEqual(result[4].calendarState, .saved)

        XCTAssertEqual(result[5].calendarState, nil)
        XCTAssertEqual(result[5].dismissedScene, .calendar(result: .saved))
    }
Enter fullscreen mode Exit fullscreen mode

If you read comments for this test you will be amazed by just how easily I've tested the whole used story. However, let's take a closer look, what happened here.

Firstly, I've used a TestStore which is a special test implementation of the store. The biggest difference is that this store can receive events as input for the sake of real user behavior simulation. We have two events from the user right here: showScene, where the user tries to open a calendar for adding a new event and userFinishedWithCalendarEditor, whose name speaks for itself.

Also, inside the TestStore I've mocked the permissionService to return the authorized status for every authorization attempt it helps to test a successful flow.

And last but not least we received an array of every state mutation in the result, which helps us to see what's really going on there. Which scenes were opened and closed, how permissions were changed. I can even say that it looks like a UI testing, but without UI.

But what is this TestStore and what is the difference with the normal store?

import RxCocoa
import RxSwift
import RxTest

open class TestStore<State: Core.State, Event>: Store<State, Event> where State.Event == Event {

    private let observer: TestableObserver<State>
    private let scheduler: TestScheduler
    private let disposeBag: DisposeBag

    public init(scheduler: TestScheduler,
                disposeBag: DisposeBag,
                initial: State,
                feedBacks: [SideEffect<State, Event>]) {

        self.scheduler = scheduler
        self.disposeBag = disposeBag
        self.observer = scheduler.createObserver(State.self)
        super.init(initial: initial, feedBacks: feedBacks, reducer: State.reduce, scheduler: scheduler)

        stateBus
            .distinctUntilChanged()
            .drive(observer)
            .disposed(by: disposeBag)
    }

    public func start(with events: [Recorded<RxSwift.Event<Event>>] = []) -> [Recorded<RxSwift.Event<State>>] {
        scheduler
            .createColdObservable(events)
            .bind(to: eventBus)
            .disposed(by: disposeBag)
        scheduler.start()
        return observer.events
    }

    public func start(with events: [Event] = []) -> [State] {
        start(
            with: events
                .enumerated()
                .map { .next(($0.offset), $0.element) }
        )
            .compactMap(\.value.element)
    }
}
Enter fullscreen mode Exit fullscreen mode

Please don't be scared of RxSwift code here, everything is quite simple. Let me explain. As far as you know every reactive framework is based on the event sequences. And every event has its own time when to appear in this sequence. RxTest in this play is a special framework, which can help us to avoid all this timing complexity. Moreover, in this particular case, it helps us to provide an array of input events for our system, which helps to simulate real user behavior. That's it, there’s no other difference compared to a normal Store.

Actually, there's one other cool feature, which we can do with a concrete TestStore implementation for the concrete system. It's about mocking dependencies. I don’t think it’s a secret that one event or state mutation can produce a side effect. Sometimes it's not so handy when your state could be mutated by some events, caused by other side effects. Let me provide an example. You've started to work with adding new events into the calendar, but by the app logic, we should first show you an alert with a login option, even if the login itself is not required for adding an event into the calendar. It doesn’t sound very user friendly, but I made up this example only as an example. However you really want to avoid this complexity for your test. You have two solutions here. The first solution is just to mock an auth service and say to it that your user has already been authorized. However, if that’s not an option for any reason, you can just mock your alert module with a never return. Remember, that in reactive frameworks we speak in the language of data sequences. So, technically every sequence could produce zero elements for the whole time, so it’s a never. This is an example, how could it look like:

struct StateStore {

    private let scheduler: TestScheduler

    init(
        scheduler: TestScheduler,
        permissionService: (PermissionType) -> Observable<PermissionStatus> = { _ in .never() },
        alertModule: (AlertModule.State) -> Observable<GeneralEvent> = { _ in .never() }
    )
}
Enter fullscreen mode Exit fullscreen mode

Also, you can see a TestScheduler. This is a special scheduler, which helps us to work with a made-up (virtual) time. If you don’t know what scheduler is, in simple terms every one of your events in the system goes according to the imaginary clock, with this clock ticking and on every tick, an event could appear. So, this clock is the scheduler itself.

TCA

No articles without Composable Architecture so far... These two fellas really provide a lot of information to think about. While working with the approach that I've shown you before, pointfree.co shows an even more advanced version of testing the whole system. I'll show you a little bit of it, but you can make yourself familiar with this episod which is free to watch.

The test itself looks like this:

store.assert(
    .environment {
        $0.currentDate = { 100 }
        $0.trainingProcessor = TrainingProcessor(
            applyGrade: { _, _, _ in flashCard },
            nextTraining: { _ in flashCard }
        )
    },
    .send(.fillTheBlanks(.goNext(grade))),
    .receive(.trainingCompleted(.bright)) {
        $0.currentScene = .showTheWord(WordSceneState(word: flashCard.word, kind: .correctAnswer))
        $0.trainingsRemain = 0
    }
)
Enter fullscreen mode Exit fullscreen mode

They use the same concept of the TestStore, that I've shown before, but it's really more advanced. The testing of the system is now like magic.

One remark I’d make is their reducer looks a little bit different, and I've already made an overview of it in my article about side effects.

private let reducer: (inout State, Action, Environment) -> Effect<Action, Never>
Enter fullscreen mode Exit fullscreen mode

It has an environment, which holds all dependencies. So, how does their approach work? Firstly they can provide mocks for every dependency inside the test itself, mostly in the same way as I've shown you before. But further cool things happen. They have two different directives - send and receive. What are they about? The Send directive is the same as I've demonstrated in the previous approach, it is just what the user or other part of the app could send into your system. Inside a closure of it, you provide all changes of the state which should happen, to validate them. The Receive directive is the event which you will expect from the system, according to your side effects work. And the same closure to validate the state changes. So here, you really can test the whole flow.

However, pointfree.co is not pointfree.co if they don't take one step further. Just look how their assert function shows you if your state wasn't mutated as expected:

🛑 failed - Unexpected state mutation: 

     AppState(
       todos: [
         Todo(
           isComplete: false,
    +       isComplete: true,
           description: "Milk",
           id: 00000000-0000-0000-0000-000000000000
         ),
       ]
     )

(Expected: , Actual: +)
Enter fullscreen mode Exit fullscreen mode

And yes, it shows you the exact place, where the error happened. It's crazily cool I would say.

Outro

Usually, I say here something about that there’s no silver bullet, but this article was mostly not about approaches, but to show you how it is easy to test unidirectional approach. You really firstly can put all feature behavior in your tests and only after it starts to write a working code. If you don't write the test before, you will after adopting unidirectional approaches. So this was the last article about reactive programming and unidirectional approaches! I hope for now you have enough experience to cook it in the right way.

If you liked this article don't forget to smash that clap button. Moreover, you can do it 50 times, for you, it's super easy, but it will be really helpful for me.

If you don't want to lose any new articles subscribe to my twitter account

Twitter(.atimca)
.subscribe(onNext: { newArcticle in
    you.read(newArticle)
})
Enter fullscreen mode Exit fullscreen mode

Top comments (0)