feat: add Angular NgRx best practices documentation

This commit is contained in:
Dennis Hundertmark
2026-03-08 08:51:02 +01:00
parent 67dc823270
commit 2184971175
47 changed files with 8490 additions and 0 deletions
@@ -0,0 +1,722 @@
# 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();
});
```
@@ -0,0 +1,167 @@
# Vitest Setup and Migration Guide
## Vitest vs Jasmine Comparison
| Feature | Vitest | Jasmine/Karma |
| ---------- | ----------------------- | --------------------- |
| Speed | Faster (native ESM) | Slower |
| Watch mode | Instant feedback | Slower rebuilds |
| Mocking | `vi.fn()`, `vi.mock()` | `jasmine.createSpy()` |
| Assertions | `expect()` (Chai-style) | `expect()` (Jasmine) |
| UI | Built-in UI mode | Karma browser |
| Config | `angular.json` | `karma.conf.js` |
## Migration from Jasmine to Vitest
### Spy Migration
```typescript
// Jasmine
const spy = jasmine.createSpy("callback");
spy.and.returnValue("value");
expect(spy).toHaveBeenCalledWith("arg");
// Vitest
const spy = vi.fn();
spy.mockReturnValue("value");
expect(spy).toHaveBeenCalledWith("arg");
```
### SpyOn Migration
```typescript
// Jasmine
spyOn(service, "method").and.returnValue(of(data));
// Vitest
vi.spyOn(service, "method").mockReturnValue(of(data));
```
### createSpyObj Migration
```typescript
// Jasmine
const mockService = jasmine.createSpyObj("UserService", [
"getUser",
"updateUser",
]);
mockService.getUser.and.returnValue(of({ id: "1", name: "Test" }));
// Vitest
const mockService = {
getUser: vi.fn(),
updateUser: vi.fn(),
};
mockService.getUser.mockReturnValue(of({ id: "1", name: "Test" }));
```
### Async Testing Migration
```typescript
// Jasmine - using done callback
it("should load data", (done) => {
service.loadData().subscribe((data) => {
expect(data).toBeDefined();
done();
});
});
// Vitest - using async/await
it("should load data", async () => {
const data = await firstValueFrom(service.loadData());
expect(data).toBeDefined();
});
```
### Clock/Timer Migration
```typescript
// Jasmine
jasmine.clock().install();
jasmine.clock().tick(1000);
jasmine.clock().uninstall();
// Vitest
vi.useFakeTimers();
vi.advanceTimersByTime(1000);
vi.useRealTimers();
```
## Vitest Configuration Details
### Full angular.json Configuration
```json
{
"projects": {
"your-app": {
"architect": {
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "tsconfig.spec.json",
"buildTarget": "your-app:build"
}
}
}
}
}
}
```
### tsconfig.spec.json
```json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals"]
},
"include": ["src/**/*.spec.ts"]
}
```
### Optional vite.config.ts
For advanced configuration, create a `vite.config.ts`:
```typescript
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "jsdom",
include: ["src/**/*.spec.ts"],
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
exclude: [
"node_modules/",
"src/test-setup.ts",
"**/*.spec.ts",
"**/*.d.ts",
],
},
},
});
```
## Running Vitest
```bash
# Run tests
ng test
# Watch mode
ng test --watch
# Coverage
ng test --code-coverage
# Run specific file pattern
ng test --include='**/user*.spec.ts'
# CI mode (single run)
ng test --watch=false
```