723 lines
16 KiB
Markdown
723 lines
16 KiB
Markdown
# 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<void> {
|
|
const button = await this.getIncrementButton();
|
|
await button.click();
|
|
}
|
|
|
|
async decrement(): Promise<void> {
|
|
const button = await this.getDecrementButton();
|
|
await button.click();
|
|
}
|
|
|
|
// Queries
|
|
async getCount(): Promise<number> {
|
|
const display = await this.getCountDisplay();
|
|
const text = await display.text();
|
|
return parseInt(text, 10);
|
|
}
|
|
|
|
// Filter factory
|
|
static with(options: { count?: number } = {}): HarnessPredicate<CounterHarn> {
|
|
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<Auth>;
|
|
|
|
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: `
|
|
<form (submit)="onSubmit($event)">
|
|
<input [formField]="loginForm.email" />
|
|
<input [formField]="loginForm.password" type="password" />
|
|
<button type="submit" [disabled]="loginForm().invalid()">Submit</button>
|
|
</form>
|
|
`,
|
|
})
|
|
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<Login>;
|
|
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: `<p appHighlight="lightblue">Test</p>`,
|
|
})
|
|
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<boolean>({ alias: "appIf" });
|
|
|
|
constructor() {
|
|
effect(() => {
|
|
if (this.condition()) {
|
|
this.viewContainer.createEmbeddedView(this.templateRef);
|
|
} else {
|
|
this.viewContainer.clear();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
describe("If", () => {
|
|
@Component({
|
|
imports: [If],
|
|
template: `<p *appIf="show()">Visible</p>`,
|
|
})
|
|
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<T>(
|
|
fixture: ComponentFixture<any>,
|
|
inputName: string,
|
|
value: T,
|
|
): void {
|
|
fixture.componentRef.setInput(inputName, value);
|
|
fixture.detectChanges();
|
|
}
|
|
|
|
export async function waitForSignal<T>(
|
|
signal: () => T,
|
|
predicate: (value: T) => boolean,
|
|
timeout = 5000,
|
|
): Promise<T> {
|
|
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();
|
|
});
|
|
```
|