Testing Architecture: Strategies for Angular Apps
A solid testing strategy is the foundation of any maintainable Angular application. Without reliable tests, every refactoring becomes a risk, every deployment a gamble. But testing does not mean writing tests randomly: you need a well-defined testing architecture, with clear strategies for every level of the application.
In this article, we will explore the test pyramid, the trophy model, and see how to test services, components, and end-to-end flows in Angular using Jest, Angular Testing Library, and Playwright.
What You Will Learn
- The test pyramid and Kent C. Dodds' trophy model
- Unit testing Angular services with Jest
- Component testing with Angular Testing Library
- E2E testing with Playwright
- Mocking strategies for HttpClient and services
- Testing asynchronous code: Observables and Promises
- Integrating tests into the CI/CD pipeline
Test Pyramid vs Trophy Model
The classic test pyramid, proposed by Mike Cohn, suggests a pyramidal distribution: many unit tests at the base, fewer integration tests in the middle, and a handful of E2E tests at the top. The idea is that the fastest, most isolated tests should make up the majority.
Kent C. Dodds' trophy model partially flips this view, proposing to invest more in integration tests, which offer the best ratio of writing cost to confidence in the application's correctness.
Comparison of the Two Models
| Level | Pyramid (Cohn) | Trophy (Dodds) | Speed | Confidence |
|---|---|---|---|---|
| Static Analysis | Not included | Trophy base | Instant | Low |
| Unit Tests | 70% of tests | 20% of tests | Very high | Medium |
| Integration Tests | 20% of tests | 60% of tests | High | High |
| E2E Tests | 10% of tests | 20% of tests | Low | Very high |
Which Model to Follow?
For Angular applications, the trophy model is often more effective. Component integration tests (which verify templates, bindings, and interactions) catch more real bugs than pure unit tests of isolated classes. The recommendation is: write tests at the highest level possible that remains fast and reliable.
Unit Testing Services with Jest
Angular services contain business logic and API communication. Testing a service means verifying that the logic works correctly in isolation from its dependencies.
Configuring Jest for 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;
Testing a Service with 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('Percentage must be between 0 and 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(() => {
// Verify no pending HTTP requests
httpMock.verify();
});
describe('getProducts', () => {
it('should load all products', () => {
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('should filter by category', () => {
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('should handle HTTP errors', () => {
service.getProducts().subscribe({
next: () => fail('Should have failed'),
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('should calculate discount correctly', () => {
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('should throw error for invalid percentage', () => {
expect(() => service.calculateDiscount(product, -10))
.toThrow('Percentage must be between 0 and 100');
expect(() => service.calculateDiscount(product, 110))
.toThrow('Percentage must be between 0 and 100');
});
});
});
Component Testing with Angular Testing Library
Angular Testing Library follows the Testing Library philosophy: test components the way a real user would use them, interacting with the DOM through roles, visible text, and accessible labels, rather than through implementation details.
Testing a Component with Interactions
@Component({
standalone: true,
selector: 'app-product-list',
template: `
<div class="product-list">
<input
type="text"
placeholder="Search products..."
[ngModel]="searchTerm"
(ngModelChange)="onSearch($event)"
aria-label="Search products"
/>
<div class="filters">
<select
[ngModel]="selectedCategory"
(ngModelChange)="onCategoryChange($event)"
aria-label="Filter by category">
<option value="">All categories</option>
<option *ngFor="let cat of categories" [value]="cat">
{{ cat }}
</option>
</select>
</div>
<div *ngIf="loading" role="status">Loading...</div>
<div *ngIf="error" role="alert" class="error">
{{ error }}
<button (click)="reload()">Retry</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 ? 'In Stock' : 'Out of Stock' }}
</span>
<button (click)="addToCart(product)"
[disabled]="!product.inStock">
Add to Cart
</button>
</li>
</ul>
<p *ngIf="filteredProducts.length === 0 && !loading">
No products found
</p>
</div>
`,
imports: [CommonModule, FormsModule]
})
export class ProductListComponent implements OnInit {
// ... implementation
}
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('should display the product list', async () => {
await setup();
expect(screen.getByText('Laptop Pro')).toBeTruthy();
expect(screen.getByText('Wireless Mouse')).toBeTruthy();
expect(screen.getByText('TypeScript Handbook')).toBeTruthy();
});
it('should display availability status', async () => {
await setup();
const available = screen.getAllByText('In Stock');
const outOfStock = screen.getAllByText('Out of Stock');
expect(available.length).toBe(2);
expect(outOfStock.length).toBe(1);
});
it('should filter products with search', async () => {
await setup();
const searchInput = screen.getByLabelText('Search products');
fireEvent.input(searchInput, { target: { value: 'Laptop' } });
await waitFor(() => {
expect(screen.getByText('Laptop Pro')).toBeTruthy();
expect(screen.queryByText('Wireless Mouse')).toBeNull();
});
});
it('should disable button for out-of-stock products', async () => {
await setup();
const buttons = screen.getAllByText('Add to Cart');
// Third product is out of stock
expect(buttons[2]).toBeDisabled();
expect(buttons[0]).not.toBeDisabled();
});
it('should add to cart when button is clicked', async () => {
await setup();
const buttons = screen.getAllByText('Add to Cart');
fireEvent.click(buttons[0]);
expect(mockCartService.addItem).toHaveBeenCalledWith(mockProducts[0]);
});
it('should show loading state', async () => {
// Simulate slow loading
const neverResolve = new Observable<Product[]>(() => {});
await setup({ getProducts: () => neverResolve });
expect(screen.getByText('Loading...')).toBeTruthy();
});
it('should show error and allow retry', async () => {
mockProductService.getProducts
.mockReturnValueOnce(throwError(() => new Error('Network error')))
.mockReturnValueOnce(of(mockProducts));
await setup();
// Verify error message
expect(screen.getByRole('alert')).toBeTruthy();
// Click Retry
fireEvent.click(screen.getByText('Retry'));
await waitFor(() => {
expect(screen.getByText('Laptop Pro')).toBeTruthy();
});
});
it('should show message when no results found', async () => {
await setup();
const searchInput = screen.getByLabelText('Search products');
fireEvent.input(searchInput, { target: { value: 'xyznonexistent' } });
await waitFor(() => {
expect(screen.getByText('No products found')).toBeTruthy();
});
});
});
E2E Testing with Playwright
End-to-end tests verify the entire application works correctly, from the browser to the backend. Playwright is the modern choice for E2E thanks to its speed, multi-browser support, and intuitive APIs.
Configuring 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
}
});
E2E Test of a User Flow
import { test, expect } from '@playwright/test';
test.describe('Product flow', () => {
test('user searches and adds a product to cart', async ({ page }) => {
// Navigate to products page
await page.goto('/products');
// Wait for products to load
await expect(page.getByRole('list')).toBeVisible();
// Search for a product
await page.getByLabel('Search products').fill('Laptop');
await page.waitForTimeout(300); // debounce
// Verify filtered results
const products = page.locator('.product-card');
await expect(products).toHaveCount(1);
await expect(products.first()).toContainText('Laptop Pro');
// Add to cart
await page.getByRole('button', { name: 'Add to Cart' }).click();
// Verify notification
await expect(
page.getByText('Product added to cart')
).toBeVisible();
// Go to cart
await page.getByRole('link', { name: 'Cart' }).click();
// Verify the product is in the cart
await expect(page.getByText('Laptop Pro')).toBeVisible();
await expect(page.getByText('999')).toBeVisible();
});
test('user handles out-of-stock product', async ({ page }) => {
await page.goto('/products');
await expect(page.getByRole('list')).toBeVisible();
// Search for an out-of-stock product
await page.getByLabel('Search products').fill('TypeScript Handbook');
await page.waitForTimeout(300);
// Verify the button is disabled
const addButton = page.getByRole('button', { name: 'Add to Cart' });
await expect(addButton).toBeDisabled();
// Verify the status label
await expect(page.getByText('Out of Stock')).toBeVisible();
});
test('network error handling', async ({ page }) => {
// Intercept the API request and simulate an error
await page.route('/api/products', route =>
route.fulfill({ status: 500, body: 'Server Error' })
);
await page.goto('/products');
// Verify the error message
await expect(page.getByRole('alert')).toBeVisible();
// Remove the intercept
await page.unroute('/api/products');
// Click Retry
await page.getByRole('button', { name: 'Retry' }).click();
// Verify products are loaded
await expect(page.getByRole('list')).toBeVisible();
});
});
Mocking Strategies
Mocking is essential to isolate units under test. In Angular, the main strategies are
three: manual mocks, automatic spies, and HttpTestingController
for HTTP calls.
Manual Mocks vs Spies
// 1. Manual Mock: total control
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. Automatic Spy with jest.spyOn
const authService = TestBed.inject(AuthService);
jest.spyOn(authService, 'isAuthenticated').mockReturnValue(true);
jest.spyOn(authService, 'getCurrentUser').mockReturnValue(mockUser);
// 3. Full auto-mock with jest.mock
jest.mock('./auth.service');
// All methods become jest.fn() automatically
// 4. Factory with createSpyObj (Jasmine-style)
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 HttpClient with HttpTestingController
describe('ApiService - Advanced HTTP Testing', () => {
let service: ApiService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ApiService,
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting()
]
});
service = TestBed.inject(ApiService);
httpMock = TestBed.inject(HttpTestingController);
});
it('should add the authentication token', () => {
service.getProducts().subscribe();
const req = httpMock.expectOne('/api/products');
expect(req.request.headers.get('Authorization'))
.toBe('Bearer test-token');
});
it('should handle concurrent requests', () => {
// Simulate two simultaneous requests
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('should handle retry on error', () => {
service.getProductsWithRetry().subscribe({
next: (products) => {
expect(products).toEqual(mockProducts);
}
});
// First request: fails
const req1 = httpMock.expectOne('/api/products');
req1.flush('Error', { status: 503, statusText: 'Service Unavailable' });
// Second request (retry): succeeds
const req2 = httpMock.expectOne('/api/products');
req2.flush(mockProducts);
});
});
Testing Asynchronous Code
Testing asynchronous code in Angular requires attention: Observables, Promises, timers,
and fakeAsync are the main tools.
import { fakeAsync, tick, flush, waitForAsync } from '@angular/core/testing';
describe('Async tests', () => {
// fakeAsync: manually control time
it('should debounce the search', fakeAsync(() => {
const service = TestBed.inject(SearchService);
const spy = jest.spyOn(service, 'search');
component.onSearchInput('ang');
tick(200); // Not yet...
expect(spy).not.toHaveBeenCalled();
component.onSearchInput('angular');
tick(300); // 300ms debounce expired
expect(spy).toHaveBeenCalledWith('angular');
expect(spy).toHaveBeenCalledTimes(1);
}));
// Test with time-delayed Observable emissions
it('should handle Observable with 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: for tests with real Promises
it('should resolve data from the service', waitForAsync(async () => {
const service = TestBed.inject(DataService);
const data = await firstValueFrom(service.getData());
expect(data).toBeDefined();
expect(data.length).toBeGreaterThan(0);
}));
// flush: resolve all pending timers
it('should complete all animations', fakeAsync(() => {
component.startAnimation();
flush(); // Resolves all setTimeout/setInterval
expect(component.animationComplete).toBe(true);
}));
});
CI/CD Integration
Tests must run automatically in the CI/CD pipeline to ensure that every commit and pull request does not introduce regressions.
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/
Coverage Strategies
Recommended Coverage Metrics
| Code Type | Coverage Target | Rationale |
|---|---|---|
| Services (business logic) | 90%+ | Critical logic, easily testable |
| Components (interaction) | 80%+ | User behavior, visual states |
| Pipes and Directives | 95%+ | Pure functions, few edge cases |
| Guards and Interceptors | 90%+ | Security, critical paths |
| Models and Utilities | 85%+ | Logic reused everywhere |
| Config and Routing | Not necessary | Declarative, covered by E2E |
Coverage Is Not Everything
100% coverage does not mean the code is bug-free. A test that only verifies a function does not throw errors achieves 100% but does not verify the correctness of results. Focus on test quality, not just the coverage percentage.
Conclusion
An effective testing strategy in Angular combines different levels of tests, each with its own purpose. The key points to remember are:
Related deep dive: 5 - REST vs GraphQL vs tRPC: Quando Usare Cosa.
- Follow the trophy model: invest more in integration tests
- Use Jest for fast and powerful unit tests of services and logic
- Use Angular Testing Library to test components like a real user
- Use Playwright for critical E2E flows and cross-browser testing
- Automate everything in the CI/CD pipeline
- Focus on test quality, not just coverage
Key Takeaways
- Trophy model: Invest more in integration tests
- Testing Library: Test behavior, not implementation
- HttpTestingController: The essential tool for testing HTTP calls
- fakeAsync: Control time in asynchronous code tests
- Playwright: Fast, parallel, multi-browser E2E tests
- CI/CD: Every commit should pass through the test suite
Frequently asked questions
What is the main goal of a well-defined testing architecture in an Angular application?
A well-defined testing architecture helps ensure that every refactoring and deployment is reliable, reducing the risk of introducing bugs.
What are the key differences between the test pyramid and the trophy model in terms of testing strategies?
The test pyramid suggests a pyramidal distribution with many unit tests at the base, while the trophy model recommends investing more in integration tests for better confidence in application correctness.
Why is it recommended to write component integration tests instead of pure unit tests in Angular applications?
Component integration tests catch more real bugs than pure unit tests by verifying templates, bindings, and interactions.
What is the main purpose of end-to-end testing with tools like Playwright in an Angular application?
End-to-end testing verifies that the entire application works correctly from the browser to the backend.
Commenti
Caricamento commenti...
Sign in to comment