How to test Redux Saga with dependencies
Let’s think about an alternative approach to build your sagas.
How do your sagas look like?
Let’s take some common scenario — you have a form that updates a product/user/whatever via backend API. I assume you have something like this
Why does it look like this and not otherwise? The answer is quite obvious — because it’s how it’s suggested in official docs. And that’s why we do the same :)
What’s wrong with it?
This approach works well for a demo. But in real life things get complicated. In real life, you will have much more sagas and
configureSaga function will grow a lot. Saga registration and management become uneasy but still, it’s not a big issue.
The other thing is the dependencies that we have in our saga. Sagas should keep our application logic and make our React component simple. I try to remove all complex logic from my React containers. I usually pass actions to my main component
So ProductUpdatePage.ts renders a form and allows to update the product. But as you can see the component has no idea how does productSave function actually works.
connected.ts is some kind of a configuration for a component where we assign data from the store and pass actions. In fact, there’s no logic — it’s configuration.
index.ts is just a shorthand it exports the right HOC version of the component.
For me ideal case when my components don’t know anything about history, API, other services. I can move everything to the saga via actions.
Now back to the saga. We have application logic in sagas now. So what’s wrong with it? Difficulties begin when we start testing our saga.
We don’t inject dependencies in the saga and that’s why it’s tricky to replace dependency with mocked version. Or we have to do integration testing that is much more expensive and harder to maintain. A few more approaches for testing
What about a better solution
If we don’t have dependencies we can go with a functional approach without any pain. But when we need dependencies it becomes a problem since we cannot effectively replace them with the instances that we can test. So what do we need? Right - Dependency Injection. Basically, approach #3 has it, we inject dependencies via function arguments. And the test works very well. The problem is it’s catastrophically uncomfortable to pass dependencies inside the action. What if we could pass dependencies separately? So we need some internal state inside the saga. Are you thinking the same thing as me? I’m thinking about classes.
- we create a new class and allow to pass dependencies in the constructor
- we’ve added new function *register it should simplify watchers registration. I’ll tell about it in the next part of this article.
yield takeEvery(SAVE_PRODUCT, this.saveProduct());will return absolutely the same generator function as we had before but this time we can use backenApi and history that have been injected in the constructor.
- now when we can inject dependencies we don’t have a problem with mocking and successfully resolve the problem that we had with approach #3.
How to configure sagas?
Wait! but how/where are you going to construct all your sagas and pass all required dependencies. That’s the right question. We have to build and register saga only once. Moreover, ISaga that we used above can help us simplify saga registration.
I’d recommend using DI container and do not initiate dependencies multiple times. Things like browserHistory must be initiated only once to prevent unexpected bugs.
What do we have at the end of the day
- easy saga testing with dependencies
- pure unit test, we don’t need to integrate saga with anything. Easy to maintain, runs faster, use fewer resources
- easy saga registration with ISaga. We can add many takeEvery inside one saga. SAVE_PRODUCT, REFRESH_PRODUCT
- initiate dependencies only once. No more dozen of createBrowserHistory() in different parts for the app.