Front-end angularforall.com

- Tests Angular : Jest, Cypress et stratégie

Angular Tests Jest Cypress Quality
Tests Angular : Jest, Cypress et stratégie

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.
Anti-pattern: ne pas inverser la pyramide en écrivant 80% de E2E. Les tests E2E sont lents, fragiles et difficiles à maintenir. Chaque flakiness de test E2E coûte du temps d'équipe.

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

  1. Après chaque mutation de Signal dans un test, appelle fixture.detectChanges() pour mettre à jour le DOM.
  2. Pour les composants utilisant OnPush, appelle fixture.changeDetectorRef.markForCheck() avant detectChanges().

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: utilise des attributs 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
    });
});
setInput() : utilisez toujours 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 servicejest.fn()cy.intercept()Isoler les dépendances
Données de testVariables inlinecy.fixture()Réponses API déterministes
Timer fakefakeAsync/tickcy.clock()Tester setTimeout/Interval
Spyjest.spyOn()cy.spy()Vérifier les appels
Asyncasync/awaitChaîne de commandesOpé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() + HttpTestingController pour mocker les appels HTTP dans TestBed.
  • Tagger les éléments interactifs avec data-cy dè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.ts et bloquer le merge si non atteints.
  • Lancer les tests unitaires à chaque PR et les E2E uniquement sur les branches principales.
  • Éviter les setTimeout dans les tests Cypress — utiliser les aliases et les assertions should.
  • 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.

Partager