Skip to main content

6 - Testing Architecture: Strategies for Angular Apps

Testing strategies for Angular: test pyramid, unit testing with Jest, component testing, E2E with Playwright, mocking and CI integration.

What do you think about this article?

At a Glance

Testing strategies for Angular: test pyramid, unit testing with Jest, component testing, E2E with Playwright, mocking and CI integration.

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

jest.config.ts

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

product.service.ts

@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);
  }
}
    
product.service.spec.ts - Complete unit test

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

product-list.component.ts

@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
}
    
product-list.component.spec.ts - Testing Library tests

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

playwright.config.ts

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

e2e/product-flow.spec.ts

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

Comparing mocking approaches

// 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

Advanced test 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.

Async tests with fakeAsync and waitForAsync

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.

.github/workflows/test.yml - Complete test pipeline

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:

  • 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.

Sources and further reading

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects