Files
NGRX-Playground/.agents/skills/angular-testing/references/testing-patterns.md
T
2026-03-08 08:51:02 +01:00

16 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 [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

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