How Redux Simplifies Unit Testing
When deepEquals is the only assertion you need
April 12, 2018
Today was a boring day. I had a bit of downtime because of some arbitrary mid-sprint deadline set for tomorrow. I didn't want to go too deep on something because I was expecting a lot of bug reports to come up (which did). So instead, while waiting, I decided to write unit tests... lots of them. It was fun, and only because of how Redux greatly simplifies writing unit tests.
Let's start by looking at how the reducer tests were written:
// Omitting all the import sorcery
export const reducer = (state: AppState, action: Action): AppState => {
switch(action.type){
case ActionType.SOME_ACTION:
return {...state, ...stateChangesForSomeAction}
/* more actions */
default:
return state
}
}
Now over to its test, which is usually one per action.
// Omitting all the import sorcery
QUnit.test('SOME_ACTION', assert => {
const action = new SomeAction({...})
const currentState = {...}
const expectedState = {...}
const newState = reducer(currentState, action)
assert.deepEquals(newState, expectedState)
})
And that's it!
Reducers are just data transformers. They accept the current state plus the dispatched action, and return the new state. And the states are just plain JavaScript objects which means comparing expected state and actual state is just a matter of doing a deep comparison between the two objects!
Now let's do effects. Here's an example of one that that does a GET
request when SOME_ACTION
is dispatched:
// Omitting all the import and injection sorcery
class MyEffect {
@Effect
someEffect: Observable<Action> = this.actions
.ofType(ActionType.SOME_ACTION)
.mergeMap((action: Action) => this.httpClient
.get(`${this.appConfig.apiBase}/some-endpoint`)
.map((data: any) => new SomeActionSuccess(data))
.catch((error: any) => of(new SomeActionFail(error))))
}
The tests are often one per resulting action, because one effect can emit different actions depending on what happened.
// Omitting all the import, injection and testbed sorcery
QUnit.test('someEffect', assert => {
const done = assert.async()
assert.expect(1)
const flushedData = {...}
const expectedData = {...}
effects.someEffect.subscribe((action: SomeActionSuccess) => {
assert.deepEqual(action.payload, expectedData)
done()
})
mockActionsHub.next(new SomeAction())
mockHttpClient.expectOne('/api/v1/someEndpoint').flush(flushedData)
})
And that's it!
Just as a reducer takes state plus action and constructs new state, an effect takes in data wrapped in an Action
, transforms it using observable operators, and emits data wrapped in an Action
. The effects test is just a matter of subscribing to an observable, emitting data on one end, and testing what comes out the other end. And again, the wrapped data is just plain a JavaScript object which means comparing expected and actual data is just another case of deep comparison.
Conclusion
Small, simple, repeatable
That was my goal and the reason why I went for Redux. It's so that anyone can reason about the code very easily. It's so that when something breaks, it only takes a few questions to pinpoint where the bug is likely ocurring. It's so that one does not have to think of a unique and snowflake-y way to manage data. It's so that the barrier of entry is very low for onboarding developers. It's so that code can be so stupidly simple, you can describe it by drawing ascii art.
PS: Earlier today, I introduced Redux Dev Tools to our QA person. I instructed him to export the logs whenever he sees a bug so that it can be replayed by a developer. Let's see what QA thinks about Redux and its potential to change our QA workflow.
PPS: If you look at the tests, all they're doing is comparing data structures. I don't need a fancy test runner and assertion lib to do that. So when a developer asked me why I chose QUnit, I responded with "because it's simple".
PPPS: Most people see redundant code as bad. To me, repeatability is a good sign. It means a convention exists. That's why I don't see action, reducer and effect definition as redundant. Of course, copy-paste logic is a different story and must be avoided.