Mettez en place une stratégie de tests Angular complète : tests unitaires avec Jest, tests E2E avec Cypress, pyramide de tests et intégration en pipeline.
Pyramide de tests Angular
La pyramide de tests recommande de concentrer l'effort sur les tests unitaires (rapides, nombreux, isolés), de compléter avec des tests d'intégration (services + HTTP), et de limiter les tests E2E aux parcours métier critiques.
- Unitaires (70%) — pipes, services purs, reducers NgRx, fonctions utilitaires. Exécution en millisecondes avec Jest.
- Intégration (20%) — composants avec
TestBed, mocks HTTP, stores. Exécution en secondes. - E2E (10%) — parcours utilisateur complets (login, achat, formulaire multi-étapes). Exécution en minutes avec Cypress.
Jest : configuration et premiers tests
Angular 16+ supporte Jest nativement via le builder @angular/build:jest. Pour les projets existants :
npm install --save-dev jest jest-preset-angular @types/jest
# Remplacer Karma/Jasmine dans angular.json
ng generate @angular/build:jest-builder
Configuration minimale dans jest.config.ts :
import type { Config } from 'jest';
const config: Config = {
preset: 'jest-preset-angular',
setupFilesAfterFramework: ['<rootDir>/setup-jest.ts'],
testPathPattern: '.*\\.spec\\.ts$',
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.spec.ts', '!src/main.ts'],
coverageThresholds: {
global: { branches: 70, functions: 75, lines: 80, statements: 80 }
}
};
export default config;
// setup-jest.ts
import 'jest-preset-angular/setup-jest';
Test d'un service simple :
// calculator.service.spec.ts
import { CalculatorService } from './calculator.service';
describe('CalculatorService', () => {
let service: CalculatorService;
beforeEach(() => { service = new CalculatorService(); });
it('should add two numbers', () => {
expect(service.add(2, 3)).toBe(5);
});
it('should throw on division by zero', () => {
expect(() => service.divide(10, 0)).toThrow('Division par zéro');
});
});
Tester des composants avec TestBed
TestBed crée un module Angular minimal pour tester un composant avec ses dépendances réelles ou mockées.
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { ArticlesComponent } from './articles.component';
import { ArticlesService } from './articles.service';
describe('ArticlesComponent', () => {
let fixture: ComponentFixture<ArticlesComponent>;
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ArticlesComponent],
providers: [provideHttpClientTesting()]
}).compileComponents();
fixture = TestBed.createComponent(ArticlesComponent);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('should display articles after load', () => {
fixture.detectChanges(); // déclenche ngOnInit
const req = httpMock.expectOne('/api/articles');
req.flush([{ id: 1, name: 'Test article' }]);
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('.article-item');
expect(items.length).toBe(1);
});
});
Signal + TestBed
- Après chaque mutation de Signal dans un test, appelle
fixture.detectChanges()pour mettre à jour le DOM. - Pour les composants utilisant
OnPush, appellefixture.changeDetectorRef.markForCheck()avantdetectChanges().
Cypress : tests E2E des parcours critiques
Cypress s'intègre nativement avec Angular via @cypress/angular pour les tests de composants isolés, et via le runner E2E standard pour les parcours complets.
npm install --save-dev cypress @cypress/schematic
ng add @cypress/schematic
Test E2E d'un parcours de connexion :
// cypress/e2e/auth.cy.ts
describe('Authentification', () => {
beforeEach(() => {
cy.visit('/login');
});
it('should login with valid credentials', () => {
cy.get('[data-cy="email"]').type('user@exemple.com');
cy.get('[data-cy="password"]').type('motdepasse123');
cy.get('[data-cy="submit"]').click();
cy.url().should('include', '/dashboard');
cy.get('[data-cy="user-name"]').should('contain', 'Bonjour');
});
it('should show error with invalid credentials', () => {
cy.get('[data-cy="email"]').type('faux@exemple.com');
cy.get('[data-cy="password"]').type('mauvais');
cy.get('[data-cy="submit"]').click();
cy.get('[data-cy="error-message"]').should('be.visible');
cy.url().should('include', '/login');
});
});
data-cy dédiés aux tests — jamais de sélecteurs CSS ou de classes qui peuvent changer. Cela découple les tests de l'implémentation visuelle.
Couverture de code et CI
La couverture de code mesure quelle proportion du code est exécutée par les tests. Un seuil raisonnable pour une application Angular de production est 80% de lignes couvertes.
# Lancer les tests avec rapport de couverture
ng test --coverage --watch=false
# Dans la CI (GitHub Actions)
- name: Unit tests
run: ng test --coverage --watch=false --browsers=ChromeHeadless
- name: E2E tests
run: npx cypress run --headless
Exemple de configuration GitHub Actions complète :
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: ng test --coverage --watch=false
- run: ng build
- run: npx cypress run
Tester les Signals Angular
Les signaux s'intègrent naturellement dans les tests unitaires Jest. Lisez-les directement comme des fonctions, et modifiez-les via set()/update() puis vérifiez les valeurs dérivées.
// cart.store.spec.ts — Tester un Signal Store
import { TestBed } from '@angular/core/testing';
import { CartStore } from './cart.store';
describe('CartStore', () => {
let store: CartStore;
beforeEach(() => {
TestBed.configureTestingModule({});
store = TestBed.inject(CartStore);
});
it('should start with empty cart', () => {
expect(store.items()).toHaveLength(0);
expect(store.totalItems()).toBe(0);
expect(store.isEmpty()).toBe(true);
});
it('should add item to cart', () => {
store.addItem({ productId: 1, name: 'Test', price: 10, quantity: 1 });
expect(store.items()).toHaveLength(1);
expect(store.totalItems()).toBe(1);
expect(store.totalPrice()).toBe(10);
});
it('should increment quantity for existing item', () => {
store.addItem({ productId: 1, name: 'Test', price: 10, quantity: 1 });
store.addItem({ productId: 1, name: 'Test', price: 10, quantity: 1 });
expect(store.items()).toHaveLength(1); // Un seul article
expect(store.totalItems()).toBe(2); // Quantité 2
});
it('should remove item', () => {
store.addItem({ productId: 1, name: 'Test', price: 10, quantity: 1 });
store.removeItem(1);
expect(store.items()).toHaveLength(0);
expect(store.isEmpty()).toBe(true);
});
});
Tester un composant avec Signal inputs
// user-card.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserCardComponent } from './user-card.component';
describe('UserCardComponent', () => {
let fixture: ComponentFixture<UserCardComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserCardComponent]
}).compileComponents();
fixture = TestBed.createComponent(UserCardComponent);
});
it('should display user name', () => {
// Définir les signal inputs via componentRef
fixture.componentRef.setInput('name', 'Alice Dupont');
fixture.componentRef.setInput('role', 'Développeur Senior');
fixture.detectChanges();
const h3 = fixture.nativeElement.querySelector('h3');
expect(h3.textContent).toContain('Alice Dupont');
});
it('should use default role when not provided', () => {
fixture.componentRef.setInput('name', 'Bob');
fixture.detectChanges();
const p = fixture.nativeElement.querySelector('p');
expect(p.textContent).toContain('Membre'); // Valeur par défaut
});
});
fixture.componentRef.setInput('name', value) pour les signal inputs — la syntaxe classique component.name = value ne déclenche pas la mise à jour des signaux dans TestBed.
Jest avancé : mocks, spies et async
Mocker un service injecté avec inject()
// articles.component.spec.ts
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ArticlesService } from './articles.service';
import { ArticlesComponent } from './articles.component';
import { of, throwError } from 'rxjs';
describe('ArticlesComponent', () => {
let mockArticlesService: jest.Mocked<ArticlesService>;
beforeEach(async () => {
// Créer un mock de service complet
mockArticlesService = {
getArticles: jest.fn(),
deleteArticle: jest.fn(),
} as any;
await TestBed.configureTestingModule({
imports: [ArticlesComponent],
providers: [
{ provide: ArticlesService, useValue: mockArticlesService }
]
}).compileComponents();
});
it('should load articles on init', fakeAsync(() => {
const mockData = [{ id: 1, title: 'Test', category: 'front' }];
mockArticlesService.getArticles.mockReturnValue(of(mockData));
const fixture = TestBed.createComponent(ArticlesComponent);
fixture.detectChanges();
tick(); // Avance le temps virtuel pour les Observables
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('.article-item');
expect(items).toHaveLength(1);
}));
it('should handle service error gracefully', fakeAsync(() => {
mockArticlesService.getArticles.mockReturnValue(
throwError(() => new Error('Network error'))
);
const fixture = TestBed.createComponent(ArticlesComponent);
fixture.detectChanges();
tick();
fixture.detectChanges();
const errorMsg = fixture.nativeElement.querySelector('[data-cy="error-msg"]');
expect(errorMsg).toBeTruthy();
}));
});
Tests des pipes
// truncate.pipe.spec.ts
import { TruncatePipe } from './truncate.pipe';
describe('TruncatePipe', () => {
let pipe: TruncatePipe;
beforeEach(() => { pipe = new TruncatePipe(); });
it('should truncate long strings', () => {
const result = pipe.transform('Hello World Angular', 10);
expect(result).toBe('Hello Worl...');
});
it('should not truncate short strings', () => {
const result = pipe.transform('Hello', 10);
expect(result).toBe('Hello');
});
it('should handle null', () => {
const result = pipe.transform(null, 10);
expect(result).toBe('');
});
});
Cypress : interceptors et fixtures
Les interceptors Cypress permettent de contrôler les réponses API dans les tests E2E, rendant les tests déterministes et rapides.
// cypress/e2e/articles.cy.ts
describe('Articles list', () => {
beforeEach(() => {
// Intercepter l'appel API et retourner une fixture
cy.intercept('GET', '/api/articles*', { fixture: 'articles.json' })
.as('getArticles');
cy.visit('/articles');
});
it('should display articles from API', () => {
cy.wait('@getArticles'); // Attendre l'appel intercepté
cy.get('[data-cy="article-item"]').should('have.length', 3);
});
it('should filter articles by category', () => {
cy.wait('@getArticles');
cy.get('[data-cy="filter-front"]').click();
// Vérifier que le filtre est appliqué
cy.get('[data-cy="article-item"]').each(($el) => {
cy.wrap($el).should('have.attr', 'data-category', 'front');
});
});
it('should show loading state', () => {
// Simuler une réponse lente
cy.intercept('GET', '/api/articles*', (req) => {
req.reply({ delay: 2000, fixture: 'articles.json' });
});
cy.visit('/articles');
cy.get('[data-cy="loading-spinner"]').should('be.visible');
cy.get('[data-cy="loading-spinner"]', { timeout: 5000 }).should('not.exist');
});
});
// cypress/fixtures/articles.json
[
{ "id": 1, "title": "Angular Signals", "category": "front" },
{ "id": 2, "title": "RxJS Patterns", "category": "front" },
{ "id": 3, "title": "Django ORM", "category": "back" }
]
| Technique | Jest | Cypress | Usage |
|---|---|---|---|
| Mock service | jest.fn() | cy.intercept() | Isoler les dépendances |
| Données de test | Variables inline | cy.fixture() | Réponses API déterministes |
| Timer fake | fakeAsync/tick | cy.clock() | Tester setTimeout/Interval |
| Spy | jest.spyOn() | cy.spy() | Vérifier les appels |
| Async | async/await | Chaîne de commandes | Opérations asynchrones |
Checklist stratégie de tests
- Respecter la pyramide : 70% unitaires, 20% intégration, 10% E2E — ne pas surinvestir dans le E2E.
- Remplacer Karma par Jest pour des tests unitaires 3 à 5 fois plus rapides.
- Utiliser
provideHttpClientTesting()+HttpTestingControllerpour mocker les appels HTTP dans TestBed. - Tagger les éléments interactifs avec
data-cydès le développement — ne pas sélectionner par classes CSS. - Utiliser
fixture.componentRef.setInput()pour les signal inputs dans TestBed. - Fixer des seuils de couverture dans
jest.config.tset bloquer le merge si non atteints. - Lancer les tests unitaires à chaque PR et les E2E uniquement sur les branches principales.
- Éviter les
setTimeoutdans les tests Cypress — utiliser les aliases et les assertionsshould. - Tester les cas d'erreur (réseau KO, formulaire invalide) autant que les cas nominaux.
- Utiliser
cy.intercept()avec des fixtures JSON pour des tests E2E rapides et déterministes.