Skip to content

Commit 23ad484

Browse files
committed
feat(recipes): add recipe for "testing my code that triggers side effects"
1 parent 5aa1c23 commit 23ad484

10 files changed

Lines changed: 395 additions & 2 deletions

File tree

recipes/advanced/testing-code-triggering-side-effects/README.md

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,137 @@
22

33
This recipe shows you how simple it is to test your code that triggers side effects (like loading data from your API) with **simplux**.
44

5-
If you are new to **simplux** there is [a recipe](../../basics/getting-started#readme) that will help you get started before you follow this recipe.
5+
If you are new to **simplux** there is [a recipe](../../basics/getting-started#readme) that will help you get started before you follow this recipe. The recipe for [performing side effects](../performing-side-effects#readme) is also important for following this recipe.
66

77
> You can play with the code for this recipe in this [code sandbox](https://codesandbox.io/s/github/MrWolfZ/simplux/tree/master/recipes/advanced/testing-code-triggering-side-effects).
88
9-
This recipe is still a work-in-progress.
9+
Before we start let's install all the packages we need.
10+
11+
```sh
12+
npm i @simplux/core @simplux/testing redux -S
13+
```
14+
15+
Now we're ready to go.
16+
17+
For this recipe we use a simple scenario: loading data from an API. Let's create a module for this.
18+
19+
```ts
20+
interface Todo {
21+
id: string
22+
description: string
23+
isDone: boolean
24+
}
25+
26+
interface TodoState {
27+
[id: string]: Todo
28+
}
29+
30+
const initialState: TodoState = {}
31+
32+
const todosModule = createSimpluxModule({
33+
name: 'todos',
34+
initialState,
35+
})
36+
37+
const { setTodoItems } = createMutations(todosModule, {
38+
setTodoItems(state, items: Todo[]) {
39+
for (const id of Object.keys(state)) {
40+
delete state[id]
41+
}
42+
43+
for (const item of items) {
44+
state[item.id] = item
45+
}
46+
},
47+
})
48+
49+
// this effect simulates calling our API
50+
const loadTodosFromApi = createEffect(async (includeDoneItems: boolean) => {
51+
await new Promise(resolve => setTimeout(resolve, 200))
52+
53+
const todos = [
54+
{ id: '1', description: 'go shopping', isDone: false },
55+
{ id: '2', description: 'clean house', isDone: true },
56+
{ id: '3', description: 'bring out trash', isDone: true },
57+
{ id: '4', description: 'go to the gym', isDone: false },
58+
] as Todo[]
59+
60+
return todos.filter(t => !t.isDone || includeDoneItems)
61+
})
62+
```
63+
64+
In our application code we want to load the todo items when a button is clicked. There is also a checkbox to determine whether the todo items should be filtered.
65+
66+
```ts
67+
async function onLoadButtonClicked(includeDoneItems: boolean) {
68+
const todos = await loadTodosFromApi(includeDoneItems)
69+
setTodoItems(todos)
70+
return todos
71+
}
72+
```
73+
74+
This is the code we are going to test.
75+
76+
The best way to test our code is to test it in isolation from the module. That means we do not want the effect (or the mutation) to be executed during our test. This is where the **simplux** testing extension comes into play: it allows us to mock a effects.
77+
78+
```ts
79+
import { mockEffect, mockMutation } from '@simplux/testing'
80+
81+
it('loads data with correct filter', async () => {
82+
const loadDataMock = jest.fn().mockReturnValue(Promise.resolve([]))
83+
84+
// after this line all invocations of the effect will be
85+
// redirected to the provided mock function
86+
mockEffect(loadTodosFromApi, loadDataMock)
87+
88+
// we also mock the mutation that is called in our handler;
89+
// see the recipe for "testing my code that uses mutations"
90+
// for more details on this
91+
mockMutation(setTodoItems, jest.fn())
92+
93+
await onLoadButtonClicked(true)
94+
95+
expect(loadDataMock).toHaveBeenCalledWith(true)
96+
})
97+
```
98+
99+
The `mockEffect` call above mocks our mutation indefinitely. The testing extension provides a way to clear all simplux mocks which we can simply do after each test.
100+
101+
```ts
102+
import { clearAllSimpluxMocks } from '@simplux/testing'
103+
104+
afterEach(clearAllSimpluxMocks)
105+
```
106+
107+
In specific rare situations it can be useful to manually clear a mock during a test. For this the `mockEffect` function returns a callback function that can be called to clear the mock.
108+
109+
```ts
110+
it('sets the loaded items in the module', async () => {
111+
const data: Todo[] = [
112+
{
113+
id: '1',
114+
description: 'clean',
115+
isDone: false,
116+
},
117+
]
118+
119+
const loadDataMock = jest.fn().mockReturnValue(Promise.resolve(data))
120+
const setTodoItemsMock = jest.fn()
121+
122+
const clearLoadDataMock = mockEffect(loadTodosFromApi, loadDataMock)
123+
mockMutation(setTodoItems, setTodoItemsMock)
124+
125+
await onLoadButtonClicked(true)
126+
127+
// clear the mock explicitly
128+
clearLoadDataMock()
129+
130+
// do something that requires the mock to be cleared
131+
132+
expect(setTodoItemsMock).toHaveBeenCalledWith(data)
133+
})
134+
```
135+
136+
And that is all you need to test your code that uses **simplux** effects.
137+
138+
Have a look at our [other recipes](../../../../..#recipes) to learn how **simplux** can help you make your life simple in other situations.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!DOCTYPE html>
2+
<html lang="">
3+
<head>
4+
<meta charset="UTF-8" />
5+
</head>
6+
<body>
7+
Include done items?
8+
<input type="checkbox" id="includeDoneItemsCheckbox" />
9+
<br />
10+
<button id="loadDataBtn">Load data</button>
11+
<ul id="itemList"></ul>
12+
</body>
13+
</html>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
module.exports = {
2+
roots: ['<rootDir>/src', '<rootDir>'],
3+
moduleFileExtensions: ['js', 'ts'],
4+
transform: {
5+
'^.+\\.ts': 'ts-jest',
6+
},
7+
testMatch: ['<rootDir>/src/**/*.spec.ts'],
8+
testPathIgnorePatterns: ['node_modules'],
9+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"name": "@simplux/recipes.advanced.testing-code-triggering-side-effects",
3+
"version": "0.9.0-alpha.0",
4+
"description": "This recipe shows you how simple it is to test your code that triggers side effects (like loading data from your API)",
5+
"main": "src/index.ts",
6+
"scripts": {
7+
"start": "webpack-dev-server --mode development --progress --color",
8+
"test": "jest --forceExit --verbose --detectOpenHandles --no-cache --colors --runInBand --config=./jest.config.js"
9+
},
10+
"repository": {
11+
"type": "git",
12+
"url": "git+https://github.com/MrWolfZ/simplux.git"
13+
},
14+
"bugs": {
15+
"url": "https://github.com/MrWolfZ/simplux/issues"
16+
},
17+
"homepage": "https://github.com/MrWolfZ/simplux/tree/master/recipes/advanced/testing-code-triggering-side-effects#readme",
18+
"keywords": [
19+
"recipe",
20+
"redux",
21+
"simplux",
22+
"effects",
23+
"testing",
24+
"typescript"
25+
],
26+
"author": "Jonathan Ziller <jonathan.ziller@gmail.com> (https://www.github.com/MrWolfZ)",
27+
"license": "MIT",
28+
"private": true,
29+
"dependencies": {
30+
"@simplux/core": "0.9.0-alpha.0",
31+
"@simplux/testing": "0.9.0-alpha.0",
32+
"redux": "^4.0.1"
33+
},
34+
"devDependencies": {
35+
"@types/jest": "^24.0.18",
36+
"html-webpack-plugin": "^3.2.0",
37+
"jest": "24.8.0",
38+
"ts-jest": "^24.0.2",
39+
"ts-loader": "^6.0.2",
40+
"typescript": "^3.5.1",
41+
"webpack": "^4.32.2",
42+
"webpack-cli": "^3.3.2",
43+
"webpack-dev-server": "^3.5.1"
44+
}
45+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"infiniteLoopProtection": true,
3+
"hardReloadOnChange": false,
4+
"view": "tests"
5+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// this code is part of the simplux recipe "testing my code that triggers side effects":
2+
// https://github.com/MrWolfZ/simplux/tree/master/recipes/advanced/testing-code-triggering-side-effects
3+
4+
import {
5+
clearAllSimpluxMocks,
6+
mockEffect,
7+
mockMutation,
8+
} from '@simplux/testing'
9+
import { onLoadButtonClicked } from './index'
10+
import { loadTodosFromApi, setTodoItems, Todo } from './todos'
11+
12+
describe('loading todo items', () => {
13+
// the best way to test our code is to test it in isolation from
14+
// the module; that means we do not want any mutations or effects
15+
// to be executed during our test; this is where the **simplux**
16+
// testing extension comes into play: it allows us to mock
17+
// mutations and effects
18+
it('loads data with correct filter', async () => {
19+
const loadDataMock = jest.fn().mockReturnValue(Promise.resolve([]))
20+
21+
// after this line all invocations of the effect will be
22+
// redirected to the provided mock function
23+
mockEffect(loadTodosFromApi, loadDataMock)
24+
25+
// we also mock the mutation that is called in our handler;
26+
// see the recipe for "testing my code that uses mutations"
27+
// for more details on this
28+
mockMutation(setTodoItems, jest.fn())
29+
30+
await onLoadButtonClicked(true)
31+
32+
expect(loadDataMock).toHaveBeenCalledWith(true)
33+
})
34+
35+
// the `mockMutation` and `mockEffect` calls above mock the mutation
36+
// and effect indefinitely; the testing extension provides a way
37+
// to clear all simplux mocks which we can simply do after each test
38+
afterEach(clearAllSimpluxMocks)
39+
40+
// in specific rare situations it can be useful to manually clear
41+
// a mock during a test; for this the `mockEffect` function
42+
// returns a callback function that can be called to clear the mock
43+
it('sets the loaded items in the module', async () => {
44+
const data: Todo[] = [
45+
{
46+
id: '1',
47+
description: 'clean',
48+
isDone: false,
49+
},
50+
]
51+
52+
const loadDataMock = jest.fn().mockReturnValue(Promise.resolve(data))
53+
const setTodoItemsMock = jest.fn()
54+
55+
const clearLoadDataMock = mockEffect(loadTodosFromApi, loadDataMock)
56+
mockMutation(setTodoItems, setTodoItemsMock)
57+
58+
await onLoadButtonClicked(true)
59+
60+
// clear the mock explicitly
61+
clearLoadDataMock()
62+
63+
// do something that requires the mock to be cleared
64+
65+
expect(setTodoItemsMock).toHaveBeenCalledWith(data)
66+
})
67+
})
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// this code is part of the simplux recipe "testing my code that triggers side effects":
2+
// https://github.com/MrWolfZ/simplux/tree/master/recipes/advanced/testing-code-triggering-side-effects
3+
4+
import { loadTodosFromApi, setTodoItems } from './todos'
5+
6+
if (document.getElementById('loadDataBtn')) {
7+
setupEventHandler()
8+
} else {
9+
document.addEventListener('DOMContentLoaded', setupEventHandler)
10+
}
11+
12+
export function setupEventHandler() {
13+
document
14+
.getElementById('loadDataBtn')!
15+
.addEventListener('click', async () => {
16+
const inputElement = document.getElementById(
17+
'includeDoneItemsCheckbox',
18+
) as HTMLInputElement
19+
const value = inputElement.checked
20+
21+
const items = await onLoadButtonClicked(value)
22+
23+
const itemList = document.getElementById('itemList') as HTMLUListElement
24+
let child = itemList.lastElementChild
25+
while (child) {
26+
itemList.removeChild(child)
27+
child = itemList.lastElementChild
28+
}
29+
30+
for (const { id, description } of items) {
31+
const newItemElement = document.createElement('li')
32+
newItemElement.id = id
33+
newItemElement.innerHTML = description
34+
itemList.appendChild(newItemElement)
35+
}
36+
})
37+
}
38+
39+
// this is the code we want to test; it calls a mutation and an effect
40+
export async function onLoadButtonClicked(includeDoneItems: boolean) {
41+
const todos = await loadTodosFromApi(includeDoneItems)
42+
setTodoItems(todos)
43+
return todos
44+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// this code is part of the simplux recipe "testing my code that triggers side effects":
2+
// https://github.com/MrWolfZ/simplux/tree/master/recipes/advanced/testing-code-triggering-side-effects
3+
4+
import {
5+
createEffect,
6+
createMutations,
7+
createSimpluxModule,
8+
} from '@simplux/core'
9+
10+
export interface Todo {
11+
id: string
12+
description: string
13+
isDone: boolean
14+
}
15+
16+
export interface TodoState {
17+
[id: string]: Todo
18+
}
19+
20+
const initialState: TodoState = {}
21+
22+
export const todosModule = createSimpluxModule({
23+
name: 'todos',
24+
initialState,
25+
})
26+
27+
export const { setTodoItems } = createMutations(todosModule, {
28+
setTodoItems(state, items: Todo[]) {
29+
for (const id of Object.keys(state)) {
30+
delete state[id]
31+
}
32+
33+
for (const item of items) {
34+
state[item.id] = item
35+
}
36+
},
37+
})
38+
39+
// this effect simulates calling our API
40+
export const loadTodosFromApi = createEffect(
41+
async (includeDoneItems: boolean) => {
42+
await new Promise(resolve => setTimeout(resolve, 200))
43+
44+
const todos = [
45+
{ id: '1', description: 'go shopping', isDone: false },
46+
{ id: '2', description: 'clean house', isDone: true },
47+
{ id: '3', description: 'bring out trash', isDone: true },
48+
{ id: '4', description: 'go to the gym', isDone: false },
49+
] as Todo[]
50+
51+
return todos.filter(t => !t.isDone || includeDoneItems)
52+
},
53+
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"compilerOptions": {
3+
"sourceMap": true,
4+
"strict": true,
5+
"noUnusedLocals": true,
6+
"noUnusedParameters": true,
7+
"lib": ["dom", "es2015"],
8+
"types": ["node", "jest"]
9+
}
10+
}

0 commit comments

Comments
 (0)