Parlons store, tests et détails d'implémentation

Si vous êtes familiers avec Redux, alors les termes reducer ou action creator vous disent sûrement quelque chose. Si vous avez déjà construit une application un peu complexe exécutant des tâches asynchrones, alors les termes thunks, saga ou epics vous parleront peut-être.

Dans ce post, j'aimerais challenger la documentation officielle concernant la stratégie de tests des applications Redux.

Je parle ici de Redux, mais les concepts sont aussi largement applicables à Vuex si vous utilisez Vue, ou tout autre système de state management.

Le problème

L'un des avantages à utiliser un store tel que Redux, c'est la testabilité : le store est découplé de React (ou autre bibliothèque / framework graphique), ce qui le rend plus simple à tester. On lit également souvent que les reducers et les actions creators sont des fonctions pures et sont donc facilement testable. De plus, les librairies telles que redux-saga et redux-observable offrent souvent des guides sur les tests unitaires.

Alors on écrit des tests pour nos reducers :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Test unitaire
describe('todoReducer', () => {
  it('should handle TODO_CREATED', () => {
    // Given
    const todo = {
      id: 'todo-1',
      isComplete: false,
      text: 'my todo content'
    }
    const action = todoCreated(todo)
    const initialState = []

    // When
    const nextState = todoReducer(initialState, action)

    // Then
    expect(nextState).toEqual([todo])
  })
})

// Implémentation
const todoCreated = createAction('TODO_CREATED', todo => ({ todo }))
const todoReducer = createReducer([], {
  [todoCreated.type]: (state, action) => [...state, action.todo]
})

Certes, ces tests sont plutôt simples à écrire car :

  • nous n'avons pas de dépendance externes.
  • notre fonction est une fonction pure (qui retournera donc toujours le même résultat lorsqu'on lui donnera les mêmes arguments)

Certains parlent même de générer ces tests unitaires tellement ils sont simples !

Et l'on écrit des tests pour nos thunks (ou sagas, ou epics) :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Test unitaire
decribe('addTodo', () => {
  it('creates TODO_CREATED when the api created the todo', () => {
    // Given
    const dispatch = jest.fn()
    const todo = {
      id: 'todo-1',
      isComplete: false,
      text: 'some-content'
    }
    mockMyApiSomehow.mockResolveValue(todo)

    // When
    const action = await addTodo('some content')(dispatch)

    // Then
    const expectedAction = todoCreated(action)
    expect(dispatch).toHaveBeenCalledWith(expectedAction)
  })
})

// Implémentation
const addTodo = text => dispatch => {
  return api.put('/todos', { text })
    .then(todo => dispatch(todoCreated(todo)))
}

Là ça se corse, on doit mocker des modules, faire des assertions techniques assez complexe mais c'est faisable !

En fait, ces tests respectent plusieurs propriétés de la liste Test Desiderata de Kent Beck - ils sont :

  • isolés (ne sont pas interdépendants)
  • composables (ils donneront les mêmes résultats s'ils sont lancés plusieurs fois)
  • rapides
  • automatisés

Mais malgré tout, même en atteignant 100% de coverage sur mes reducers, mes actions creators et autres middlewares, il m'est déjà arrivé qu'en lançant l'application, rien ne fonctionne.

Pourquoi ? Parce qu'il m'arrive :

  • d'oublier de brancher un reducer sur une action
  • d'oublier de brancher une saga ou une epic sur une action

Je pense que cette situation est symptomatique : nous testons un détail d'implémentation.

Une proposition

En y réfléchissant, je me suis dis qu'il devait y avoir d'autres façons de tester mon store. Je voulais que la façon dont mes tests soient écrits ressemble à la façon dont mon store est utilisé dans mon application, par mes components :

  • en dispatchant des actions dans le store (et non pas dans le reducer) pour modifier le state
  • en lisant le state en utilisant des selectors pour vérifier les données en sortie (et non pas la sortie des reducers)

Alors je me suis lancé et j'ai réécris mon test :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Test unitaire de ma fonctionnalité
describe('Adding a todo to the list', () => {
  it('creates a new todo', async () => {
    // Given
    const api = createApiTestImplementation()
    const store = createTestStore(api)
    const text = 'my todo content'

    // When
    store.dispatch(addTodo(text))
    await flushPromises()

    // Then
    expect(getAllTodos(store.getState()).toEqual([{
      id: expect.any(string),
      isComplete: false,
      text: 'my todo content'
    }])
  })
})

// La fonction flushPromises sert à attendre que l'ensemble
// des promesses soient résolues (https://github.com/testing-library/react-testing-library/issues/11) :
export function flushPromises() {
  return new Promise(resolve => setImmediate(resolve));
}

... et c'est tout ! En écrivant un test qui comporte moins de lignes :

  • les cas testés précédemment dans nos reducers sont testés
  • les selectors sont testés
  • les éventuels middlewares sont testés

Mais ce n'est pas tout. Cette façon d'écrire les tests offre bien plus d'avantages :

  • nos tests ressemblent exactement à leur utilisation dans l'application, ce qui augmente la confiance que nous donne notre test (je considère la propriété Inspiring qui est certes subjective maintenant respectée)
  • le monde extérieur n'a pas conscience de notre structure de reducers et de la structure de notre state
  • le mécanisme de communication avec l'API est inconnu du monde extérieur (on peut passer de redux-thunk, à redux-observable par exemple)

J'insiste sur ces derniers points : nous avons réussi à tester notre store en boite noire. En fait, notre test respecte maintenant la propriété Structure-insensitive. Ce qui signifie que nous pouvons refactorer tout notre modèle de données sans avoir à changer notre test !

N'est-ce pas plus confortable ?