707 lines
18 KiB
Markdown
707 lines
18 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
|
|
type="password"
|
|
[formField]="loginForm.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();
|
|
});
|
|
```
|