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
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
Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025
En activité depuis 2013
Petit passage par les pays baltes entre
2020 et 2022 (outl1ne)
Chez KNP Labs depuis 2018
Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025
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
Ingéniérie des systèmes decisionnels
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
Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025
Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025
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
Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025
Cas 3 : Erreur
Cas 4 : Authentification OK
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>
}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
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
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,
},
},
})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,
},
},
})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,
},
},
})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())
}
}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
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
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 !
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
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
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 !
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,
},
},
})Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025
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 :
Des fonctions, des fonctions, toujours des fonctions !
Et nos tests unitaires, dans tout ça ?
Quelques propriétés élémentaires que l'on peut exploiter à notre avantage :
Dont découle logiquement une approche fonctionnelle des tests :
Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025
Tests de selecteurs (modèle)
Tests d'effets de bord (appels d'API, accès au presse papier, au local storage, ...)
Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025
Tests de reducers (modèle)
Tests de composants d'interface
Tests de composants montés par une route (features)
Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025
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
Risques de faux positifs
Risques de faux négatifs
Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025
Un bilan mi-figue mi-raisin
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
Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025
Kent C. Dodds. Write tests. Not too many. Mostly integration. 2019.
Principe
Scénario nominal
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()
expect(await screen.findByText(/app/)).toBeInTheDocument()
})
})MSW (Mock Service Worker), par Artem Zakharchenko et 150+ contributeurs
// 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
// 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()
})
})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
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 diffuser des actions "à la main" dans les tests de composant pour simuler un comportement
Baisse du risque de faux positifs / négatifs
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 !
Plus lents que des tests unitaires
Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025
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 :
Les tests E2E sont-ils utiles ?
Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025
Quel coverage ?
(c) Google 2024
Adam Bender. SMURF: Beyond the Test Pyramid. 2024.
Mise en place de tests d'intégration sur une app React - PTC 25 mars 2025