Building testable apps
In this article, I want to talk about my learnings on building testable apps from the past few years. I've been building mostly Frontend apps using React, so I'll talk from that perspective, but it should be relevant to all the app developments.
In the lifespan of software, we as developers basically work on only two things.
- change code to add or alter features. These features can be any requirement that is to keep the software useful.
- fix bugs introduced from the previous process.
I think everybody agrees that writing tests help hugely with the above two actions. However, in my experience as a frontend developer, writing tests has never been an easy task.
Hard to unit test UI components.
A component can have many sources of state. It is usually tedious to mock all of them. One typical example is the context. When we use react-testing-library (RTL) to test a simple component, we have to set up all the contexts it is used in. For example, the antd context and redux context.
Another thing when testing using RTL is that when testing a larger component, it is hard to imagine the UIs and the user interactions by looking at the code. For me, I need to refer to a running application while writing tests. These multiple setup costs have deterred devs in our team away from writing unit tests for each component they build.
Hard to unit test state.
State logics are scattered across the app and some parts of it are highly coupled with UI. So in order to test state, we are often forced to render the UI as well, hence it goes back to pain point no. 1. The relations between state models are not always clearly defined. States are usually imperatively written (often derived directly from server response structures), it's hard to reason it in an object-oriented style.
In addition to that, state logic written using async libraries like redux-saga is very hard to test. We'll need to run a generator instance and feed in all the data required by the instance in order to get our result.
To re-summarise the pain points, I think I can drill them down to the following points.
- RTL is hard to use or integration tests are hard to write. And it's not very time-effective and developer-friendly when it comes to testing small components.
- State is often tangled with UI which makes it more complicated to reason about.
- Async logic is tested separately and it's hard to make sense of them.
Naturally, if integration tests are hard to write using RTL, we can opt-out to non-integration test tools like Enzyme to only shallowly test the component itself. Enzyme supports event simulation. We can test the internal states by interacting with the component. But the component can still depend on external contexts, which will require some setup costs. If we can move that dependency to the props, then we can remove the context setup. So we are talking about a PURE component. Its output is only depending on inputs. This reminds me of a famous law in UI development.
fn is the pure function component mentioned above. The state is passed in as arguments to the component, then it outputs renderable UI. The function is idempotent as it is in the functional programming paradigm. It's simple and powerful.
Another subtle observation is that the state is singled out as a parameter. It implies the separation of UI code and state code. They are mostly separated except when it is time to render the state onto the form of UI. So if we can gather all the state-related procedures together and make them agnostic of UI, then it will be easier to reason about. This also rings the bell of another way of state management I would like to try, mobx. Yes, it is object-oriented programming we know!
I think I've kind of had the solutions for the first two problems now. The last one, it's more about testing business flow. For example, it is required that when you submit a form, besides sending out the form data to the server, you also have to refresh the local user data if the submission is successful and update another couple of endpoints. Essentially, this is all related to the state or model, nothing about UI. For this, we can just test the function that triggers the form submission in our model and then assert the final result. The only thing here is that the flow is asynchronous. Asynchronous operations do depend on outside systems which we have to mock them up. However, now we have grouped the business flow in one function and put it together with the state object. It is more clear now to the tester how does the business flow work.
Another law of UI development is that
Every change on UI originates from an event.
The event can be a button click, reload of a page, or mouse movements. But eventually, they lead to the invocation of some of function or flow. We can leave the testing of the initiation of the event to function calling to the UI itself. Then our state test can focus on testing the (async) function and its impact on the state.
Based on the discussions above, I have come up with the following rules hopefully by following them, we can have an easier time writing unit tests.
For UI component
- have clear type declarations of input props.
- keep the component size small, perform only a few things at a time.
- keep any business logic out of the UI component, move them to the state object.
- always ensure that there is no access to any non-local state. if there must be such a case, for example from context variables, isolate it to a data provider node, and pass it to the component via consumer.
- make sure hooks in the component only use local state.
- use composition over HOC (more explicit, easier to debug).
- group relevant state variables together. best in a model object.
- make sure to differentiate derived data.
- group data accessor together with the state itself.
- group data modifier together with the state itself.
- state group should have no assumption of the UI.
- recommend differentiating UI state vs App state
With these, we can write simple UI tests using shallow renderers. It's easy to come up with the parameters that can alter the shape of the tested component. The tests can be extremely trivial to write. We also can easily test non-UI logics that are closely relevant to the state objects. These tests should focus on how originating events alter the state. So we should be able to assert that one event always ends up in one state. (idempotence)
Assuming we have followed the above rules strictly and based on the functional UI law we discussed above, we can conclude that :
Since the state is correct, and the function is correct, the UI must be correct.
By separating things apart, we can test only the things we write and assume it will still work when everything else is put together.
Of course, for the best result, we should have set up some e2e tests. But that's another beast to conquer. For now, if we can achieve high unit tests coverage, it is already good enough.