Skip to main content

Testing

How would we go about testing our Todo List app?

It's straightforward: let's dispatch some actions, wait for them to finish, and then verify if the new state is as expected, or check if some error was thrown.

We have the following actions in our app:

  • AddTodoAction
  • RemoveAllTodosAction
  • RemoveCompletedTodosAction
  • AddRandomTodoAction
  • ToggleTodoAction
  • NextFilterAction

Basic action testing

Let's start with the AddTodoAction. This action adds a new todo item with the given text to the list. That is, unless the text already exists in the list, in which case it throws an error.

This is a possible test for this action:

test('AddTodoAction', async () => {

const store = createStore<State>({ initialState: State.initialState });

// Should add a new todo item, with text 'Some text'
await store.dispatchAndWait(new AddTodoAction('Some text'));
expect(store.state.todoList.items[0].text).toBe('Some text');

// Fail to add the same text again
let status = await store.dispatchAndWait(new AddTodoAction('Some text'));
expect(status.originalError).toBeInstanceOf(UserException);
});

Easy, right? We create a new store, dispatch the action, and check the new state. The dispatchAndWait method waits for the action to finish dispatching, and returns the action status, which contains detailed information about the dispatch, including any errors thrown.

Setting the initial state for the test

Now, let's test the RemoveAllTodosAction. We expect this action to remove all todo items from the list, which means we must first add some items to the list.

In other words, we must set the initial state of the store as a prerequisite for this test.

There are a few ways to do this. For example, we can first create the store, and then dispatch actions to change its initial state into the desired one:

const store = createStore<State>({ initialState: State.initialState });
store.dispatch(new AddTodoAction('First todo'));
store.dispatch(new AddTodoAction('Second todo'));

Alternatively, we could have already created the store with the proper initial state, in a single step:

const store = createStore<State>({
initialState:
State.initialState.withTodoList(
new TodoList(
[
new TodoItem('First todo', false),
new TodoItem('Second todo', true),
]
)
)
});

Or, we could have created the state separately:

let item1 = new TodoItem('First todo', false);
let item2 = new TodoItem('Second todo', true);
let todoList = new TodoList([item1, item2]);
let state = State.initialState.withTodoList(todoList);

const store = createStore<State>({ initialState: state });

The rest of the test is straightforward. This is the complete code:

test('RemoveAllTodosAction', async () => {

let item1 = new TodoItem('First todo', false);
let item2 = new TodoItem('Second todo', true);
let todoList = new TodoList([item1, item2]);
let state = State.initialState.withTodoList(todoList);

const store = createStore<State>({ initialState: state });

// Should remove all todo items
await store.dispatchAndWait(new RemoveAllTodosAction());
expect(store.state.todoList.items.length).toBe(0);
});

Testing asynchronous actions

Both AddTodoAction and RemoveAllTodosAction above are "synchronous", meaning they don't involve any asynchronous operation. We know this by looking at their reducers, which are declared with reduce().

The only asynchronous action we have in our app is AddRandomTodoAction, which is declared with async reduce() and returns a Promise. This action fetches a random todo item from an external API:

class AddRandomTodoAction extends Action {
async reduce() {
let response = await fetch("https://dummyjson.com/todos/random/1");
if (!response.ok) throw new UserException("API failed.");
let jsonResponse = await response.json();
let text = jsonResponse[0].todo;

return (state: State) =>
state.withTodoList(this.state.todoList.addTodoFromText(text));
}
}

Testing an asynchronous action is just as easy, and not different from testing a synchronous one. We still dispatch the action, wait for it to finish, and check the new state.

test('AddRandomTodoAction', async () => {

const store = createStore<State>({ initialState: State.initialState });

// Should add a new todo item
await store.dispatchAndWait(new AddRandomTodoAction());
expect(store.state.todoList.items.length).toBe(1);
});

The test above is calling the real external API. This works, but note we can't check the text of the new todo item, because it's random. We could instead mock or simulate the API call to have it return a fixed value, and then check if the new todo item has the expected text.

To that end, let's first go back to the AddRandomTodoAction code, and extract the API call into a separate function called fetchRandomTodo:

class AddRandomTodoAction extends Action {  

async reduce() {
let text = await this.fetchRandomTodo();
return (state: State) => state.withTodoList(this.state.todoList.addTodoFromText(text));
}

async fetchRandomTodo() {
let response = await fetch("https://dummyjson.com/todos/random/1");
if (!response.ok) throw new UserException("API failed.");
let jsonResponse = await response.json();
return jsonResponse[0].todo;
}
}

We can now mock the fetchRandomTodo method in our test:

class MockAddRandomTodoAction extends AddRandomTodoAction {
async fetchRandomTodo() {
return "Fixed text";
}
}

test('AddRandomTodoAction', async () => {
const store = createStore<State>({ initialState: State.initialState });

// Here we use the mock action
await store.dispatchAndWait(new MockAddRandomTodoAction());

// Now we can check if the new todo item has the expected text
expect(store.state.todoList.items[0].text).toBe("Fixed text");
});

The above code is just an example, and it's not the recommended way to mock functions. You should feel free to use any mocking features of your test framework, or any mocking library you prefer.

And remember you can also use the real API calls, turning your tests into easy-to-write integration tests.

Try it yourself

Now, try to implement the tests for the remaining synchronous actions:

  • RemoveCompletedTodosAction
  • AddRandomTodoAction
  • ToggleTodoAction
  • NextFilterAction