Files

18 KiB

Angular Testing Patterns

Table of Contents

Vitest Advanced Patterns

Snapshot Testing

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

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

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

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

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

// 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

# Run with UI
npx vitest --ui

# Open UI at specific port
npx vitest --ui --port 51204

Concurrent Tests

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

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

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

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

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

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

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

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

@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

@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

@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

// 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

// 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

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