Testing Architecture: Strategie per App Angular
Una strategia di testing solida è il fondamento di qualsiasi applicazione Angular manutenibile. Senza test affidabili, ogni refactoring diventa un rischio, ogni deploy una scommessa. Ma testare non significa scrivere test a caso: serve un'architettura dei test ben definita, con strategie chiare per ogni livello dell'applicazione.
In questo articolo esploreremo la piramide dei test, il modello trophy, e vedremo come testare servizi, componenti e flussi end-to-end in Angular usando Jest, Angular Testing Library e Playwright.
Cosa Imparerai
- La piramide dei test e il modello trophy di Kent C. Dodds
- Unit testing di servizi Angular con Jest
- Component testing con Angular Testing Library
- Test E2E con Playwright
- Strategie di mocking per HttpClient e servizi
- Testing di codice asincrono: Observable e Promise
- Integrazione dei test nella pipeline CI/CD
Piramide dei Test vs Modello Trophy
La piramide dei test classica, proposta da Mike Cohn, suggerisce una distribuzione a piramide: molti unit test alla base, meno test di integrazione al centro, e pochi test E2E in cima. L'idea è che i test più veloci e isolati devono essere la maggioranza.
Il modello trophy di Kent C. Dodds ribalta parzialmente questa visione, proponendo di investire di più nei test di integrazione, che offrono il miglior rapporto tra costo di scrittura e fiducia nel funzionamento dell'applicazione.
Confronto dei Due Modelli
| Livello | Piramide (Cohn) | Trophy (Dodds) | Velocita | Fiducia |
|---|---|---|---|---|
| Static Analysis | Non inclusa | Base del trofeo | Istantanea | Bassa |
| Unit Test | 70% dei test | 20% dei test | Molto alta | Media |
| Integration Test | 20% dei test | 60% dei test | Alta | Alta |
| E2E Test | 10% dei test | 20% dei test | Bassa | Molto alta |
Quale Modello Seguire?
Per applicazioni Angular, il modello trophy è spesso più efficace. I test di integrazione dei componenti (che verificano template, binding e interazioni) catturano più bug reali rispetto ai puri unit test di classi isolate. La raccomandazione è: scrivi i test al livello più alto possibile che rimane veloce e affidabile.
Unit Testing di Servizi con Jest
I servizi Angular contengono la logica di business e la comunicazione con le API. Testare un servizio significa verificare che la logica funzioni correttamente in isolamento dalle dipendenze.
Configurazione di Jest per Angular
import type { Config } from 'jest';
const config: Config = {
preset: 'jest-preset-angular',
setupFilesAfterSetup: ['<rootDir>/setup-jest.ts'],
testPathIgnorePatterns: [
'<rootDir>/node_modules/',
'<rootDir>/dist/',
'<rootDir>/e2e/'
],
moduleNameMapper: {
'@app/(.*)': '<rootDir>/src/app/$1',
'@env/(.*)': '<rootDir>/src/environments/$1'
},
collectCoverageFrom: [
'src/app/**/*.ts',
'!src/app/**/*.module.ts',
'!src/app/**/*.routes.ts',
'!src/app/**/index.ts'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
export default config;
Test di un Servizio con HttpClient
@Injectable({ providedIn: 'root' })
export class ProductService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/products';
getProducts(category?: string): Observable<Product[]> {
let params = new HttpParams();
if (category) {
params = params.set('category', category);
}
return this.http.get<Product[]>(this.baseUrl, { params });
}
getProductById(id: string): Observable<Product> {
return this.http.get<Product>(this.baseUrl + '/' + id);
}
createProduct(data: CreateProductDto): Observable<Product> {
return this.http.post<Product>(this.baseUrl, data);
}
calculateDiscount(product: Product, percentage: number): number {
if (percentage < 0 || percentage > 100) {
throw new Error('La percentuale deve essere tra 0 e 100');
}
return product.price * (1 - percentage / 100);
}
}
import { TestBed } from '@angular/core/testing';
import {
HttpTestingController,
provideHttpClientTesting
} from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { ProductService } from './product.service';
describe('ProductService', () => {
let service: ProductService;
let httpMock: HttpTestingController;
const mockProducts: Product[] = [
{ id: '1', name: 'Laptop', price: 999, category: 'electronics', inStock: true },
{ id: '2', name: 'Mouse', price: 29, category: 'electronics', inStock: true },
{ id: '3', name: 'Book', price: 15, category: 'books', inStock: false }
];
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ProductService,
provideHttpClient(),
provideHttpClientTesting()
]
});
service = TestBed.inject(ProductService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
// Verifica che non ci siano richieste HTTP pendenti
httpMock.verify();
});
describe('getProducts', () => {
it('deve caricare tutti i prodotti', () => {
service.getProducts().subscribe(products => {
expect(products).toEqual(mockProducts);
expect(products.length).toBe(3);
});
const req = httpMock.expectOne('/api/products');
expect(req.request.method).toBe('GET');
req.flush(mockProducts);
});
it('deve filtrare per categoria', () => {
service.getProducts('electronics').subscribe(products => {
expect(products.length).toBe(2);
});
const req = httpMock.expectOne(
r => r.url === '/api/products' &&
r.params.get('category') === 'electronics'
);
expect(req.request.method).toBe('GET');
req.flush(mockProducts.filter(p => p.category === 'electronics'));
});
it('deve gestire errori HTTP', () => {
service.getProducts().subscribe({
next: () => fail('Dovrebbe fallire'),
error: (error) => {
expect(error.status).toBe(500);
}
});
const req = httpMock.expectOne('/api/products');
req.flush('Server error', {
status: 500,
statusText: 'Internal Server Error'
});
});
});
describe('calculateDiscount', () => {
const product: Product = {
id: '1', name: 'Laptop', price: 1000,
category: 'electronics', inStock: true
};
it('deve calcolare lo sconto correttamente', () => {
expect(service.calculateDiscount(product, 20)).toBe(800);
expect(service.calculateDiscount(product, 50)).toBe(500);
expect(service.calculateDiscount(product, 0)).toBe(1000);
expect(service.calculateDiscount(product, 100)).toBe(0);
});
it('deve lanciare errore per percentuale non valida', () => {
expect(() => service.calculateDiscount(product, -10))
.toThrow('La percentuale deve essere tra 0 e 100');
expect(() => service.calculateDiscount(product, 110))
.toThrow('La percentuale deve essere tra 0 e 100');
});
});
});
Component Testing con Angular Testing Library
Angular Testing Library segue la filosofia di Testing Library: testare i componenti come li userebbe un utente reale, interagendo con il DOM attraverso ruoli, testo visibile e label accessibili, anziche attraverso dettagli di implementazione.
Test di un Componente con Interazioni
@Component({
standalone: true,
selector: 'app-product-list',
template: `
<div class="product-list">
<input
type="text"
placeholder="Cerca prodotti..."
[ngModel]="searchTerm"
(ngModelChange)="onSearch($event)"
aria-label="Cerca prodotti"
/>
<div class="filters">
<select
[ngModel]="selectedCategory"
(ngModelChange)="onCategoryChange($event)"
aria-label="Filtra per categoria">
<option value="">Tutte le categorie</option>
<option *ngFor="let cat of categories" [value]="cat">
{{ cat }}
</option>
</select>
</div>
<div *ngIf="loading" role="status">Caricamento...</div>
<div *ngIf="error" role="alert" class="error">
{{ error }}
<button (click)="reload()">Riprova</button>
</div>
<ul *ngIf="!loading && !error">
<li *ngFor="let product of filteredProducts" class="product-card">
<h3>{{ product.name }}</h3>
<p class="price">EUR {{ product.price }}</p>
<span [class.in-stock]="product.inStock"
[class.out-of-stock]="!product.inStock">
{{ product.inStock ? 'Disponibile' : 'Esaurito' }}
</span>
<button (click)="addToCart(product)"
[disabled]="!product.inStock">
Aggiungi al carrello
</button>
</li>
</ul>
<p *ngIf="filteredProducts.length === 0 && !loading">
Nessun prodotto trovato
</p>
</div>
`,
imports: [CommonModule, FormsModule]
})
export class ProductListComponent implements OnInit {
// ... implementazione
}
import { render, screen, fireEvent, waitFor } from '@testing-library/angular';
import { ProductListComponent } from './product-list.component';
import { ProductService } from '../../services/product.service';
import { CartService } from '../../services/cart.service';
import { of, throwError } from 'rxjs';
describe('ProductListComponent', () => {
const mockProducts: Product[] = [
{ id: '1', name: 'Laptop Pro', price: 999, category: 'electronics', inStock: true },
{ id: '2', name: 'Wireless Mouse', price: 29, category: 'electronics', inStock: true },
{ id: '3', name: 'TypeScript Handbook', price: 45, category: 'books', inStock: false }
];
const mockProductService = {
getProducts: jest.fn().mockReturnValue(of(mockProducts))
};
const mockCartService = {
addItem: jest.fn()
};
async function setup(overrides = {}) {
return render(ProductListComponent, {
providers: [
{ provide: ProductService, useValue: { ...mockProductService, ...overrides } },
{ provide: CartService, useValue: mockCartService }
]
});
}
beforeEach(() => {
jest.clearAllMocks();
});
it('deve mostrare la lista dei prodotti', async () => {
await setup();
expect(screen.getByText('Laptop Pro')).toBeTruthy();
expect(screen.getByText('Wireless Mouse')).toBeTruthy();
expect(screen.getByText('TypeScript Handbook')).toBeTruthy();
});
it('deve mostrare lo stato di disponibilità', async () => {
await setup();
const disponibili = screen.getAllByText('Disponibile');
const esauriti = screen.getAllByText('Esaurito');
expect(disponibili.length).toBe(2);
expect(esauriti.length).toBe(1);
});
it('deve filtrare i prodotti con la ricerca', async () => {
await setup();
const searchInput = screen.getByLabelText('Cerca prodotti');
fireEvent.input(searchInput, { target: { value: 'Laptop' } });
await waitFor(() => {
expect(screen.getByText('Laptop Pro')).toBeTruthy();
expect(screen.queryByText('Wireless Mouse')).toBeNull();
});
});
it('deve disabilitare il pulsante per prodotti esauriti', async () => {
await setup();
const buttons = screen.getAllByText('Aggiungi al carrello');
// Il terzo prodotto è esaurito
expect(buttons[2]).toBeDisabled();
expect(buttons[0]).not.toBeDisabled();
});
it('deve aggiungere al carrello quando si clicca il pulsante', async () => {
await setup();
const buttons = screen.getAllByText('Aggiungi al carrello');
fireEvent.click(buttons[0]);
expect(mockCartService.addItem).toHaveBeenCalledWith(mockProducts[0]);
});
it('deve mostrare il caricamento', async () => {
// Simula un caricamento lento
const neverResolve = new Observable<Product[]>(() => {});
await setup({ getProducts: () => neverResolve });
expect(screen.getByText('Caricamento...')).toBeTruthy();
});
it('deve mostrare errore e permettere il retry', async () => {
mockProductService.getProducts
.mockReturnValueOnce(throwError(() => new Error('Network error')))
.mockReturnValueOnce(of(mockProducts));
await setup();
// Verifica messaggio di errore
expect(screen.getByRole('alert')).toBeTruthy();
// Clicca su Riprova
fireEvent.click(screen.getByText('Riprova'));
await waitFor(() => {
expect(screen.getByText('Laptop Pro')).toBeTruthy();
});
});
it('deve mostrare messaggio quando non ci sono risultati', async () => {
await setup();
const searchInput = screen.getByLabelText('Cerca prodotti');
fireEvent.input(searchInput, { target: { value: 'xyznonexistent' } });
await waitFor(() => {
expect(screen.getByText('Nessun prodotto trovato')).toBeTruthy();
});
});
});
Test E2E con Playwright
I test end-to-end verificano il funzionamento dell'intera applicazione, dal browser al backend. Playwright è la scelta moderna per E2E grazie alla velocità, al supporto multi-browser e alle API intuitive.
Configurazione di Playwright
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['junit', { outputFile: 'test-results/e2e-results.xml' }]
],
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] }
}
],
webServer: {
command: 'npm run start',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 120000
}
});
Test E2E di un Flusso Utente
import { test, expect } from '@playwright/test';
test.describe('Flusso prodotti', () => {
test('utente cerca e aggiunge un prodotto al carrello', async ({ page }) => {
// Naviga alla pagina prodotti
await page.goto('/products');
// Attendi che i prodotti siano caricati
await expect(page.getByRole('list')).toBeVisible();
// Cerca un prodotto
await page.getByLabel('Cerca prodotti').fill('Laptop');
await page.waitForTimeout(300); // debounce
// Verifica i risultati filtrati
const products = page.locator('.product-card');
await expect(products).toHaveCount(1);
await expect(products.first()).toContainText('Laptop Pro');
// Aggiungi al carrello
await page.getByRole('button', { name: 'Aggiungi al carrello' }).click();
// Verifica notifica
await expect(
page.getByText('Prodotto aggiunto al carrello')
).toBeVisible();
// Vai al carrello
await page.getByRole('link', { name: 'Carrello' }).click();
// Verifica che il prodotto sia nel carrello
await expect(page.getByText('Laptop Pro')).toBeVisible();
await expect(page.getByText('999')).toBeVisible();
});
test('utente gestisce prodotto esaurito', async ({ page }) => {
await page.goto('/products');
await expect(page.getByRole('list')).toBeVisible();
// Cerca un prodotto esaurito
await page.getByLabel('Cerca prodotti').fill('TypeScript Handbook');
await page.waitForTimeout(300);
// Verifica che il pulsante sia disabilitato
const addButton = page.getByRole('button', { name: 'Aggiungi al carrello' });
await expect(addButton).toBeDisabled();
// Verifica l'etichetta di stato
await expect(page.getByText('Esaurito')).toBeVisible();
});
test('gestione degli errori di rete', async ({ page }) => {
// Intercetta la richiesta API e simula un errore
await page.route('/api/products', route =>
route.fulfill({ status: 500, body: 'Server Error' })
);
await page.goto('/products');
// Verifica il messaggio di errore
await expect(page.getByRole('alert')).toBeVisible();
// Rimuovi l'intercettazione
await page.unroute('/api/products');
// Clicca su Riprova
await page.getByRole('button', { name: 'Riprova' }).click();
// Verifica che i prodotti siano caricati
await expect(page.getByRole('list')).toBeVisible();
});
});
Strategie di Mocking
Il mocking è essenziale per isolare le unita sotto test. In Angular, le strategie
principali sono tre: mock manuali, spy automatici e HttpTestingController
per le chiamate HTTP.
Mock Manuali vs Spy
// 1. Mock Manuale: controllo totale
const mockAuthService: Partial<AuthService> = {
isAuthenticated: jest.fn().mockReturnValue(true),
getCurrentUser: jest.fn().mockReturnValue({
id: 'user-1',
name: 'Test User',
role: 'admin'
}),
logout: jest.fn()
};
// 2. Spy Automatico con jest.spyOn
const authService = TestBed.inject(AuthService);
jest.spyOn(authService, 'isAuthenticated').mockReturnValue(true);
jest.spyOn(authService, 'getCurrentUser').mockReturnValue(mockUser);
// 3. Auto-mock completo con jest.mock
jest.mock('./auth.service');
// Tutti i metodi diventano jest.fn() automaticamente
// 4. Factory con createSpyObj (stile Jasmine)
function createMockService<T>(methods: (keyof T)[]): jest.Mocked<T> {
const mock: any = {};
methods.forEach(method => {
mock[method] = jest.fn();
});
return mock as jest.Mocked<T>;
}
const mockService = createMockService<ProductService>([
'getProducts', 'getProductById', 'createProduct'
]);
Mocking di HttpClient con HttpTestingController
describe('ApiService - HTTP Testing avanzato', () => {
let service: ApiService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ApiService,
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting()
]
});
service = TestBed.inject(ApiService);
httpMock = TestBed.inject(HttpTestingController);
});
it('deve aggiungere il token di autenticazione', () => {
service.getProducts().subscribe();
const req = httpMock.expectOne('/api/products');
expect(req.request.headers.get('Authorization'))
.toBe('Bearer test-token');
});
it('deve gestire richieste concorrenti', () => {
// Simula due richieste simultanee
service.getProducts().subscribe();
service.getCategories().subscribe();
const requests = httpMock.match(r => r.url.startsWith('/api/'));
expect(requests.length).toBe(2);
requests[0].flush(mockProducts);
requests[1].flush(mockCategories);
});
it('deve gestire il retry su errore', () => {
service.getProductsWithRetry().subscribe({
next: (products) => {
expect(products).toEqual(mockProducts);
}
});
// Prima richiesta: fallisce
const req1 = httpMock.expectOne('/api/products');
req1.flush('Error', { status: 503, statusText: 'Service Unavailable' });
// Seconda richiesta (retry): successo
const req2 = httpMock.expectOne('/api/products');
req2.flush(mockProducts);
});
});
Testing di Codice Asincrono
Testare codice asincrono in Angular richiede attenzione: Observable, Promise, timer
e fakeAsync sono gli strumenti principali.
import { fakeAsync, tick, flush, waitForAsync } from '@angular/core/testing';
describe('Test asincroni', () => {
// fakeAsync: controlla il tempo manualmente
it('deve debounce la ricerca', fakeAsync(() => {
const service = TestBed.inject(SearchService);
const spy = jest.spyOn(service, 'search');
component.onSearchInput('ang');
tick(200); // Non ancora...
expect(spy).not.toHaveBeenCalled();
component.onSearchInput('angular');
tick(300); // Debounce di 300ms scaduto
expect(spy).toHaveBeenCalledWith('angular');
expect(spy).toHaveBeenCalledTimes(1);
}));
// Test con Observable che emettono nel tempo
it('deve gestire Observable con delay', fakeAsync(() => {
const results: string[] = [];
of('a', 'b', 'c').pipe(
concatMap(val => of(val).pipe(delay(100)))
).subscribe(val => results.push(val));
tick(100);
expect(results).toEqual(['a']);
tick(100);
expect(results).toEqual(['a', 'b']);
tick(100);
expect(results).toEqual(['a', 'b', 'c']);
}));
// waitForAsync: per test con Promise reali
it('deve risolvere dati dal servizio', waitForAsync(async () => {
const service = TestBed.inject(DataService);
const data = await firstValueFrom(service.getData());
expect(data).toBeDefined();
expect(data.length).toBeGreaterThan(0);
}));
// flush: risolve tutti i timer pendenti
it('deve completare tutte le animazioni', fakeAsync(() => {
component.startAnimation();
flush(); // Risolve tutti i setTimeout/setInterval
expect(component.animationComplete).toBe(true);
}));
});
Integrazione CI/CD
I test devono essere eseguiti automaticamente nella pipeline CI/CD per garantire che ogni commit e pull request non introduca regressioni.
name: Test Suite
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run test:ci
env:
CI: true
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./coverage/lcov.info
e2e-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run e2e
env:
CI: true
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
Strategie di Coverage
Metriche di Coverage Consigliate
| Tipo di Codice | Coverage Target | Motivazione |
|---|---|---|
| Servizi (business logic) | 90%+ | Logica critica, facilmente testabile |
| Componenti (interazione) | 80%+ | Comportamento utente, stati visivi |
| Pipe e Direttive | 95%+ | Funzioni pure, pochi edge case |
| Guard e Interceptor | 90%+ | Sicurezza, path critici |
| Modelli e Utility | 85%+ | Logica riutilizzata ovunque |
| Config e Routing | Non necessario | Dichiarativo, coperto da E2E |
Coverage Non e Tutto
Una coverage del 100% non significa che il codice è privo di bug. Un test che verifica solo che una funzione non lanci errori raggiunge il 100% ma non verifica la correttezza dei risultati. Concentratevi sulla qualità dei test, non solo sulla percentuale di copertura.
Conclusione
Una strategia di testing efficace in Angular combina diversi livelli di test, ciascuno con il proprio scopo. I punti fondamentali da ricordare sono:
- Segui il modello trophy: investi di più nei test di integrazione
- Usa Jest per unit test veloci e potenti di servizi e logica
- Usa Angular Testing Library per testare i componenti come un utente reale
- Usa Playwright per flussi E2E critici e test cross-browser
- Automatizza tutto nella pipeline CI/CD
- Concentrati sulla qualità dei test, non solo sulla coverage
Punti Chiave da Ricordare
- Trophy model: Investi di più nei test di integrazione
- Testing Library: Testa il comportamento, non l'implementazione
- HttpTestingController: Lo strumento essenziale per testare chiamate HTTP
- fakeAsync: Controlla il tempo nei test di codice asincrono
- Playwright: Test E2E veloci, paralleli e multi-browser
- CI/CD: Ogni commit deve passare attraverso la suite di test
Domande frequenti
Qual è la piramide dei test classica proposta da Mike Cohn?
La piramide dei test classica propone una distribuzione a piramide, con molti unit test alla base, meno test di integrazione al centro e pochi test E2E in cima.
Qual è il modello trophy di Kent C. Dodds e perché è spesso più efficace per le applicazioni Angular?
Il modello trophy ribalta parzialmente la visione della piramide dei test classica, proponendo di investire di più nei test di integrazione che offrono il miglior rapporto tra costo di scrittura e fiducia nel funzionamento dell'applicazione.
Come si configurano Jest per Angular?
La configurazione di Jest per Angular non è specificata nell'articolo, ma si presume che sia necessario seguire le istruzioni del framework per abilitare la compatibilità con Angular.
Quali sono le strategie principali di mocking in Angular?
Le strategie principali di mocking in Angular sono tre: mock manuali, spy automatici e per le chiamate HTTP.

Commenti
Caricamento commenti...
Accedi per commentare