# 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(); }); ```