Des tests unitaires aux tests d’intégration : les changements apportés par MSW à nos stratégies de tests

Publié le

20 sept. 2024

Introduction

Certains des projets frontend confiés à KNP sont des applications métier qui nécessitent une interface riche et réactive. Le développement d’une SPA se prête généralement bien à ce type de besoin, et ce malgré une complexité qui peut s’avérer élevée, avec la répercussion sur les coûts qu’elle implique.

Du point de vue de l’organisation de la codebase, on retrouve couramment les trois couches suivantes : une couche de vue (composants), qui gère la logique de présentation de l’information ; une couche d’effets (side effects), qui permet d’isoler les interactions de l’application avec le monde extérieur ; une couche d’état (global state), principalement utilisée pour faire la liaison entre les deux premières couches. Cette dernière peut aussi éventuellement servir à stocker les données qui ne peuvent être gérées dans l’état interne des vues.

Un tel découpage offre l’avantage d’être parfaitement agnostique des technologies utilisées pour l’implémentation des différentes couches. A quelques variations syntaxiques près, il est donc relativement simple de passer d’un projet qui utilise cette architecture à un autre. La séparation de responsabilité augmente par ailleurs la testabilité de l’application de façon significative.

La réalisation d’une fonctionnalité métier, pour un utilisateur, implique généralement la sollicitation d’au moins un composant (classe, fonction, etc) de chaque couche. Leur bon fonctionnement est garanti, au minimum, par des tests unitaires. Pour autant, ces tests s'exécutant par définition en isolation, le niveau de confiance qu’ils apportent peut s'avérer insuffisant, dès lors que l’on souhaite également garantir les interactions des composants des différentes couches les uns avec les autres. Pourtant, ces interactions se montrent le plus souvent bien plus représentatives des conditions réelles d’exécution de l’application, et, par voie de conséquence, de l'expérience qui en est faite par l’utilisateur.

Cet article entend illustrer ce problème au travers d’un cas pratique. Il présente la solution généralement retenue sur les projets les plus récents développés chez KNP, ainsi que ses limites.

Prérequis

Les concepts de base de l’écosystème React / Redux / React Testing Library sont supposées acquis pour la lecture de cet article.

Cas d’étude

Nous présenterons ici le cas simple d’un formulaire de connexion, dont les spécifications fonctionnelles sont les suivantes : Un utilisateur s’authentifie en saisissant son email et son mot de passe ; lorsque l’authentification est en cours, le bouton d’envoi doit être désactivé ; en cas de réussite de l’authentification, l’application s’affiche ; dans le cas contraire, un message d’erreur s’affiche. La maquette suivante présente le formulaire tel qu’il pourrait être intégré :

unnamed.png

Le rendu du formulaire est fait par un composant fonctionnel React, qui utilise différents hooks pour interagir avec le store de l’application. Le formulaire est une agrégation de composants stylisés (styled components) dont le détail n’est pas donné ici par souci de concision.

const Firewall: React.FC = () => {
  const dispatch = useDispatch()
  const isAuthenticating = useSelector(selectIsAuthenticating)
  const isAuthenticated = useSelector(selectIsAuthenticated)
  const hasError = useSelector(selectHasError)
  
  if (isAuthenticated) {
    return <>{ children }</>
  }

  const onSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
    e.preventDefault()

    dispatch(slice.actions.authenticateWithCredentials({
      username: e.currentTarget.username?.value || '',
      password: e.currentTarget.password?.value || '',
    }))
  }

  return <Form onSubmit={ onSubmit }>
    <Label htmlFor="username">Email :</Label>
    <Input
      type="email"
      autoComplete="username"
      id="username"
      name="username"
      icon="fa fa-user"
      required
    />
    <Label htmlFor="password">Mot de passe :</Label>
    <Input
      type="password"
      autoComplete="current-password"
      id="password"
      name="password"
      icon="fa fa-lock"
      required
    />
    <SubmitButton loading={ isAuthenticating } $backgroundColor="blue" $iconLayout="right">
      Se connecter
      <i className="fa fa-arrow-right" />
    </SubmitButton>

    { hasError &&
      <Message type={ MessageType.ERROR } content="Une erreur s'est produite..." />
    }
  </Form>
}

Les différents appels à l'API nécessaires à l'authentification sont isolés grâce à un middleware. Dans cet exemple, nous utilisons l’implémentation proposée par redux-saga.

export function* authenticateWithCredentials({ payload }: { payload: AuthenticatePayload }): Generator {
  try {
    const post = (yield getContext(Context.Post)) as ApiPost
    const container = (yield call(post, '/auth-tokens', payload)) as LdapToken

    const storage = (yield getContext(Context.Storage)) as Storage
    yield call([ storage, 'setItem' ], 'token', container.token)

    yield put(slice.actions.successfullyRetrievedToken())
  } catch (e) {
    error(e)
    yield put(slice.actions.error())
  }
}

export function* getAuthenticatedUser(): Generator {
  try {
    const get = (yield getContext(Context.Get)) as ApiGet
    const user = yield call(get, '/me')

    yield put(slice.actions.authenticated(user))
  } catch (e) {
    error(e)
    yield put(slice.actions.error())
  }
}

export default function* rootSaga(): Generator {
  yield takeLeading(slice.actions.authenticateWithCredentials, authenticateWithCredentials)
  yield takeEvery(slice.actions.successfullyRetrievedToken, getAuthenticatedUser)
}

Remarquons que l’authentification n’est pas directe. Un premier appel à l’API est fait avec les informations d’authentification de l’utilisateur (email et mot de passe) afin de récupérer un token. Ce token est stocké dans le localstorage afin de pouvoir ré-authentifier automatiquement l'utilisateur sans qu’il ait besoin de saisir une nouvelle fois ses identifiants. Cette partie n’est pas couverte dans l’article. Un deuxième appel à l'API permet de récupérer un objet métier [User] qui contient les données de l’utilisateur: nom, prénom, etc. L’utilisateur est considéré comme authentifié si et seulement si l’enchaînement de ces deux actions se déroule sans erreur. Ce point est important pour la suite.

Enfin, nous utilisons redux afin de gérer le cycle de vie du formulaire et ses différents états (non authentifié / en cours d'authentification / authentifié / en erreur), en fonction du résultat que nous renvoie l’API.

export enum AuthStatus {
  NeedsAuthentication = 'NeedsAuthentication',
  Authenticating = 'Authenticating',
  Authenticated = 'Authenticated',
  UnknownError = 'UnknownError',
}

type AuthenticationState = {
  status: AuthStatus
}

export const initialState: AuthenticationState = ({
  status: AuthStatus.NeedsAuthentication,
})

export interface AuthenticatePayload {
  username: string
  password: string
}

const slice = createSlice({
  name: 'me/authentication',
  initialState,
  reducers: {
    authenticateWithCredentials: (state, _action: PayloadAction<AuthenticatePayload>) => ({
      ...state,
      status: AuthStatus.Authenticating,
    }),
    
    successfullyRetrievedToken: state => state,
    
    authenticated: (state, _action: PayloadAction<User>) => ({
      ...state,
      status: AuthStatus.Authenticated,
    }),
    
    error: state => ({
      ...state,
      status: AuthStatus.UnknownError,
    }),
  },
})

export const selectIsAuthenticating: Selector<boolean> = state =>
  state.authentication.status === AuthStatus.Authenticating

export const selectIsAuthenticated: Selector<boolean> = state =>
  state.authentication.status === AuthStatus.Authenticated

export const selectHasError: Selector<boolean> = state =>
  state.authentication.status === AuthStatus.UnknownError

export default slice

Approche unitaire

Comme souligné en introduction, la séparation des responsabilités de ces trois composants facilite grandement leur couverture par des tests unitaires. Un projet dont la stratégie de test repose exclusivement sur ce type de tests vérifiera donc les affirmations suivantes.

Pour la partie redux [slice.ts], il existe un fichier de test couvrant l’ensemble des reducers et des sélecteurs. Cette partie est triviale et son implémentation immédiate. Pour la partie sagas [effects.ts], il existe au moins un test par scénario d’exécution de chaque saga (cas nominal et cas d’erreur), soit un total de quatre tests si on considère les deux sagas qui interviennent dans notre cas d’étude. A noter que les tests unitaires de sagas qui permettent de véritablement vérifier leur comportement et non pas leur implémentation représentent un certain challenge. Ils ont fait l’objet d’un article publié par Phil Herbert (ThoughtWorks, 2018) qui est toujours d’actualité, et ne seront donc pas détaillés ici.

Pour la partie présentation [Firewall.tsx], on trouverait vraisemblablement une implémentation proche de celle exposée ci-après, que l’on se propose de commenter.

describe('features/Me/Authentication', () => {
  test('authenticates', async () => {
    const { userEvent } = setup(<Firewall>app</Firewall>)
    const submit = screen.getByRole('button', { name: 'Se connecter' })
    expect(submit).toBeEnabled()
    
    await userEvent.type(screen.getByLabelText('Email'), 'hibous.forestis@knplabs.forest')
    await userEvent.type(screen.getByLabelText('Mot de passe'), 'owl')

    await userEvent.click(submit)
    expect(submit).toBeDisabled()
    
    act(() => {
      store.dispatch(slice.actions.authenticated({
        firstname: 'Hibous',
        lastname: 'Forestis',
        company: 'KNP Labs',
        // ...
      }))
    })    
    expect(await screen.findByText(/app/)).toBeInTheDocument()
  })

  test('cannot authenticate', async () => {
    const { userEvent } = setup(<Firewall>app</Firewall>)
    const submit = screen.getByRole('button', { name: 'Se connecter' })
    expect(submit).toBeEnabled()

    await userEvent.type(screen.getByLabelText('Email'), 'hibous.forestis@knplabs.forest')
    await userEvent.type(screen.getByLabelText('Mot de passe'), 'owl')

    await userEvent.click(submit)
    expect(submit).toBeDisabled()

    act(() => {
      store.dispatch(slice.actions.error())
    })   
    expect(screen.getByText(trans('error'))).toBeInTheDocument()
  })
})

Ce fichier contient une seule suite de deux tests unitaires. Le rendu du composant sous test est effectué par un utilitaire [setup], qui permet de décorer ce composant avec les providers nécessaires à son bon fonctionnement, notamment un store de test. Ce store diffère de celui qui est utilisé par l’application réelle car il n’intègre pas le middleware redux-saga. Dit autrement, dans cette configuration, les actions redux ne sont écoutées par aucune saga, et les appels d’API resteront sans réponse. Ce n’est pas inhabituel lorsque la stratégie de test, y compris pour les composants de présentation, repose uniquement sur des tests unitaires. Avec cette stratégie en effet, seule est vérifiée l’adéquation du rendu du composant en fonction des événements d’origines humaines (clic sur un bouton, action avec le clavier, etc) ou systèmes (réponse d’API par exemple) provoqués lors du test.

L’utilitaire [setup] renvoie un objet de type [UserEvent], qui permet de simuler les interactions d’un utilisateur au plus proche de ce qui se passerait dans un navigateur. Tout ceci fait partie des bonnes pratiques recommandées par les développeurs de React Testing Library, lesquelles peuvent être retrouvées dans leur documentation officielle.

Les actions systèmes [authenticated] et [error], normalement émises par les sagas, sont déclenchées manuellement à partir du store de test. C’est le seul moyen, dans l’environnement de test choisi, de mettre le composant testé dans l'état spécifique dans lequel il se trouverait après la réussite de l'authentification ou son échec. S’il existe des tests de sagas garantissant l’émission de ces actions à partir de l’action d’entrée [authenticateWithCredentials] émise à la soumission du formulaire par l’utilisateur, on peut considérer que l’ensemble des spécifications fonctionnelles du formulaire sont couvertes.

Problématique

La confiance apportée par les tests unitaires d’un composant s’arrête là où commencent les interactions avec d’autres composants. A cet égard, l’émission manuelle d’actions systèmes, telle que pratiquée dans l'exemple précédent, pose deux problèmes majeurs.

Tout d'abord, à supposer que l’implémentation des sagas change, et que, pour une raison quelconque, celles-ci ne soient pas / plus couvertes par des tests, rien n’empêche que l’application soit HS sans pour autant que les tests décrits précédemment échouent. Ces tests produisent, dans cette situation, des résultats faux positifs. Corollaire immédiat, si ces tests sont modifiés de sorte que les actions [authenticated] et [error] ne soient plus émises, les deux tests échouent alors que l’application, elle, fonctionne toujours. On parle dans ce cas de tests produisant des faux négatifs.

On constate un différentiel important entre la réalité testée et la réalité d’exécution de l’application. Dès lors, le retour sur investissement des tests, en termes de confiance dans ce qui est livré, baisse.

En dehors des composants d’interface réutilisables et qui fonctionnent en pure isolation (champ de formulaire, par exemple) les composants de présentation ne remplissent que rarement les conditions leur permettant d’être réellement testés unitairement. A partir du moment ou un composant d’interface est couplé avec un store, lui-même potentiellement connecté à un ou plusieurs middlewares, des efforts supplémentaires dans la mise en place de l'environnement de test sont nécessaires, pour un gain somme toute relatif en termes de confiance ajoutée.

Assurer la couverture des composants d’interface à l'aide de tests d’intégration s’avère bien plus efficace et permet, en outre, de faire l’économie d’un certain nombre de tests unitaires couvrant les composants d’autres couches de l’application.

Approche en intégration

MSW est un outil permettant de mocker les réponses d’une API tout en restant agnostique du client la consommant. L’interception des requêtes se passe au niveau le plus bas. Tous les composants sollicités entre le moment où la requête est envoyée (par exemple en utilisant l’API Fetch de javascript) et le moment où la réponse est reçue (par exemple dans une saga) sont donc exécutés. Nous avons fait le choix de suivre les recommandations de MSW pour sa mise en place. D’abord, le serveur doit être déclaré comme suit :

import { setupServer } from 'msw/node'
import { handlers } from 'test/mocks/handlers'

export const server = setupServer(...handlers)

Ce serveur doit être exécuté avant le démarrage de toute suite de test. Avec Jest, que nous utilisons ici comme framework de test, l’implémentation est la suivante :

import { server } from 'test/mocks/server'

beforeAll(() => {
  server.listen({
    onUnhandledRequest(req) {
      console.log('%s request to %s unhandled by tests', req.method, req.url)
    },
  })
})

Enfin, des handlers doivent être déclarés pour chaque point d’API appelé dans l’ensemble des tests de l’application. Nous faisons le choix de regrouper ces handlers dans un seul et même fichier, mais il est bien entendu possible de séparer en plusieurs fichiers, voire même de déclarer les handlers nécessaires au cas par cas, dans chaque fichier de test.

import { HttpResponse, http } from 'msw'
import { users } from 'test/fixtures'

export const ok = (body:any = null) => new HttpResponse(JSON.stringify(body), {
  status: 200,
  headers: { 'Content-Type': ContentType.Json },
})

export const handlers = [
  http.post('/auth-tokens', () => ok({ token: 'my-token' })),
  http.get('/me', () => ok(users[0])),
]

Une fois ce nouvel environnement mis en place, les tests du composant de présentation du formulaire peuvent être modifiés de la façon suivante :

describe('features/Me/Authentication', () => {
  test('authenticates', async () => {
    const { userEvent } = setup(<Firewall>app</Firewall>)
    const submit = screen.getByRole('button', { name: 'Se connecter' })
    expect(submit).toBeEnabled()
    
    await userEvent.type(screen.getByLabelText('Email', 'hibous.forestins@knp.forest')
    await userEvent.type(screen.getByLabelText('Mot de passe', 'owl')
    await userEvent.click(submit)

    expect(submit).toBeDisabled()
    expect(await screen.findByText(/app/)).toBeInTheDocument()
  })

  test('cannot authenticate', async () => {
    const { userEvent } = setup(<Firewall />)
    const submit = screen.getByRole('button', { name: 'Se connecter' })
    
    server.use(
      http.get('/me', async () => HttpResponse.error())
    )

    await userEvent.type(screen.getByLabelText('Email', 'hibous.forestins@knp.forest')
    await userEvent.type(screen.getByLabelText('Mot de passe', 'owl')
    
    await userEvent.click(submit)
    await waitFor(() => expect(submit).toBeDisabled())
    await waitFor(() => expect(submit).toBeEnabled())

    expect(screen.getByText(trans('error'))).toBeInTheDocument()
  })
})

Avec cette approche, les spécifications fonctionnelles restent parfaitement couvertes. Il existe pourtant des différences importantes avec l’approche unitaire. Tout d’abord, le composant sous test étant désormais testé en intégration avec les composants des autres couches, il se rapproche davantage de celui utilisé dans l’application réelle, puisqu’il n’y a plus besoin de le décorer avec un store différent. La seule différence est que l’application réelle interagit avec une vraie API, alors qu’ici les appels sont interceptés par MSW.

Première conséquence, les interactions directes avec le store ont disparu des tests. La chaîne d’actions [authenticateWithCredentials] -> [authenticated | error] s’exécute en condition réelle et n’apparaît plus du tout dans le corps du test : on a fait disparaître du test des détails d’implémentation, potentiellement source de faux positifs / négatifs, pour se concentrer sur du pur fonctionnel.

Deuxième conséquence, qui découle de la première : les sagas qui interviennent dans la réalisation de la fonctionnalité d’authentification se trouvent entièrement couvertes par les tests d’intégration de la couche de présentation. Un test unitaire pourrait néanmoins rester justifié pour s’assurer de l'interaction de la saga [authenticateWithCredentials] avec le localstorage. Mais dans la plupart des cas, il est possible de s’en passer. Il en est de même pour les tests de sélecteurs: ceux-ci sont désormais redondants, dans la mesure où les tests de la couche de présentation couvrent déjà tous les états possibles que peuvent prendre le statut de l’authentification.

C’est la raison pour laquelle, pour la grande majorité des fonctionnalités testées avec cet environnement, on peut se permettre d’alléger fortement la part de tests unitaires sans pour autant perdre en couverture. Les sélecteurs qui ne sont que des accesseurs a une portion du state, ainsi que les sagas qui ne font qu’interagir avec une API sont des bons exemples de tests verbeux, redondants et qui n'apportent que peu de confiance. On pourrait faire la même remarque au sujet des reducers : ne sont-ils pas, eux aussi, directement testés en intégration avec la couche de présentation ? Ils le sont, mais nous faisons néanmoins le choix de garder un test unitaire pour chaque reducer. Le store global de l’application étant potentiellement partagé avec d’autres composants, il n'y a aucune garantie que la réussite des tests d’un seul composant qui l’utilise soit garant de l’ensemble. La même logique s’applique aux sélecteurs complexes et réutilisables.

Limites

Du fait de leur interaction avec les autres couches de l’application, dont certaines nécessitent potentiellement des appels asynchrones, les tests d’intégration s’exécutent lentement par rapport à des tests unitaires. Pour les applications qui dénombrent plusieurs centaines de cas d’utilisation, le temps d’exécution dans un environnement d’intégration continue (CI) et sa part dans le budget du projet peut augmenter de façon significative. L’asynchronicité des tests peut également être source d’une certaine confusion lors d’une première expérience. Une vigilance accrue doit être accordée au fait d’attendre correctement que les différents éléments de l’interface soient bien dans l’état désiré tout au long de chaque test, sans quoi des incohérences peuvent apparaître et des résultats aléatoires, qui différent d’un environnement à un autre, où d’une exécution à une autre, peuvent être observés.

Conclusion

Malgré ces limites, la stratégie de test développée dans cet article a été adoptée sur plusieurs projets réalisés par KNP, à des échelles différentes. Elle s’est montrée efficace et maintenable, sur des projets qui impliquent toujours au moins deux développeurs et des cycles de vie relativement longs (en années).

Publié par

Joris Langlois
Joris Langlois

Joris found his passion for front- and back-end development at KNP. His gentle and at the same time reassuring way, helps our customers to turn an idea into a great web application.

Commentaires