Testing the TodoMVC app
We've already covered two important parts of building the TodoMVC app: state diagrams drawn in the editor and the code associated with nodes. These things are crucial in the development process, because without them there would be no app at all. However, if we want to make sure the development process is sustainable, we need to have an automatic test suite. Without such a thing we can't really know if a little change to the codebase we've just introduced doesn't break any important functionality without testing the whole thing manually. And because humans are not the best test runners, at least compared to computers, manual tests tend to be not so repetitive and quite error prone.
I'm pretty sure you also love that feeling of freedom that a good automatic test suite brings. There's no fear of refactoring, no fear of regression. So why are we even talking about automatic tests, if it's so obvious that they are beneficial? It's because they are not trivial to introduce. Many frameworks have their own testing framework, testing philosophy etc. So does Rosmaro. It only shows that test automation is still kind of an uncharted territory.
Before we dive deep into the details of how the tests in our codebase are implemented, I'd like to show you a snippet from the test of adding a new item:
// __tests__/adding_a_todo.js
test('adding a todo', () =>
testFlow([
assertFooterInvisible,
assertMainInvisible,
assertNewTodoFormFocused,
assertMarkAllAsCompletedInvisible,
addTodo({ value: 'new todo' }),
assertTodoPresent({ value: 'new todo' }),
assertTodoActive({ value: 'new todo' }),
assertMarkAllAsCompletedUnchecked,
assertFooterVisible,
assertMainVisible,
]));
A test is a high-level description of the required behavior, without going deep into any implementation details of the application, like what does it exactly mean that the footer is invisible or how to add a todo.
Every test is made of a series of little steps that can either make assertions, like assertFooterInvisible
or "perform" an action, like addTodo
. Steps are reusable and may be composed in various way to create different tests.
Even though there are multiple wonderful testing libraries under the hood, like dom-testing-library which is the framework-agnostic core of react-testing-library, which in our case of a snabbdom-based implementation is used via snabbdom-testing-library or jest, the testing framework itself, it's the functional approach that makes tests simple. The Virtual DOM enables us to narrow down the whole DOM-related code to a bunch of pure functions of the application state (state => UI
). Rosmaro's side-effect model, heavily inspired by Elm, where effects are simply values returned by pure functions, is usually implemented by running the Rosmaro model function in a Redux store with Redux-Saga. It's quite straightforward to achieve, as the Rosmaro ecosystem provides the glue necessary to make all these pieces work together. Standing on the shoulders of giants, it took just a little bit of code to create rosmaro-testing-library which main purpose is to reduce the tests to a series of steps looking like this:
{
feed: { type: '...'},
consume: ({ result }) => {
expect(/* ... */)// ...
}
}
Because a Rosmaro model is nothing but a pure function of state and action that returns an updated version of the state and some result, that is ({ state, action }) => ({ state, result })
, there are no moving parts we need to keep track of when testing it. As long as we provide the same state
and action
values, it will always return the exact same new state
and result
. We don't need to think about asynchronous calls, unexpected side effects run in the test suite or unhandled promise rejections, because there are not things like that at all.
We could say that the rosmaro-testing-library is a "fake", manually "increased" runtime environment for the Rosmaro model under test. It's actually very similar to Redux, in terms of reducing the list of steps using the model function. The testing-related part is the consume
function that allows us to make assertions about the result of consuming an action
.
Please don't worry, if some of the things above are not totally clear. They will certainly make sense after we go through the code.
Let's then take a look at the complete test file, not just a snippet extracted from it:
// __tests__/adding_a_todo.js
import testFlow from '~/testUtils/testFlow';
import assertFooterInvisible from '~/testSteps/assert_footer_invisible';
import assertFooterVisible from '~/testSteps/assert_footer_visible';
import assertMainInvisible from '~/testSteps/assert_main_invisible';
import assertMainVisible from '~/testSteps/assert_main_visible';
import addTodo from '~/testSteps/add_todo';
import assertNewTodoFormFocused from '~/testSteps/assert_new_todo_form_focused';
import assertMarkAllAsCompletedInvisible from '~/testSteps/assert_mark_all_as_completed_invisible';
import assertMarkAllAsCompletedUnchecked from '~/testSteps/assert_mark_all_as_completed_unchecked';
import assertTodoPresent from '~/testSteps/assert_todo_present';
import assertTodoActive from '~/testSteps/assert_todo_active';
test('adding a todo', () =>
testFlow([
assertFooterInvisible,
assertMainInvisible,
assertNewTodoFormFocused,
assertMarkAllAsCompletedInvisible,
addTodo({ value: 'new todo' }),
assertTodoPresent({ value: 'new todo' }),
assertTodoActive({ value: 'new todo' }),
assertMarkAllAsCompletedUnchecked,
assertFooterVisible,
assertMainVisible,
]));
The file lives in the __tests__
directory, what denotes that it will be picked by jest
and run when we run npm t
in our terminal.
As we can see, there isn't much more code than what we've already seen. Actually there are only imports. Starting from the very top of the file, we import the testFlow
helper function from a project-specific setup file ~/testUtils/testFlow
, which main goal is to wrap the testFlow utility from rosmaro-testing-library. All the other imports are test steps, that is { feed, consume }
objects or collections of these objects (they may be nested).
The difference between a test and a test step is that a test is the final, complete scenario the test runner picks and runs, while a test step is just a building block that may be reused in multiple tests, or even within the same test.
The complete code of the step asserting that the footer is invisible is just a couple of lines long:
// testSteps/assert_footer_invisible.js
export default ({ testContext }) => ({
feed: { type: 'RENDER' },
consume: ({ result }) => {
const { queryByTestId } = testContext.render(result.data);
expect(queryByTestId('footer')).toBeNull();
},
});
What's returned by the function of testContext
is the actual test step. We'll go back to the testContext
soon, but for now, I think it's a better idea to focus on feed
and consume
.
The feed
value is the action that's passed to the model. In this case, we want to ensure that the footer is not rendered, so we're giving the model a { type: 'RENDER' }
action, expecting it to render the UI.
The consume
function takes the result of calling the Rosmaro model with the recent state
and the action
from the feed
property. Let's focus on this function for a moment, as it plays a very important role in testing Rosmaro models. Because we're using rosmaro-binding-utils, the result
always has the following structure: { data, effect }
. The RENDER
action is not supposed to have any side effects, and that's why we may totally ignore the effect
property and focus solely on the data
, which in this case is a Snabbdom VDOM node. We're using the render
function from the testContext
. It comes from snabbdom-testing-library and gives us all the selectors and utilities provided by dom-testing-library. Due to the fact that the template of the TodoMVC app doesn't really help us with label-based selectors, we're using a little bit less nice (when it comes to test coverage), but well functioning selector function queryByTestId
. It matches the element based on the data-testid
attribute we're setting on it:
// components/root/bindings/main/Layout/WholeApp/index.js
h('footer.footer', { attrs: { 'data-testid': 'footer' } }, [
list.counter,
controls.Navigation.ui,
controls.ClearCompleted,
]),
After querying for the element, we can use the expect
function from jest
to make sure that no footer is rendered:
// testSteps/assert_footer_invisible.js
expect(queryByTestId('footer')).toBeNull();
The testContext
object comes from rosmaro-testing-library
and is used to store things that may be useful during the test. In our setup file, testUtils/testFlow.js
, we put there the render
function from snabbdom-testing-library
. This object may be updated in a very similar way the state is updated, that is by returning its new version. If there's something in the result of consuming an action that we would like to make available for further test steps, we can add it to the context. You can find more on this in the README of rosmaro-testing-library.
In the test, there's an interesting, parameterized step to add a new item - addTodo
:
// __tests__/adding_a_todo.js
test('adding a todo', () =>
testFlow([
// ...
addTodo({ value: 'new todo' }),
// ...
]));
Let's take a closer look at its source code:
// testSteps/add_todo.js
import typeInNewTodo from '~/testSteps/type_in_new_todo';
import enterInNewTodoInput from '~/testSteps/enter_in_new_todo_input';
export default ({ value }) => [typeInNewTodo({ value }), enterInNewTodoInput];
As we can see, this step consists of two smaller steps: typeInNewTodo
and enterInNewTodoInput
. The first one simulates writing something in the input field and looks like this:
// testSteps/type_in_new_todo.js
import { fireEvent } from 'snabbdom-testing-library';
import { consumeActionsWithEffects } from 'rosmaro-testing-library';
export default ({ value }) => ({ testContext }) => ({
feed: { type: 'RENDER' },
consume: ({ result }) => {
testContext.store.clearActions();
const { getByPlaceholderText } = testContext.render(result.data);
const input = getByPlaceholderText('What needs to be done?');
fireEvent.input(input, { target: { value } });
const actions = testContext.store.getActions();
return { step: consumeActionsWithEffects(actions) };
},
});
We're using a factory function, that is a function of value
, in order to build a test step (a function of testContext
) that simulates typing the value
in the input field. In order to examine the input field and its behavior, we're rendering the UI in the same way we did it previously, that is by passing a RENDER
action to the model. This time, however, we need to do something more sophisticated - we need to simulate user interaction. That's why after querying for the input field, here using the getByPlaceholderText
function, we're simulating input event using the fireEvent
function from snabbdom-testing-library
. Because all the UI does is dispatching actions to the redux store, we're using the redux-mock-store
package to simulate a store and get all the actions that have been dispatched to it. Aadding an item and reacting to it is quite complex and takes a few events, and that's why we're making use of the consumeActionsWithEffects
helper from rosmaro-testing-library
in order to emulate DISPATCH
effects. It will generate further test steps, that is { feed, consume }
objects necessary to fully handle the process of adding an item. As mentioned in the previous post with the code of the TodoMVC app, the list of items dispatches events that the rest of the app may react to, for example:
// main/Components/List/View/index.js
return [{type: 'DISPATCH', action: {type: 'SOME_TODOS_COMPLETED'}}];
Sometimes, when we do really care about every single iteration, we may want to manually "execute" effects by storying them in the testContext
and passing them as actions in the feed
property, for example to make sure that there's no race condition, if our Rosmaro model is used to handle some network communication or another time sensitive process.
Below you can see some other tests, taken directly from the tests directory, just without all the imports (for the sake of brevity):
// __tests__/counter_for_1_active_todo.js
test('the counter shows "1 item left"', () =>
testFlow([
addTodo({ value: 'todo A' }),
addTodo({ value: 'todo B' }),
toggleTodo({ value: 'todo A' }),
assertCounterValue({ expectedValue: '1 item left' }),
]));
// __tests__/marking_a_todo_as_completed.js
test('marking a todo as completed', () =>
testFlow([
addTodo({ value: 'first todo' }),
addTodo({ value: 'second todo' }),
toggleTodo({ value: 'second todo' }),
assertTodoCompleted({ value: 'second todo' }),
assertTodoActive({ value: 'first todo' }),
]));
// __tests__/removing_a_todo.js
test('removing a todo', () =>
testFlow([
addTodo({ value: 'todo A' }),
toggleTodo({ value: 'todo A' }),
addTodo({ value: 'todo B' }),
addTodo({ value: 'todo C' }),
assertTodoPresent({ value: 'todo A' }),
assertTodoCompleted({ value: 'todo A' }),
clickDestroy({ todo: 'todo B' }),
assertTodoNotPresent({ value: 'todo B' }),
assertTodoPresent({ value: 'todo C' }),
assertTodoActive({ value: 'todo C' }),
]));
I hope you enjoyed this post and that it gave you a broad idea how Rosmaro models may be tested. If you're willing to learn more, you may find the following resources helpful:
- rosmaro-testing-library on GitHub
- testUtils, testSteps and the test files from the repository of the Rosmaro TodoMVC app