Tests unitaires et d’intégration
De manière générale, en ce qui concerne les tests des composants dans O3, il existe trois catégories de tests:
- Tests unitaires - vérifient que des éléments individuels et isolés de fonctionnalité fonctionnent comme prévu.
- Tests d’intégration - vérifient que plusieurs unités fonctionnent ensemble en harmonie.
- Tests de bout en bout (e2e) - une automatisation de navigateur qui se comporte comme un utilisateur pour cliquer dans l’application et vérifier qu’elle fonctionne correctement.
Lors du travail avec un module frontend, ses tests unitaires (et parfois d’intégration) accompagnants seront généralement colocalisés dans un fichier nommé *.test.tsx. Ces tests suivent généralement le modèle suivant:
- Les tests d’intégration rendent généralement l’application complète tout en simulant quelques éléments comme les requêtes réseau ou l’accès à la base de données.
- Les tests unitaires testent généralement des fonctions pures et vérifient qu’elles renvoient une sortie donnée lorsqu’elles reçoivent une entrée.
- Les tests e2e exécutent généralement l’application entière (frontend et backend) et votre test interagira avec l’application comme le ferait un utilisateur typique.
Philosophie de test: Plus vos tests ressemblent à la façon dont votre logiciel est utilisé, plus ils peuvent vous donner confiance.
État des test runners
Les modules frontend React actuels utilisent Vitest par défaut. Certains dépôts plus anciens utilisent encore Jest, donc vérifiez le package.json du module avant de copier des commandes ou une configuration de mocks depuis un autre dépôt.
Pourquoi le projet évolue vers Vitest :
- Exécutions plus rapides des tests et meilleur mode watch.
- Meilleure compatibilité avec ESM et l’écosystème Vite.
- Expérience développeur plus fluide à mesure que le nombre de modules frontend augmente.
Exécuter les tests et fichiers de setup
Les commandes de test varient selon le module, donc vérifiez le package.json du module pour connaître les scripts exacts. Les modèles courants incluent yarn test, yarn test:watch et yarn turbo test dans les dépôts qui utilisent Turbo.
Les fichiers de setup des modules Vitest actuels s’appellent généralement setup-tests.ts et sont déclarés via test.setupFiles dans vitest.config.ts. Les anciens modules Jest peuvent encore utiliser setupTests.ts. Si vous devez ajouter des mocks globaux ou des utilitaires de test, utilisez le fichier de setup et la convention de nommage déjà présents dans ce dépôt.
Exemple: Tester un module frontend
Nous allons parcourir un exemple de suite de tests pour comprendre l’approche adoptée. Dans ce cas, nous utiliserons le module frontend module-management . C’est un module plus ancien basé sur Jest, donc la syntaxe utilise jest; les modules Vitest actuels suivent la même forme de test, mais utilisent vi pour les mocks. Cette application prend la page Manage Module de l’application de référence OpenMRS 2.x et la repense pour fonctionner dans O3. Elle fournit une interface permettant aux utilisateurs de gérer les modules backend. Elle liste tous les modules installés et permet aux administrateurs de contrôler l’exécution des modules via les actions exposées Start, Stop et Unload. Les utilisateurs peuvent également consulter des informations détaillées sur les modules listés.
Examinons comment nous pourrions tester le composant ModuleManagement. En regardant son code, nous pouvons voir qu’il fait plusieurs choses:
- Il récupère les données des modules depuis le backend en utilisant un hook SWR personnalisé.
- Lorsque les données sont en cours de chargement, un état de chargement est affiché.
- Lorsque les données sont chargées mais vides, un état vide est affiché.
- Lorsqu’un problème survient lors de la récupération des données, un état d’erreur est affiché.
- Lorsque les données sont récupérées, un datatable Carbon contenant des informations sur les modules existants est rendu.
- Si l’utilisateur qui consulte la liste des modules a des privilèges suffisants, des boutons d’action sont affichés sur la page.
Nous pourrions écrire une suite de tests qui teste chacun de ces chemins.
Écrire le test
Créez un nouveau fichier à côté de module-management.component.tsx nommé module-management.test.tsx.
Configurez une fonction qui rend le composant:
function renderModuleManagement() {
renderWithSwr(<ModuleManagement />);
}Note: Cette fonction d’aide renderWithSwr enveloppe un composant dans un contexte SWR qui fournit une configuration globale pour tous les hooks SWR.
Tester qu’un état de chargement est rendu
Nous ne voulons pas directement atteindre la base de données avec notre test et pour éviter cela, nous pourrions simuler la logique de récupération des données comme suit:
mockedOpenmrsFetch.mockResolvedValueOnce({ data: { results: [] } });Ce code simule la fonction openmrsFetch, en remplaçant son implémentation par une promesse qui se résout avec un objet dont la propriété results est un tableau vide.
Ensuite, nous voulons rendre le composant:
renderModuleManagement();
await waitForLoadingToFinish();Ce code demande au test runner d’attendre jusqu’à ce que le chargeur soit retiré de l’interface utilisateur. Cela imite l’attente de la résolution d’une requête backend. Ensuite, nous voulons commencer à écrire nos assertions:
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
expect(screen.getByText(/there are no modules to display/i)).toBeInTheDocument();Ces assertions:
- Affirment qu’un élément DOM avec le rôle
progressbarne devrait pas être dans le document. - Affirment que le DOM ne contient pas d’élément avec le rôle
table. - Affirment que le texte
There are no modules to displayest rendu dans le document.
À tout moment, vous pouvez exécuter des tests en appelant yarn test ou yarn turbo test (si turbo est configuré).
Pour évaluer l’état d’erreur, vous pourriez écrire:
it("renders an error state view if there was a problem fetching module data", async () => {
const error = {
message: "Internal Server Error",
response: {
status: 500,
statusText: "Internal Server Error",
},
};
// Équivaut à renvoyer une promesse rejetée depuis openmrsFetch
mockedOpenmrsFetch.mockRejectedValueOnce(error);
renderModuleManagement();
// Wait for the loading state to disappear from the screen
await waitForLoadingToFinish();
expect(screen.getByText(/sorry, there was a problem fetching modules/i)).toBeInTheDocument();
});Pour tester le chemin heureux, nous pourrions écrire le test suivant:
it("renders detailed information about the module", async () => {
const user = userEvent.setup();
const testModules = [
{
uuid: "initializer",
display: "Initializer",
name: "Initializer",
description:
"Le module OpenMRS Initializer est un module uniquement API qui traite le contenu du dossier de configuration lorsqu'il est trouvé dans le répertoire de données d'application d'OpenMRS.",
packageName: "org.openmrs.module.initializer",
author: "Mekom Solutions",
version: "2.3.0",
started: true,
startupErrorMessage: null,
requireOpenmrsVersion: "2.1.1",
awareOfModules: [
"org.openmrs.module.metadatamapping",
"org.bahmni.module.bahmni.ie.apps",
"org.openmrs.module.datafilter",
"org.openmrs.module.htmlformentry",
"org.bahmni.module.appointments",
"org.openmrs.module.metadatasharing",
"org.openmrs.module.openconceptlab",
"org.bahmni.module.bahmnicore",
"org.openmrs.module.idgen",
"org.openmrs.module.legacyui",
],
requiredModules: [],
resourceVersion: "1.8",
},
{
uuid: "serialization.xstream",
display: "Serialization Xstream",
name: "Serialization Xstream",
description: "API et services de (dé)sérialisation principaux pris en charge par la bibliothèque xstream",
packageName: "org.openmrs.module.serialization.xstream",
author: "luzhuangwei",
version: "0.2.15",
started: true,
startupErrorMessage: null,
requireOpenmrsVersion: "1.9.9",
awareOfModules: [],
requiredModules: [],
resourceVersion: "1.8",
},
];
// Return the `testModules` object defined above in the API response. The nested `data` property below corresponds to the `data` property returned from calling the `useSWR` hook
mockedOpenmrsFetch.mockResolvedValueOnce({
data: { results: testModules },
});
renderModuleManagement();
// Wait for the loading state to disappear from the screen
await waitForLoadingToFinish();
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /add \/ upgrade modules/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /search from addons/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /check for updates/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /start all/i })).toBeInTheDocument();
expect(screen.getByRole("table")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /manage modules/i })).toBeInTheDocument();
expect(screen.getByRole("searchbox", { name: /filter table/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /clear search input/i })).toBeInTheDocument();
const modules = testModules.map((module) => module);
modules.forEach((module) => {
expect(screen.getByText(module.name)).toBeInTheDocument();
expect(screen.getByText(module.description)).toBeInTheDocument();
});
const expectedColumnHeaders = [/status/, /name/, /author/, /version/, /description/];
expectedColumnHeaders.forEach((row) => {
expect(screen.getByRole("columnheader", { name: new RegExp(row, "i") })).toBeInTheDocument();
});
const searchbox = screen.getByRole("searchbox", { name: /filter table/i });
// search for the Serializer module
await user.type(searchbox, "Seri");
expect(screen.getByText(/Serialization Xstream/i)).toBeInTheDocument();
expect(screen.queryByText(/Initializer/i)).not.toBeInTheDocument();
await user.clear(searchbox);
// search for something that doesn't exist in the module list
await user.type(searchbox, "super-duper-unreleased-module");
expect(screen.queryByText(/Serialization Xstream/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Initializer/i)).not.toBeInTheDocument();
expect(screen.getByText(/no matching modules found/i)).toBeInTheDocument();
});Il se passe beaucoup de choses ici, alors essayons de le décomposer. Pour commencer, nous invoquons la configuration userEvent avant que le composant ne soit rendu. Ensuite, nous mettons en place un objet appelé testModules qui imite les données que nous nous attendrions typiquement à obtenir du backend. Nous configurons ensuite la fonction mockedOpenmrsFetch pour qu’elle renvoie les données simulées. Après cela, nous pouvons enfin rendre le composant. Parce que nous simulons un délai, nous attendrons que l’état de chargement soit terminé avant d’exécuter nos assertions. Une fois cela fait, nous exécuterons des assertions sur le datatable de gestion des modules, comparant les en-têtes de tableau et les données de ligne à nos données attendues. Nous simulerons ensuite une recherche dans la liste, explorant à la fois le chemin heureux et l’état vide.
Bien que ce test passe et puisse nous donner une certaine confiance que ce composant fonctionne comme prévu, il y a encore quelques faiblesses dans cette approche. Par exemple:
- Parce que nous ne regardons pas le point de terminaison de l’API et les paramètres de la requête, nous ne pouvons pas dire si l’utilisateur fait la bonne requête.
- Parce que nous simulons le backend, nous ne pouvons pas prédire avec confiance ce qui se passera si le vrai serveur est en panne, ou s’il renvoie un résultat inattendu.
Modèles de simulation
Lors de l’écriture de tests, vous aurez souvent besoin de simuler des dépendances. Voici quelques modèles que vous pouvez utiliser pour écrire des tests plus efficaces, maintenables et sûrs sur le plan du typage pour vos modules frontend:
Le framework exporte des mocks de modules qui peuvent être utilisés pour tester les fonctionnalités dans @openmrs/esm-framework. Le framework fournit deux fichiers de mock :
mock.tsx- Utilisé lors d’importations via les modules ES (import)mock-jest.tsx- Utilisé lors d’importations via CommonJS (require)
Lorsque vous importez @openmrs/esm-framework/mock, le package se résout automatiquement vers le fichier de mock approprié en fonction de votre système de modules. Vous n’avez pas besoin de préciser quel fichier utiliser — le framework s’en charge automatiquement. Notez que cette résolution dépend du système de modules (ESM vs CommonJS), pas du runner de test. Par exemple, un projet Jest configuré pour utiliser les modules ES utilisera mock.tsx via import, tandis qu’une utilisation CommonJS (via require()) utilisera mock-jest.tsx.
Dans la configuration de test de chaque module frontend, vous verrez une configuration qui mappe ou mocke @openmrs/esm-framework. L’approche exacte dépend du runner de test utilisé dans ce dépôt (Jest ou Vitest).
Exemple d’alias de configuration Vitest :
import { fileURLToPath } from "node:url";
import { defineConfig } from 'vitest/config';
const r = (relativePath: string) => fileURLToPath(new URL(relativePath, import.meta.url));
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: [r('./tools/setup-tests.ts')],
server: {
deps: {
inline: [/@openmrs/],
},
},
alias: [
{ find: /^@openmrs\/esm-framework$/, replacement: '@openmrs/esm-framework/mock' },
],
},
});Exemple Jest :
module.exports = {
// ... autres propriétés de configuration
moduleNameMapper: {
"@openmrs/esm-framework": "@openmrs/esm-framework/mock",
},
};Exemple de fichier de setup Vitest :
vi.mock('@openmrs/esm-framework', () => import('@openmrs/esm-framework/mock'));Ces approches indiquent au runner de test de remplacer toutes les importations de @openmrs/esm-framework par le contenu de @openmrs/esm-framework/mock pendant les tests. C’est crucial, car @openmrs/esm-framework est une bibliothèque qui fournit des fonctionnalités cœur telles que la configuration, l’authentification, le chargement de modules, le routage, etc. La version mock (@openmrs/esm-framework/mock) fournit des stubs pour toutes ces fonctions qui :
- Ne font pas de requêtes réseau réelles.
- Ne nécessitent pas un backend OpenMRS réel.
- Permettent un contrôle plus simple des fonctionnalités du framework dans vos tests.
C’est considéré comme une bonne pratique en matière de tests parce que cela :
- Isole les tests des dépendances externes.
- Rend les tests plus prévisibles et plus faciles à écrire.
- Permet de contrôler le comportement du framework dans vos tests.
- Accélère l’exécution des tests en évitant les requêtes réseau et les dépendances backend.
Le mock du framework agrège des stubs pour toutes les APIs du framework que votre module pourrait utiliser. Si une fonctionnalité est manquante, vous devriez d’abord l’ajouter au mock. Vous pouvez le faire en étendant l’un des stubs existants ou en en ajoutant de nouveaux.
Préférer les mocks partagés avant les mocks partiels
Le mock du framework devrait couvrir les comportements réutilisables du framework dont votre module a besoin. Si vous simulez une fonction du framework parce qu’il manque un stub généralement utile dans le mock partagé, ajoutez ce stub au package de mock approprié au lieu de construire une structure de mock séparée dans chaque test. Vous avez peut-être vu un modèle comme celui-ci dans le passé:
import { useConfig } from "@openmrs/esm-framework";
jest.mock("@openmrs/esm-framework", () => ({
...jest.requireActual("@openmrs/esm-framework"),
useConfig: jest.fn(() => ({ config: { someConfig: "valeur" } })),
}));Évitez cette approche lorsque l’objectif est de fournir un stub de framework réutilisable. Elle crée une structure de mock parallèle qui est plus difficile à maintenir que le mock centralisé du framework. Comme les stubs du mock du framework sont centralisés, il est plus simple d’ajouter de nouveaux stubs à chaque fois que de nouvelles fonctionnalités sont ajoutées au framework.
La bonne façon d’étendre le mock du framework est d’ajouter la fonctionnalité manquante au mock lui-même. Voici un exemple de la manière de procéder :
Exemple Jest :
import { useConfig } from "@openmrs/esm-framework";
const mockUseConfig = jest.mocked(useConfig);
beforeEach(() => {
mockUseConfig.mockReturnValue({ config: { someConfig: "valeur" } });
});
// Vos tests vont ici...Exemple Vitest :
import { useConfig } from "@openmrs/esm-framework";
import { vi } from "vitest";
const mockUseConfig = vi.mocked(useConfig);
beforeEach(() => {
mockUseConfig.mockReturnValue({ config: { someConfig: "valeur" } });
});
// Vos tests vont ici...Les mocks partiels ciblés restent raisonnables lorsqu’un test précis doit remplacer une exportation existante, comme useSession, launchWorkspace2 ou un composant UI du framework. Avec Vitest, conservez le reste du module et remplacez uniquement l’exportation nécessaire :
import { launchWorkspace2 } from "@openmrs/esm-framework";
import { beforeEach, vi } from "vitest";
vi.mock("@openmrs/esm-framework", async (importOriginal) => {
const actual = await importOriginal<typeof import("@openmrs/esm-framework")>();
return {
...actual,
launchWorkspace2: vi.fn(),
};
});
const mockLaunchWorkspace2 = vi.mocked(launchWorkspace2);
beforeEach(() => {
mockLaunchWorkspace2.mockReset();
});Voici un exemple réel de pull request qui a ajouté de nouveaux stubs au mock du framework: https://github.com/openmrs/openmrs-esm-core/pull/1105 .
Lorsque vous ajoutez de nouveaux stubs, suivez ces règles:
- Choisissez le bon fichier de mock: ajoutez les stubs au mock qui correspond au package contenant la fonctionnalité originale:
- Pour une fonctionnalité de
@openmrs/esm-extensions, ajoutez le stub dans l’implémentation@openmrs/esm-extensions/mock. - Pour une fonctionnalité de
@openmrs/esm-api, ajoutez le stub dans l’implémentation@openmrs/esm-api/mock. - Pour une fonctionnalité de
@openmrs/esm-state, ajoutez le stub dans l’implémentation@openmrs/esm-state/mock. - Et ainsi de suite…
- Pour une fonctionnalité de
- Suivez les modèles existants: suivez les modèles déjà présents dans le fichier de mock.
Annoter les mocks avec des informations de type
Dans la mesure du possible, préférez utiliser la fonction mocked du runner de test (jest.mocked pour Jest ou vi.mocked pour Vitest) pour obtenir des informations de type pour vos mocks. Cela vous aidera à détecter les erreurs de type tôt et rendra vos tests plus robustes sur le plan du typage.
Important: La fonction mocked est uniquement typée et n’effectue pas de mock au runtime. Le module doit être mocké séparément via votre configuration de test (comme indiqué dans la section Modèles de simulation ci-dessus) en utilisant jest.mock(), vi.mock() ou la configuration moduleNameMapper.
Vous avez peut-être vu ce modèle dans le passé:
import { useLayoutType } from "@openmrs/esm-framework";
const mockUseLayoutType = useLayoutType as jest.Mock;La fonction mocked est une meilleure alternative car elle préserve la signature de type de la fonction d’origine, offrant une meilleure sécurité de type et une meilleure complétion automatique:
Exemple Jest :
import { useLayoutType } from "@openmrs/esm-framework";
// Remarque : le module doit être mocké au runtime (via jest.mock() ou moduleNameMapper)
// pour que mockReturnValue et les autres méthodes de mock fonctionnent.
const mockUseLayoutType = jest.mocked(useLayoutType);Exemple Vitest :
import { useLayoutType } from "@openmrs/esm-framework";
import { vi } from "vitest";
// Remarque : le module doit être mocké au runtime (via vi.mock() dans setup-tests.ts)
// pour que mockReturnValue et les autres méthodes de mock fonctionnent.
const mockUseLayoutType = vi.mocked(useLayoutType);La fonction mocked déduira le type du mock à partir de la fonction source, en préservant les types des paramètres et des valeurs de retour. Dans la plupart des cas, cette inférence est suffisante lorsque le type de la fonction est concret. Vous pouvez aller plus loin en annotant explicitement le mock avec le type de la fonction source :
Exemple Jest :
import { useConfig } from "@openmrs/esm-framework";
import type { PatientRegistrationConfig } from "../config-schema";
const mockUseConfig = jest.mocked(useConfig<PatientRegistrationConfig>);Exemple Vitest :
import { useConfig } from "@openmrs/esm-framework";
import type { PatientRegistrationConfig } from "../config-schema";
import { vi } from "vitest";
const mockUseConfig = vi.mocked(useConfig<PatientRegistrationConfig>);Quand l’inférence ne suffit pas: Dans de rares cas où l’inférence TypeScript n’est pas suffisante (p. ex. avec des generics complexes, des signatures surchargées ou des cas limites), vous pouvez utiliser MockedFunction comme solution de repli via une assertion de type :
Exemple Jest :
import { useConfig } from "@openmrs/esm-framework";
import type { PatientRegistrationConfig } from "../config-schema";
const mockUseConfig = jest.mocked(useConfig) as jest.MockedFunction<typeof useConfig<PatientRegistrationConfig>>;Exemple Vitest :
import { useConfig } from "@openmrs/esm-framework";
import type { PatientRegistrationConfig } from "../config-schema";
import { vi } from "vitest";
const mockUseConfig = vi.mocked(useConfig) as vi.MockedFunction<typeof useConfig<PatientRegistrationConfig>>;Cependant, mocked() suffit dans la plupart des cas et reste l’approche préférée. Notez que pour les fonctions avec des signatures surchargées, MockedFunction peut sélectionner la mauvaise surcharge. Dans ce cas, vous devrez peut-être utiliser une signature spécifique ou créer un alias de type pour cibler la surcharge exacte dont vous avez besoin.
Annoter les mocks useConfig avec des informations de type
Le hook useConfig est un cas particulier car il prend un paramètre générique qui spécifie la forme de l’objet de configuration. En plus d’annoter le mock avec le type de l’objet de configuration, vous devriez également fournir un objet de configuration par défaut que vos tests peuvent utiliser.
Exemple Jest :
import { getDefaultsFromConfigSchema, useConfig } from "@openmrs/esm-framework";
import type { PatientRegistrationConfig } from "../config-schema";
const mockUseConfig = jest.mocked(useConfig<PatientRegistrationConfig>);
beforeEach(() => {
mockUseConfig.mockReturnValue({
...getDefaultsFromConfigSchema(configSchema),
// Ajoutez votre configuration personnalisée ici...
});
});Exemple Vitest :
import { getDefaultsFromConfigSchema, useConfig } from "@openmrs/esm-framework";
import type { PatientRegistrationConfig } from "../config-schema";
import { vi } from "vitest";
const mockUseConfig = vi.mocked(useConfig<PatientRegistrationConfig>);
beforeEach(() => {
mockUseConfig.mockReturnValue({
...getDefaultsFromConfigSchema(configSchema),
// Ajoutez votre configuration personnalisée ici...
});
});Ce modèle garantit que vos tests ont accès à un objet de configuration par défaut qui est cohérent avec le schéma de configuration. Il fournit également des informations de type pour le mock ainsi que l’initialisation du mock avec des valeurs de configuration par défaut du schéma. Si vous devez fournir seulement un sous-ensemble des valeurs de configuration, vous devrez peut-être convertir la valeur de retour au type de configuration pour satisfaire TypeScript:
beforeEach(() => {
mockUseConfig.mockReturnValue({
...getDefaultsFromConfigSchema(configSchema),
// Ajoutez votre configuration personnalisée ici...
} as RegistrationConfig);
});Points clés à retenir
- Utilisez le mock du framework pour tester votre module de manière isolée.
- Étendez le mock du framework en ajoutant des stubs pour les comportements réutilisables du framework manquants. Utilisez des mocks partiels ciblés seulement pour des remplacements propres à un test.
- Utilisez la fonction
mocked(jest.mockedpour Jest ouvi.mockedpour Vitest) pour déduire le type de vos mocks à partir de la fonction source. - Annotez vos mocks avec des informations de type pour détecter les erreurs de type tôt et rendre vos tests plus robustes.
En fin de compte, pour obtenir la bonne stratégie de test, vous aurez probablement besoin d’un mélange de tests unitaires, d’intégration et de bout en bout. Vous devrez également déterminer le bon niveau de simulation à appliquer. Si vous simulez trop peu, vous risquez de tester trop de détails d’implémentation. Si vous simulez trop, vous risquez de sacrifier beaucoup de confiance.
Voici une vidéo intéressante de Brandon sur les tests des modules frontend: