Performances, apprentissages et limites

Mise en place de tests d'intégration sur une app React

Par

Joris Langlois

Date

25/03/2025

Conférence

01

Qui suis-je ?

04

Nouvelle approche

Exemple avec MSWjs

02

Introduction

Choix de l'architecture et du cas d'étude

05

Bénéfices et limites

03

Problématique

Limites d'une couverture 100% unitaire. Rapide, simple, mais pour quelle fiabilité ?

06

Conclusion

Mise en place de tests d'intégration sur une app React _ Sommaire

Et questions si vous en avez !

There is no silver bullet

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Qui suis-je ?

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Qui suis-je ?

En activité depuis 2013

Petit passage par les pays baltes entre

2020 et 2022 (outl1ne)

Chez KNP Labs depuis 2018

Développeur. 

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Qui suis-je ?

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Ecole d'ingénieur du Conservatoire National des Arts et Métiers (EiCNAM) depuis 2017

Certificat de spécialisation : Union Européenne

Auditeur. 

Ingéniérie des systèmes decisionnels

Introduction

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

... par un nouveau projet

un peu trop de certitudes

et un vague sentiment de malaise

Introduction _ Contexte

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Tout commença...

Introduction _ Une architecture réactive, qu'est ce que c'est ?

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Introduction _ Cas d'étude

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Cas 1 : Non authentifié

Cas 2 : En cours d'authentification

Introduction _ Cas d'étude

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Cas 3  : Erreur

Cas 4 : Authentification OK

Introduction _ Cas d'étude : vue

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// features/Authentication/index.tsx

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

  const onSubmit: React.FormEventHandler = e => {
    e.preventDefault()

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

  return <Form onSubmit={ onSubmit }>
	{/* ... */}
    <SubmitButton loading={ isAuthenticating }>
    	Se connecter
        <Icon name="arrow-right" />
    </SubmitButton>

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

Introduction _ Cas d'étude : vue

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// features/Authentication/index.tsx

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

  const onSubmit: React.FormEventHandler = e => {
    e.preventDefault()

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

  return <Form onSubmit={ onSubmit }>
	{/* ... */}
    <SubmitButton loading={ isAuthenticating }>
    	Se connecter
        <Icon name="arrow-right" />
    </SubmitButton>

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

Couplage fort avec le modèle

=> selecteurs

Introduction _ Cas d'étude : vue

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// features/Authentication/index.tsx

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

  const onSubmit: React.FormEventHandler = e => {
    e.preventDefault()

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

  return <Form onSubmit={ onSubmit }>
	{/* ... */}
    <SubmitButton loading={ isAuthenticating }>
    	Se connecter
        <Icon name="arrow-right" />
    </SubmitButton>

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

Couplage fort avec le modèle

=> actions

Couplage fort avec le modèle

=> selecteurs

Introduction _ Cas d'étude : modèle

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// features/Authentication/slice.ts

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

type State = {
  status: Status
}

export const initialState: State = ({
  status: Status.NeedsAuthentication,
})
  
export type AuthenticatePayload = {
  username: string,
  password: string,
}

export const selectIsAuthenticating = (state: State) => 
	state.auth.status === Status.Authenticating
  
export const selectIsAuthenticated = (state: State) => 
	state.auth.status === Status.Authenticated
// features/Authentication/slice.ts

const slice = createSlice({
  name: 'auth',
  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,
    },
  },
})

Introduction _ Cas d'étude : modèle

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// features/Authentication/slice.ts

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

type State = {
  status: Status
}

export const initialState: State = ({
  status: Status.NeedsAuthentication,
})
  
export type AuthenticatePayload = {
  username: string,
  password: string,
}

export const selectIsAuthenticating = (state: State) => 
	state.auth.status === Status.Authenticating
  
export const selectIsAuthenticated = (state: State) => 
	state.auth.status === Status.Authenticated
// features/Authentication/slice.ts

const slice = createSlice({
  name: 'auth',
  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,
    },
  },
})

Introduction _ Cas d'étude : modèle

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// features/Authentication/slice.ts

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

type State = {
  status: Status
}

export const initialState: State = ({
  status: Status.NeedsAuthentication,
})
  
export type AuthenticatePayload = {
  username: string,
  password: string,
}

export const selectIsAuthenticating = (state: State) => 
	state.auth.status === Status.Authenticating
  
export const selectIsAuthenticated = (state: State) => 
	state.auth.status === Status.Authenticated
// features/Authentication/slice.ts

const slice = createSlice({
  name: 'auth',
  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,
    },
  },
})

Introduction _ Cas d'étude : effets de bord

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// features/Authentication/effects.ts

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

function* authenticateWithCredentials({ payload }: { payload: AuthenticatePayload }): Generator {
  try {
    const post = yield getContext(Context.Post)
    const container = (yield call(post, '/auth-tokens', payload)) as LdapToken
    const storage = yield getContext(Context.Storage)
    yield call([ storage, 'setItem' ], 'token', container.token)
    yield put(slice.actions.successfullyRetrievedToken())
  } catch (e) {
    yield put(slice.actions.error())
  }
}

function* getAuthenticatedUser(): Generator {
  try {
    const get = yield getContext(Context.Get)
    const user = (yield call(get, '/me')) as User
    yield put(slice.actions.authenticated(user))
  } catch (e) {
    yield put(slice.actions.error())
  }
}

Introduction _ Cas d'étude : effets de bord

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// features/Authentication/effects.ts

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

function* authenticateWithCredentials({ payload }: { payload: AuthenticatePayload }): Generator {
  try {
    const post = yield getContext(Context.Post)
    const container = (yield call(post, '/auth-tokens', payload)) as LdapToken
    const storage = yield getContext(Context.Storage)
    yield call([ storage, 'setItem' ], 'token', container.token)
    yield put(slice.actions.successfullyRetrievedToken())
  } catch (e) {
    yield put(slice.actions.error())
  }
}

function* getAuthenticatedUser(): Generator {
  try {
    const get = yield getContext(Context.Get)
    const user = (yield call(get, '/me')) as User
    yield put(slice.actions.authenticated(user))
  } catch (e) {
    yield put(slice.actions.error())
  }
}

1. Action in

Introduction _ Cas d'étude : effets de bord

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// features/Authentication/effects.ts

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

function* authenticateWithCredentials({ payload }: { payload: AuthenticatePayload }): Generator {
  try {
    const post = yield getContext(Context.Post)
    const container = (yield call(post, '/auth-tokens', payload)) as LdapToken
    const storage = yield getContext(Context.Storage)
    yield call([ storage, 'setItem' ], 'token', container.token)
    yield put(slice.actions.successfullyRetrievedToken())
  } catch (e) {
    yield put(slice.actions.error())
  }
}

function* getAuthenticatedUser(): Generator {
  try {
    const get = yield getContext(Context.Get)
    const user = (yield call(get, '/me')) as User
    yield put(slice.actions.authenticated(user))
  } catch (e) {
    yield put(slice.actions.error())
  }
}

1. Action in

2. Effet

Introduction _ Cas d'étude : effets de bord

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// features/Authentication/effects.ts

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

function* authenticateWithCredentials({ payload }: { payload: AuthenticatePayload }): Generator {
  try {
    const post = yield getContext(Context.Post)
    const container = (yield call(post, '/auth-tokens', payload)) as LdapToken
    const storage = yield getContext(Context.Storage)
    yield call([ storage, 'setItem' ], 'token', container.token)
    yield put(slice.actions.successfullyRetrievedToken())
  } catch (e) {
    yield put(slice.actions.error())
  }
}

function* getAuthenticatedUser(): Generator {
  try {
    const get = yield getContext(Context.Get)
    const user = (yield call(get, '/me')) as User
    yield put(slice.actions.authenticated(user))
  } catch (e) {
    yield put(slice.actions.error())
  }
}

1. Action in

2. Effet

3. Action out !

Introduction _ Cas d'étude : effets de bord

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// features/Authentication/effects.ts

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

function* authenticateWithCredentials({ payload }: { payload: AuthenticatePayload }): Generator {
  try {
    const post = yield getContext(Context.Post)
    const container = (yield call(post, '/auth-tokens', payload)) as LdapToken
    const storage = yield getContext(Context.Storage)
    yield call([ storage, 'setItem' ], 'token', container.token)
    yield put(slice.actions.successfullyRetrievedToken())
  } catch (e) {
    yield put(slice.actions.error())
  }
}

function* getAuthenticatedUser(): Generator {
  try {
    const get = yield getContext(Context.Get)
    const user = (yield call(get, '/me')) as User
    yield put(slice.actions.authenticated(user))
  } catch (e) {
    yield put(slice.actions.error())
  }
}

4. Action in

Introduction _ Cas d'étude : effets de bord

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// features/Authentication/effects.ts

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

function* authenticateWithCredentials({ payload }: { payload: AuthenticatePayload }): Generator {
  try {
    const post = yield getContext(Context.Post)
    const container = (yield call(post, '/auth-tokens', payload)) as LdapToken
    const storage = yield getContext(Context.Storage)
    yield call([ storage, 'setItem' ], 'token', container.token)
    yield put(slice.actions.successfullyRetrievedToken())
  } catch (e) {
    yield put(slice.actions.error())
  }
}

function* getAuthenticatedUser(): Generator {
  try {
    const get = yield getContext(Context.Get)
    const user = (yield call(get, '/me')) as User
    yield put(slice.actions.authenticated(user))
  } catch (e) {
    yield put(slice.actions.error())
  }
}

4. Action in

5. Effet

Introduction _ Cas d'étude : effets de bord

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// features/Authentication/effects.ts

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

function* authenticateWithCredentials({ payload }: { payload: AuthenticatePayload }): Generator {
  try {
    const post = yield getContext(Context.Post)
    const container = (yield call(post, '/auth-tokens', payload)) as LdapToken
    const storage = yield getContext(Context.Storage)
    yield call([ storage, 'setItem' ], 'token', container.token)
    yield put(slice.actions.successfullyRetrievedToken())
  } catch (e) {
    yield put(slice.actions.error())
  }
}

function* getAuthenticatedUser(): Generator {
  try {
    const get = yield getContext(Context.Get)
    const user = (yield call(get, '/me')) as User
    yield put(slice.actions.authenticated(user))
  } catch (e) {
    yield put(slice.actions.error())
  }
}

4. Action in

5. Effet

6. Action out !

Introduction _ Cas d'étude : modèle

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// features/Authentication/slice.ts

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

type State = {
  status: Status
}

export const initialState: State = ({
  status: Status.NeedsAuthentication,
})
  
export type AuthenticatePayload = {
  username: string,
  password: string,
}

export const selectIsAuthenticating = (state: State) => 
	state.auth.status === Status.Authenticating
  
export const selectIsAuthenticated = (state: State) => 
	state.auth.status === Status.Authenticated
// features/Authentication/slice.ts

const slice = createSlice({
  name: 'auth',
  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,
    },
  },
})

Problématique

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Problématique _ Fonctions

Qu'il s'agisse de la vue :

  •  

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Du modèle :

  •  

Ou des effets de bord :

  •  
Firewall : Props \longrightarrow JSX.Element
authenticateWithCredentials : (State, Action) \longrightarrow State
getAuthenticatedUser* : Action \longrightarrow Action

Des fonctions, des fonctions, toujours des fonctions ! 

Et nos tests unitaires, dans tout ça ?

  • Modèle : application directe et triviale
  • Effets de bord : possible, à un certain coût
  • Vues : de quels composants parle-t-on ?

Quelques propriétés élémentaires que l'on peut exploiter à notre avantage :

  • Pureté
  • Composabilité

Dont découle logiquement une approche fonctionnelle des tests :

  • La robustesse du système est garantie par la composition de fonctions pures unitairement testées

Problématique _ Fonctions

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Tests de selecteurs (modèle)                           

  • Certains sélecteurs ne sont que des accesseurs
  • Chronophages, verbeux et répétitifs

Tests d'effets de bord (appels d'API, accès au presse papier, au local storage, ...)

  • Nécessitent de mocker le comportement des dépendances injectées
  • 90% des effets de bord ne gèrent "que" des appels d'API (succès / erreur)
  • Verbeux et répétitifs
  • Peu lisibles / maintenables (marble testing)

Problématique _ Limites de l'approche unitaire

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Tests de reducers (modèle)                         

  • Incontournables

Tests de composants d'interface

  • Réutilisables par définition
  • Gèrent leur propre état interne
  • Peu couplés aux autres couches
  • Composables

Problématique _ Limites de l'approche unitaire

Tests de composants montés par une route (features)

  • Spécifiques, peu réutilisables et composables
  • Fortement couplés aux autres couches
  • Impliquent un certain degré de simulation pour pouvoir mettre ces composants dans l'état souhaité pour pouvoir les tester

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Problématique _ Limites de l'approche unitaire : exemple avec React Testing Library

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// features/Authentication/Firewall.test.tsx

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'), 'joris.langlois@knplabs.com')
    await userEvent.type(screen.getByLabelText('Mot de passe'), 'notanychance')

    await userEvent.click(submit)
    expect(submit).toBeDisabled()
    
    act(() => {
      store.dispatch(slice.actions.authenticated({
        firstname: 'Joris',
        lastname: 'Langlois',
        company: 'KNP Labs',
        // ...
      }))
    })    
    expect(await screen.findByText(/app/)).toBeInTheDocument()
  })
})
// features/Authentication/Firewall.test.tsx

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'), 'joris.langlois@knplabs.com')
    await userEvent.type(screen.getByLabelText('Mot de passe'), 'notanychance')
  
    await userEvent.click(submit)
    expect(submit).toBeDisabled()
    
    act(() => {
      store.dispatch(slice.actions.authenticated({
        firstname: 'Joris',
        lastname: 'Langlois',
        company: 'KNP Labs',
        // ...
      }))
    })    
    expect(await screen.findByText(/app/)).toBeInTheDocument()
  })
})

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Problématique _ Limites de l'approche unitaire : exemple avec React Testing Library

Risques de faux positifs

  • Si on supprime / modifie l'effet responsable de diffuser les actions (authenticated ou error) dans l'application, les tests passent toujours, mais l'application est KO

Risques de faux négatifs

  • Si on arrête de diffuser ces actions dans les tests, ils sont KO alors que l'application va très bien...

Problématique _ Bilan

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Un bilan mi-figue mi-raisin

  • Les composants fonctionnent tous en isolation mais...
  • Les conditions d'exécution sont éloignées de la réalité
  • On teste l'implémentation plutôt que le comportement
  • Tests chronophages, répétitifs et verbeux
  • Pour un gain de confiance faible

Nouvelle approche

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

It's all about getting a good return on your investment where "return" is confidence and "investment" is time.

Kent C. Dodds - 2021

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Nouvelle approche _ Quelle stratégie ?

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Nouvelle approche _ Quelle stratégie ?

Kent C. Dodds. Write tests. Not too many. Mostly integration. 2019.

  • https://kentcdodds.com/blog/write-tests

Principe

  • Plutôt que de tout tester de la même manière et de façon peu efficace, on déplace l'effort de test pour le centrer sur le comportement de l'utilisateur

Scénario nominal

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Nouvelle approche _ Exemple avec MSWjs

// features/Authentication/Firewall.test.tsx

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'), 'joris.langlois@knplabs.com')
    await userEvent.type(screen.getByLabelText('Mot de passe'), 'notanychance')
    await userEvent.click(submit)

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

MSW (Mock Service Worker), par Artem Zakharchenko et 150+ contributeurs

  • Interception bas niveau des appels réseaux
  • Agnostique du client
  • Tous les composants, de l'envoi de la requête à sa résolution, sont exécutés

Nouvelle approche _ Exemple avec MSWjs

// setupTests.ts

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

const server = setupServer(...handlers)

beforeAll(() => {
  server.listen()
})

afterEach(() => server.resetHandlers())

afterAll(() => server.close())

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// test/mocks/handlers.ts

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

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

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

Scénario d'échec

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Nouvelle approche _ Exemple avec MSWjs

// features/Authentication/Firewall.test.tsx

describe('features/Me/Authentication', () => {
  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'), 'joris.langlois@knplabs.com')
    await userEvent.type(screen.getByLabelText('Mot de passe'), 'notanychance')
    
    await userEvent.click(submit)
    await waitFor(() => expect(submit).toBeDisabled())
    await waitFor(() => expect(submit).toBeEnabled())

    expect(screen.getByText(/Une erreur s'est produite/)).toBeInTheDocument()
  })
})

Bénéfices et limites

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Les sélecteurs de type accesseurs sont testés indirectement

  • Plus besoin de les tester unitairement

La plupart des effets de bord sont aussi testés indirectement à partir du moment où l'interface change en réaction à une action système

  • Plus besoin de les tester unitairement ni même de les exporter (= simplification)

Plus besoin de diffuser des actions "à la main" dans les tests de composant pour simuler un comportement

  • Tests plus lisibles et plus simples à maintenir

Baisse du risque de faux positifs / négatifs

  • Davantage de confiance dans ce qu'on livre
  • Diminution du risque de régression

Bénéfices et limites _ Bénéfices

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Risque de tests un peu instables lors de certains enchaînements

Prise en main : tout est asynchrone !

  • Résolution des réponses de MSW potentiellement instantanées
  • Besoin d'attendre que les éléments de l'interface soient tous là pour les assertions et passer au test suivant

Plus lents que des tests unitaires

  • (100 suites / 500 tests ~ 50s)

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Bénéfices et limites _ Limites

Bénéfices et limites _ Réflexions complémentaires

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

// apps/frontend/test/fixtures/index.ts

export const users = [
  {
    id: 1,
    firstname: 'John',
    lastname: 'Doe',
    profilePicturePath: 'cat.png',
    locale: 'fr',
    role: 'admin',
  },
]
// apps/backend/doc/schemas/user.json

{
    "$schema": "http://json-schema.org/draft-04/schema#",
    "id": "http://cacom.fr/user.json",
    "type": "object",
    "required": [ "id", "firstname", "lastname", "locale" ],
    "additionalProperties": false,
    "properties": {
        "id": {
            "type": "integer"
        },
        "firstname": {
            "type": "string"
        },
        "lastname": {
            "type": "string"
        },
        "profilePicturePath": {
            "type": "string"
        },
        "locale": {
            "type": "string"
        },
        "role": {
            "$ref": "defs/user-role.json"
        }
    }
}

Si les jeux de test (fixtures) sont :

  • testés unitairement en utilisant les JSON schémas du backend...
  • ...tout comme les données que l'on envoie à l'API

Les tests E2E sont-ils utiles ?

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Quel coverage ?

Bénéfices et limites _ Réflexions complémentaires

(c) Google 2024

Adam Bender. SMURF: Beyond the Test Pyramid. 2024.

  • https://testing.googleblog.com/2024/10/smurf-beyond-test-pyramid.html

Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025

Merci !

PTC - 25 mars 2025

By KNP Labs

PTC - 25 mars 2025

  • 5