# Angular Testing Patterns ## Table of Contents - [Vitest Advanced Patterns](#vitest-advanced-patterns) - [Component Harnesses](#component-harnesses) - [Testing Router](#testing-router) - [Testing Forms](#testing-forms) - [Testing Directives](#testing-directives) - [Testing Pipes](#testing-pipes) - [E2E Testing Setup](#e2e-testing-setup) ## Vitest Advanced Patterns ### Snapshot Testing ```typescript import { describe, it, expect } from 'vitest'; describe('UserCard', () => { it('should match snapshot', () => { const fixture = TestBed.createComponent(UserCard); fixture.componentRef.setInput('user', { id: '1', name: 'John', email: 'john@example.com', }); fixture.detectChanges(); expect(fixture.nativeElement.innerHTML).toMatchSnapshot(); }); }); ``` ### Parameterized Tests ```typescript import { describe, it, expect } from 'vitest'; describe('Validator', () => { it.each([ { input: '', expected: false }, { input: 'test', expected: false }, { input: 'test@example.com', expected: true }, { input: 'invalid@', expected: false }, ])('should validate email "$input" as $expected', ({ input, expected }) => { expect(isValidEmail(input)).toBe(expected); }); }); ``` ### Testing with Fake Timers ```typescript import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; describe('Debounced Search', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('should debounce search input', async () => { const fixture = TestBed.createComponent(Search); fixture.detectChanges(); fixture.componentInstance.query.set('test'); // Search not called yet expect(fixture.componentInstance.results()).toEqual([]); // Advance timers vi.advanceTimersByTime(300); await fixture.whenStable(); fixture.detectChanges(); expect(fixture.componentInstance.results().length).toBeGreaterThan(0); }); }); ``` ### Module Mocking ```typescript import { describe, it, expect, vi } from 'vitest'; // Mock entire module vi.mock('./analytics.service', () => ({ Analytics: class { track = vi.fn(); identify = vi.fn(); }, })); describe('with mocked analytics', () => { it('should track events', () => { const fixture = TestBed.createComponent(Dashboard); const analytics = TestBed.inject(Analytics); fixture.detectChanges(); expect(analytics.track).toHaveBeenCalledWith('dashboard_viewed'); }); }); ``` ### Testing Async/Await ```typescript import { describe, it, expect, vi } from 'vitest'; describe('User', () => { it('should load user data', async () => { const mockUser = { id: '1', name: 'Test' }; const httpMock = TestBed.inject(HttpTestingController); const service = TestBed.inject(User); const userPromise = service.loadUser('1'); httpMock.expectOne('/api/users/1').flush(mockUser); const user = await userPromise; expect(user).toEqual(mockUser); }); }); ``` ### Coverage Configuration ```typescript // vite.config.ts export default defineConfig({ test: { coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov'], exclude: ['node_modules/', 'src/test-setup.ts', '**/*.spec.ts', '**/*.d.ts'], thresholds: { statements: 80, branches: 80, functions: 80, lines: 80, }, }, }, }); ``` ### Vitest UI Mode ```bash # Run with UI npx vitest --ui # Open UI at specific port npx vitest --ui --port 51204 ``` ### Concurrent Tests ```typescript import { describe, it, expect } from 'vitest'; // Run tests in this describe block concurrently describe.concurrent('API calls', () => { it('should fetch users', async () => { // ... }); it('should fetch products', async () => { // ... }); it('should fetch orders', async () => { // ... }); }); ``` ### Test Fixtures ```typescript import { describe, it, expect, beforeEach } from 'vitest'; // Shared test fixtures const createTestUser = (overrides = {}) => ({ id: '1', name: 'Test User', email: 'test@example.com', ...overrides, }); const createTestProduct = (overrides = {}) => ({ id: '1', name: 'Test Product', price: 99.99, ...overrides, }); describe('Order', () => { it('should calculate total', () => { const fixture = TestBed.createComponent(Order); fixture.componentRef.setInput('user', createTestUser()); fixture.componentRef.setInput('products', [createTestProduct({ price: 10 }), createTestProduct({ id: '2', price: 20 })]); fixture.detectChanges(); expect(fixture.componentInstance.total()).toBe(30); }); }); ``` ## Component Harnesses Use Angular CDK component harnesses for more maintainable tests: ### Creating a Harness ```typescript import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing'; export class CounterHarn extends ComponentHarness { static hostSelector = 'app-counter'; // Locators private getIncrementButton = this.locatorFor('button.increment'); private getDecrementButton = this.locatorFor('button.decrement'); private getCountDisplay = this.locatorFor('.count'); // Actions async increment(): Promise { const button = await this.getIncrementButton(); await button.click(); } async decrement(): Promise { const button = await this.getDecrementButton(); await button.click(); } // Queries async getCount(): Promise { const display = await this.getCountDisplay(); const text = await display.text(); return parseInt(text, 10); } // Filter factory static with(options: { count?: number } = {}): HarnessPredicate { return new HarnessPredicate(CounterHarn, options).addOption('count', options.count, async (harness, count) => { return (await harness.getCount()) === count; }); } } ``` ### Using Harnesses in Tests ```typescript import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; describe('Counter with Harness', () => { let loader: HarnessLoader; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [Counter], }).compileComponents(); const fixture = TestBed.createComponent(Counter); loader = TestbedHarnessEnvironment.loader(fixture); }); it('should increment count', async () => { const counter = await loader.getHarness(CounterHarn); expect(await counter.getCount()).toBe(0); await counter.increment(); expect(await counter.getCount()).toBe(1); await counter.increment(); expect(await counter.getCount()).toBe(2); }); it('should find counter with specific count', async () => { const counter = await loader.getHarness(CounterHarn); await counter.increment(); await counter.increment(); // Find counter with count of 2 const counterWith2 = await loader.getHarness(CounterHarn.with({ count: 2 })); expect(counterWith2).toBeTruthy(); }); }); ``` ## Testing Router ### RouterTestingHarness ```typescript import { RouterTestingHarness } from '@angular/router/testing'; import { provideRouter } from '@angular/router'; describe('Router Navigation', () => { let harness: RouterTestingHarness; beforeEach(async () => { await TestBed.configureTestingModule({ providers: [ provideRouter([ { path: '', component: Home }, { path: 'users/:id', component: UserCmpt }, ]), ], }).compileComponents(); harness = await RouterTestingHarness.create(); }); it('should navigate to user page', async () => { const component = await harness.navigateByUrl('/users/123', UserCmpt); expect(component.id()).toBe('123'); }); it('should display user name', async () => { await harness.navigateByUrl('/users/123'); expect(harness.routeNativeElement?.textContent).toContain('User 123'); }); }); ``` ### Testing Guards ```typescript describe('AuthGuard', () => { let authService: jasmine.SpyObj; beforeEach(() => { authService = jasmine.createSpyObj('Auth', ['isAuthenticated']); TestBed.configureTestingModule({ providers: [ { provide: Auth, useValue: authService }, provideRouter([ { path: 'login', component: Login }, { path: 'dashboard', component: Dashboard, canActivate: [authGuard], }, ]), ], }); }); it('should allow access when authenticated', async () => { authService.isAuthenticated.and.returnValue(true); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/dashboard'); expect(harness.routeNativeElement?.textContent).toContain('Dashboard'); }); it('should redirect to login when not authenticated', async () => { authService.isAuthenticated.and.returnValue(false); const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/dashboard'); expect(TestBed.inject(Router).url).toBe('/login'); }); }); ``` ## Testing Forms ### Testing Signal Forms ```typescript import { form, FormField, required, email } from '@angular/forms/signals'; @Component({ imports: [FormField], template: `
`, }) export class Login { model = signal({ email: '', password: '' }); loginForm = form(this.model, (schemaPath) => { required(schemaPath.email); email(schemaPath.email); required(schemaPath.password); }); submitted = signal(false); onSubmit(event: Event) { event.preventDefault(); if (this.loginForm().valid()) { this.submitted.set(true); } } } describe('Login', () => { let fixture: ComponentFixture; let component: Login; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [Login], }).compileComponents(); fixture = TestBed.createComponent(Login); component = fixture.componentInstance; fixture.detectChanges(); }); it('should be invalid when empty', () => { expect(component.loginForm().invalid()).toBeTrue(); }); it('should be valid with correct data', () => { component.model.set({ email: 'test@example.com', password: 'password123', }); expect(component.loginForm().valid()).toBeTrue(); }); it('should show email error for invalid email', () => { component.loginForm.email().value.set('invalid'); fixture.detectChanges(); expect(component.loginForm.email().invalid()).toBeTrue(); expect( component.loginForm .email() .errors() .some((e) => e.kind === 'email'), ).toBeTrue(); }); it('should disable submit button when invalid', () => { const button = fixture.nativeElement.querySelector('button'); expect(button.disabled).toBeTrue(); }); }); ``` ### Testing Reactive Forms ```typescript describe('ReactiveForm', () => { it('should validate form', () => { const fixture = TestBed.createComponent(ProfileForm); const component = fixture.componentInstance; expect(component.form.valid).toBeFalse(); component.form.patchValue({ name: 'John', email: 'john@example.com', }); expect(component.form.valid).toBeTrue(); }); it('should show validation errors', () => { const fixture = TestBed.createComponent(ProfileForm); fixture.detectChanges(); const emailControl = fixture.componentInstance.form.controls.email; emailControl.setValue('invalid'); emailControl.markAsTouched(); fixture.detectChanges(); const errorElement = fixture.nativeElement.querySelector('.error'); expect(errorElement.textContent).toContain('Invalid email'); }); }); ``` ## Testing Directives ### Attribute Directive ```typescript @Directive({ selector: '[appHighlight]', host: { '[style.backgroundColor]': 'color()', }, }) export class Highlight { color = input('yellow', { alias: 'appHighlight' }); } describe('Highlight', () => { @Component({ imports: [Highlight], template: `

Test

`, }) class Test {} it('should apply background color', () => { const fixture = TestBed.createComponent(Test); fixture.detectChanges(); const p = fixture.nativeElement.querySelector('p'); expect(p.style.backgroundColor).toBe('lightblue'); }); }); ``` ### Structural Directive ```typescript @Directive({ selector: '[appIf]', }) export class If { private templateRef = inject(TemplateRef); private viewContainer = inject(ViewContainerRef); condition = input.required({ alias: 'appIf' }); constructor() { effect(() => { if (this.condition()) { this.viewContainer.createEmbeddedView(this.templateRef); } else { this.viewContainer.clear(); } }); } } describe('If', () => { @Component({ imports: [If], template: `

Visible

`, }) class TestCmpt { show = signal(false); } it('should show content when condition is true', () => { const fixture = TestBed.createComponent(Test); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('p')).toBeNull(); fixture.componentInstance.show.set(true); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('p')).toBeTruthy(); }); }); ``` ## Testing Pipes ```typescript @Pipe({ name: 'truncate' }) export class Truncate implements PipeTransform { transform(value: string, length: number = 50): string { if (value.length <= length) return value; return value.substring(0, length) + '...'; } } describe('Truncate', () => { let pipe: Truncate; beforeEach(() => { pipe = new Truncate(); }); it('should not truncate short strings', () => { expect(pipe.transform('Hello', 10)).toBe('Hello'); }); it('should truncate long strings', () => { expect(pipe.transform('Hello World', 5)).toBe('Hello...'); }); it('should use default length', () => { const longString = 'a'.repeat(60); const result = pipe.transform(longString); expect(result.length).toBe(53); // 50 + '...' }); }); ``` ## E2E Testing Setup ### Playwright Configuration ```typescript // playwright.config.ts import { defineConfig } 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', use: { baseURL: 'http://localhost:4200', trace: 'on-first-retry', }, webServer: { command: 'npm run start', url: 'http://localhost:4200', reuseExistingServer: !process.env.CI, }, }); ``` ### E2E Test Example ```typescript // e2e/login.spec.ts import { test, expect } from '@playwright/test'; test.describe('Login', () => { test('should login successfully', async ({ page }) => { await page.goto('/login'); await page.fill('input[name="email"]', 'test@example.com'); await page.fill('input[name="password"]', 'password123'); await page.click('button[type="submit"]'); await expect(page).toHaveURL('/dashboard'); await expect(page.locator('h1')).toContainText('Welcome'); }); test('should show error for invalid credentials', async ({ page }) => { await page.goto('/login'); await page.fill('input[name="email"]', 'wrong@example.com'); await page.fill('input[name="password"]', 'wrongpassword'); await page.click('button[type="submit"]'); await expect(page.locator('.error')).toBeVisible(); await expect(page.locator('.error')).toContainText('Invalid credentials'); }); }); ``` ## Test Utilities ### Custom Test Helpers ```typescript // test-utils.ts export function setSignalInput(fixture: ComponentFixture, inputName: string, value: T): void { fixture.componentRef.setInput(inputName, value); fixture.detectChanges(); } export async function waitForSignal(signal: () => T, predicate: (value: T) => boolean, timeout = 5000): Promise { const start = Date.now(); while (Date.now() - start < timeout) { const value = signal(); if (predicate(value)) return value; await new Promise((resolve) => setTimeout(resolve, 10)); } throw new Error('Timeout waiting for signal'); } // Usage it('should load data', async () => { const fixture = TestBed.createComponent(Data); fixture.detectChanges(); await waitForSignal( () => fixture.componentInstance.data(), (data) => data !== undefined, ); expect(fixture.componentInstance.data()).toBeDefined(); }); ```