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
+459
View File
@@ -0,0 +1,459 @@
---
name: angular-testing
description: Write unit and integration tests for Angular v20+ applications using Vitest or Jasmine with TestBed and modern testing patterns. Use for testing components with signals, OnPush change detection, services with inject(), and HTTP interactions. Triggers on test creation, testing signal-based components, mocking dependencies, or setting up test infrastructure. Don't use for E2E testing with Cypress or Playwright, or for testing non-Angular JavaScript/TypeScript code.
---
# Angular Testing
Test Angular v20+ applications with Vitest (recommended) or Jasmine, focusing on signal-based components and modern patterns.
## Vitest Setup (Angular v20+)
Angular v20+ has native Vitest support through the `@angular/build` package.
```bash
npm install -D vitest jsdom
```
Configure in angular.json:
```json
{
"projects": {
"your-app": {
"architect": {
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "tsconfig.spec.json",
"buildTarget": "your-app:build"
}
}
}
}
}
}
```
Run tests:
```bash
ng test # Run tests
ng test --watch # Watch mode
ng test --code-coverage # With coverage
```
For Vitest migration from Jasmine and advanced configuration, see [references/vitest-migration.md](references/vitest-migration.md).
## Basic Component Test
```typescript
import { describe, it, expect, beforeEach } from "vitest";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Counter } from "./counter.component";
describe("Counter", () => {
let component: Counter;
let fixture: ComponentFixture<Counter>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Counter], // Standalone component
}).compileComponents();
fixture = TestBed.createComponent(Counter);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should increment count", () => {
expect(component.count()).toBe(0);
component.increment();
expect(component.count()).toBe(1);
});
it("should display count in template", () => {
component.count.set(5);
fixture.detectChanges();
const element = fixture.nativeElement.querySelector(".count");
expect(element.textContent).toContain("5");
});
});
```
## Testing Signals
### Direct Signal Testing
```typescript
import { signal, computed } from "@angular/core";
describe("Signal logic", () => {
it("should update computed when signal changes", () => {
const count = signal(0);
const doubled = computed(() => count() * 2);
expect(doubled()).toBe(0);
count.set(5);
expect(doubled()).toBe(10);
count.update((c) => c + 1);
expect(doubled()).toBe(12);
});
});
```
### Testing Component Signals
```typescript
@Component({
selector: "app-todo-list",
template: `
<ul>
@for (todo of filteredTodos(); track todo.id) {
<li>{{ todo.text }}</li>
}
</ul>
<p>{{ remaining() }} remaining</p>
`,
})
export class TodoList {
todos = signal<Todo[]>([]);
filter = signal<"all" | "active" | "done">("all");
filteredTodos = computed(() => {
const todos = this.todos();
switch (this.filter()) {
case "active":
return todos.filter((t) => !t.done);
case "done":
return todos.filter((t) => t.done);
default:
return todos;
}
});
remaining = computed(() => this.todos().filter((t) => !t.done).length);
}
describe("TodoList", () => {
let component: TodoList;
let fixture: ComponentFixture<TodoList>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TodoList],
}).compileComponents();
fixture = TestBed.createComponent(TodoList);
component = fixture.componentInstance;
});
it("should filter active todos", () => {
component.todos.set([
{ id: "1", text: "Task 1", done: false },
{ id: "2", text: "Task 2", done: true },
{ id: "3", text: "Task 3", done: false },
]);
component.filter.set("active");
expect(component.filteredTodos().length).toBe(2);
expect(component.remaining()).toBe(2);
});
});
```
## Testing OnPush Components
OnPush components require explicit change detection:
```typescript
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<span>{{ data().name }}</span>`,
})
export class OnPushCmpt {
data = input.required<{ name: string }>();
}
describe("OnPushCmpt", () => {
it("should update when input signal changes", () => {
const fixture = TestBed.createComponent(OnPushCmpt);
// Set input using setInput (for signal inputs)
fixture.componentRef.setInput("data", { name: "Initial" });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain("Initial");
// Update input
fixture.componentRef.setInput("data", { name: "Updated" });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain("Updated");
});
});
```
## Testing Services
### Basic Service Test
```typescript
@Injectable({ providedIn: "root" })
export class CounterService {
private _count = signal(0);
readonly count = this._count.asReadonly();
increment() {
this._count.update((c) => c + 1);
}
reset() {
this._count.set(0);
}
}
describe("CounterService", () => {
let service: CounterService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CounterService);
});
it("should increment count", () => {
expect(service.count()).toBe(0);
service.increment();
expect(service.count()).toBe(1);
});
});
```
### Service with HTTP
```typescript
import {
HttpTestingController,
provideHttpClientTesting,
} from "@angular/common/http/testing";
import { provideHttpClient } from "@angular/common/http";
describe("UserService", () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Verify no outstanding requests
});
it("should fetch user by id", () => {
const mockUser = { id: "1", name: "Test User" };
service.getUser("1").subscribe((user) => {
expect(user).toEqual(mockUser);
});
const req = httpMock.expectOne("/api/users/1");
expect(req.request.method).toBe("GET");
req.flush(mockUser);
});
});
```
## Mocking Dependencies
### Using Vitest Mocks
```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
describe("UserProfile", () => {
const mockUserService = {
getUser: vi.fn(),
updateUser: vi.fn(),
user: signal<User | null>(null),
};
beforeEach(async () => {
vi.clearAllMocks();
mockUserService.getUser.mockReturnValue(of({ id: "1", name: "Test" }));
await TestBed.configureTestingModule({
imports: [UserProfile],
providers: [{ provide: UserService, useValue: mockUserService }],
}).compileComponents();
});
it("should call getUser on init", () => {
const fixture = TestBed.createComponent(UserProfile);
fixture.detectChanges();
expect(mockUserService.getUser).toHaveBeenCalledWith("1");
});
});
```
### Mock Signal-Based Service
```typescript
const mockAuth = {
user: signal<User | null>(null),
isAuthenticated: computed(() => mockAuth.user() !== null),
login: vi.fn(),
logout: vi.fn(),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProtectedPage],
providers: [{ provide: AuthService, useValue: mockAuth }],
}).compileComponents();
});
it("should show content when authenticated", () => {
mockAuth.user.set({ id: "1", name: "Test User" });
const fixture = TestBed.createComponent(ProtectedPage);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(".protected-content"),
).toBeTruthy();
});
```
## Testing Inputs and Outputs
```typescript
@Component({
selector: "app-item",
template: `<div (click)="select()">{{ item().name }}</div>`,
})
export class ItemCmpt {
item = input.required<Item>();
selected = output<Item>();
select() {
this.selected.emit(this.item());
}
}
describe("ItemCmpt", () => {
it("should emit selected event on click", () => {
const fixture = TestBed.createComponent(ItemCmpt);
const item: Item = { id: "1", name: "Test Item" };
fixture.componentRef.setInput("item", item);
fixture.detectChanges();
let emittedItem: Item | undefined;
fixture.componentInstance.selected.subscribe((i) => (emittedItem = i));
fixture.nativeElement.querySelector("div").click();
expect(emittedItem).toEqual(item);
});
});
```
## Testing Async Operations
### Using fakeAsync
```typescript
import { fakeAsync, tick, flush } from "@angular/core/testing";
it("should debounce search", fakeAsync(() => {
const fixture = TestBed.createComponent(SearchCmpt);
fixture.detectChanges();
fixture.componentInstance.query.set("test");
tick(300); // Advance time for debounce
fixture.detectChanges();
expect(fixture.componentInstance.results().length).toBeGreaterThan(0);
flush(); // Flush remaining timers
}));
```
### Using waitForAsync
```typescript
import { waitForAsync } from "@angular/core/testing";
it("should load data", waitForAsync(() => {
const fixture = TestBed.createComponent(DataCmpt);
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(fixture.componentInstance.data()).toBeDefined();
});
}));
```
## Testing HTTP Resources
```typescript
@Component({
template: `
@if (userResource.isLoading()) {
<p>Loading...</p>
} @else if (userResource.hasValue()) {
<p>{{ userResource.value().name }}</p>
}
`,
})
export class UserCmpt {
userId = signal("1");
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
}
describe("UserCmpt", () => {
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserCmpt],
providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
});
it("should display user name after loading", () => {
const fixture = TestBed.createComponent(UserCmpt);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain("Loading");
const req = httpMock.expectOne("/api/users/1");
req.flush({ id: "1", name: "John Doe" });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain("John Doe");
});
});
```
For advanced testing patterns including component harnesses, router testing, form testing, and directive testing, see [references/testing-patterns.md](references/testing-patterns.md).
For Vitest migration from Jasmine, see [references/vitest-migration.md](references/vitest-migration.md).
@@ -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
```