Intermediate

Angular: Unit Testing

Test components with the Angular Testing Library and handle dependencies, directives and async logic.

Table of Contents


Module 1 — Getting Started with Angular Testing

Why Tests Matter

Angular applications are complex systems of components and services. When writing new code, refactoring a service, or updating a template, a fundamental question arises: how confident can you be that you haven’t broken something else?

Relying solely on manual testing — clicking through the UI after every change — is slow, unreliable, and doesn’t scale. You end up with a fragile codebase where small changes have unpredictable side effects.

Concrete example: Imagine a shared DateFormatter service used by multiple features:

  • TimeRecorder module for formatting work hours
  • Invoice module for displaying dates on invoices

If you modify the DateFormatter without testing the Invoice module, you risk breaking billing without noticing.

Key benefits of testing:

BenefitDescription
Regression detection24/7 monitoring of expected code behavior
Living documentationSpec files show exactly how the code is supposed to work
Binding contractSingle source of truth, unlike READMEs that go stale
Long-term velocityUpfront investment that pays off with confidence during deployments
graph TD
    A[Code change] --> B{Automated tests}
    B -->|All pass| C[✅ Deploy with confidence]
    B -->|Failure| D[❌ Regression detected]
    D --> E[Targeted fix]
    E --> A

The Angular Testing Ecosystem

Karma (legacy) vs Vitest (modern)

Problems with Karma:

  • Launches a full browser (Chrome/Firefox) on every run — heavy and slow
  • The test runner and the code live in separate processes (communication latency)
  • For suites with thousands of tests: minutes or even hours of waiting
  • Discourages developers from running tests frequently

Vitest: the modern approach:

  • Runs tests directly in the Node.js process — eliminates browser startup
  • Runner and code live in the same process — zero communication latency
  • Near-instant feedback → developers run tests continuously
graph LR
    subgraph Karma ["Karma (Legacy)"]
        K1[Test Runner<br/>Karma] -- IPC --> K2[Chrome Browser<br/>test execution]
        K2 -- results --> K1
    end

    subgraph Vitest ["Vitest (Modern)"]
        V1[Node.js Process<br/>Runner + Tests<br/>JSDOM]
    end

    style Karma fill:#ffdddd
    style Vitest fill:#ddffdd

Role of JSDOM

Node.js has no native concept of the web DOM. Yet Angular components constantly use DOM APIs (querySelector, innerHTML, etc.). JSDOM is the bridge:

  • Provides a fully in-memory virtual DOM simulation
  • Makes Angular believe it is running in a real browser
  • High performance because there is no actual graphical rendering

Configuring Vitest in an Angular Project

New Angular 21+ project

For a new project, Vitest is configured by default:

ng new my-app

Migrating an existing project (Karma → Vitest)

Step 1: Install packages

npm install --save-dev vitest jsdom

Step 2: Uninstall Karma and Jasmine

npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter jasmine-core @types/jasmine

Step 3: Update angular.json

Replace the Karma builder:

// BEFORE
"test": {
  "builder": "@angular-devkit/build-angular:karma"
}

// AFTER
"test": {
  "builder": "@angular/build:vitest"
}

Step 4: Update tsconfig.spec.json

// BEFORE
{
  "compilerOptions": {
    "types": ["jasmine"]
  }
}

// AFTER
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

Step 5: Create vitest.config.ts

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'jsdom',
  },
});

The AAA Pattern (Arrange, Act, Assert)

The AAA pattern is the fundamental structure of every good unit test. Each section has a precise, non-negotiable role.

flowchart LR
    A["🔧 ARRANGE\nInstantiate the SUT\nDefine variables\nCreate mocks"] --> B["▶️ ACT\nCall the method\nStore the result"] --> C["✅ ASSERT\nCompare actual vs expected\nFail if different"]
    style A fill:#e8f4fd,stroke:#2196F3
    style B fill:#fff3e0,stroke:#FF9800
    style C fill:#e8f5e9,stroke:#4CAF50

ARRANGE — Set the stage

  • Create an instance of the class or service under test (SUT — System Under Test)
  • Define all variables, parameters, and mock data needed
  • Use expressive variable names: expectedResult, inputParameters
  • Isolate external dependencies (DB, API, other services) with mocks/stubs
  • ⚠️ No execution of the code under test in this phase

ACT — Execute

  • A single method or function call to validate
  • Store the result in a variable: result or actual
  • Each test proves only one thing

ASSERT — Verify

  • Compare actual with expected
  • Use descriptive names: expectedArea, expectedError
  • If values don’t match → the test fails with a clear message

Writing Your First Unit Test

Structure of a test file

import { TestBed } from '@angular/core/testing';
import { GeometryService } from './geometry-service';

describe('GeometryService', () => {   // Test suite - named after the SUT
  let service: GeometryService;

  beforeEach(() => {                  // Runs before each test
    TestBed.configureTestingModule({});
    service = TestBed.inject(GeometryService);
  });

  it('should be created', () => {    // Individual test case
    expect(service).toBeTruthy();
  });

  it('should calculate the circle area correctly', () => {
    // Arrange
    const radius = 5;

    // Act
    const area = service.calculateCircleArea(radius);

    // Assert
    expect(area).toBeCloseTo(78.5398);
  });
});

Structure utility functions

FunctionDescription
describe()Defines a test suite — groups related cases
it()Defines an individual test case
beforeEach()Runs before each test in the suite
afterEach()Runs after each test in the suite
beforeAll()Runs once before all tests
afterAll()Runs once after all tests

The role of TestBed

The Angular TestBed is Angular’s primary unit testing utility. It acts as a virtual sandbox that emulates the Angular environment for tests.

TestBed.configureTestingModule({
  declarations: [MyComponent],   // For non-standalone components
  imports: [ReactiveFormsModule], // Required modules
  providers: [MyService]          // Services to inject
});

Mock vs Spy

ConceptDefinitionWhen to use
MockCompletely replaces a dependency with a fake implementationIsolate an external service, control return values
SpyWraps an existing function to observe its behaviorVerify that a method was called, with which arguments

Module 2 — Testing Components with Angular Testing Library

ATL Philosophy

Angular Testing Library (ATL) takes a user-centered approach: test what the user sees and what they interact with, not the component’s internal instance.

“If you change internal variable names or refactor a private method, the test should not break — because the user experience has not changed.”

The old approach (fragile):

// Coupled to internal structure — fragile!
const component = fixture.componentInstance;
expect(component.isLoggedIn).toBe(false); // Internal variable

The ATL approach (robust):

// Tests what the user sees — stable!
expect(screen.queryByRole('button', { name: /logout/i })).not.toBeInTheDocument();

Benefits of ATL:

  • ✅ Better refactoring safety — tests validate public contracts
  • ✅ Better accessibility — the best queries are based on accessibility attributes
  • ✅ Tests that genuinely reflect user behavior

Rendering a Component with ATL

The render function is the central utility of ATL. It handles:

  • Compiling the component
  • Creating the rendering surface
  • Automatic cleanup after each test (prevents test leaks)
import { render, screen } from '@testing-library/angular';
import { LoginFormComponent } from './login-form';

it('should render the login form', async () => {
  // The render function is async — always await!
  const { fixture } = await render(LoginFormComponent, {
    imports: [ReactiveFormsModule],  // Required modules
    providers: [AuthService]          // Required services
  });

  // fixture gives access to the component instance
  // screen gives access to DOM queries
});

Return value of render:

PropertyDescription
fixtureComponent wrapper — access to instance, change detection
containerThe root DOM element of the rendered component
QueriesgetByText, queryByRole, etc. directly accessible

Queries in ATL

ATL enforces a strict priority order for querying elements — from most accessible to least accessible.

graph TD
    P1["🥇 ByRole\ngetByRole('button', {name: /login/i})\nMost accessible — recommended"] --> P2
    P2["🥈 ByLabelText\ngetByLabelText(/username/i)\nIdeal for form fields"] --> P3
    P3["🥉 ByPlaceholderText\ngetByPlaceholderText(/enter email/i)"] --> P4
    P4["4️⃣ ByText\ngetByText(/submit/i)\nFor visible text"] --> P5
    P5["5️⃣ ByDisplayValue\ngetByDisplayValue('Selected')"] --> P6
    P6["6️⃣ ByAltText / ByTitle"] --> P7
    P7["7️⃣ ByTestId\ngetByTestId('submit-btn')\nLast resort"]
    style P1 fill:#c8e6c9
    style P7 fill:#ffccbc

The three query prefixes

PrefixBehaviorTypical usage
getBy...Synchronous — throws if the element is missingVerify presence of an element
queryBy...Synchronous — returns null if missingVerify absence of an element
findBy...Async with built-in waitingElements that appear after an API call
// getBy — immediate presence expected
const loginButton = screen.getByRole('button', { name: /log in/i });

// queryBy — absence expected (returns null, no exception)
expect(screen.queryByText('Error message')).not.toBeInTheDocument();

// findBy — async appearance
const successMessage = await screen.findByText(/welcome/i);

Assertions with Matchers

ATL combined with JSDOM provides expressive semantic matchers via @testing-library/jest-dom.

Main matchers table

MatcherDescriptionExample
toBeInTheDocument()Element is present in the DOMexpect(btn).toBeInTheDocument()
not.toBeInTheDocument()Element is absent from the DOMexpect(err).not.toBeInTheDocument()
toBeVisible()Element is visible to the userexpect(modal).toBeVisible()
toBeDisabled()Element is disabledexpect(submitBtn).toBeDisabled()
toHaveValue(val)Element has the specified valueexpect(input).toHaveValue('admin')
toHaveTextContent(txt)Element contains this textexpect(h1).toHaveTextContent('Hello')
toHaveClass(cls)Element has this CSS classexpect(div).toHaveClass('active')
toHaveBeenCalledTimes(n)A spy was called n timesexpect(spy).toHaveBeenCalledTimes(1)
toHaveBeenCalledWith(...)A spy was called with these argsexpect(spy).toHaveBeenCalledWith({id: 1})
// Combined example
const loginButton = screen.getByRole('button', { name: /log in/i });
expect(loginButton).toBeVisible();
expect(loginButton).not.toBeDisabled();

Assertions on DOM Changes

We test behavioral changes to the DOM, not internal state variables.

it('should show confirm delete button after clicking delete', async () => {
  await render(ContactListComponent);
  const user = userEvent.setup();

  // Assert: confirmation button does not exist yet
  expect(
    screen.queryByRole('button', { name: /confirmDelete/i })
  ).not.toBeInTheDocument();

  // Act: user clicks Delete
  await user.click(screen.getByRole('button', { name: /delete/i }));

  // Assert: confirmation button appears
  expect(
    screen.getByRole('button', { name: /confirmDelete/i })
  ).toBeInTheDocument();
});

For async changes (after an API call):

it('should display data after loading', async () => {
  await render(DashboardComponent);

  // findBy automatically waits for the element to appear
  const dataElement = await screen.findByText(/Melbourne: 18°C/i);
  expect(dataElement).toBeInTheDocument();
});

Assertions on I/O Bindings

Testing a @Input

it('should bind input value to component property', async () => {
  const { fixture } = await render(UserComponent);
  const user = userEvent.setup();

  // Arrange: find the input by its label
  const usernameInput = screen.getByLabelText(/username/i);

  // Act: simulate typing
  await user.type(usernameInput, 'AngularRobot');

  // Force change detection if needed
  fixture.detectChanges();

  // Assert DOM
  expect(usernameInput).toHaveValue('AngularRobot');

  // Assert component state
  expect(fixture.componentInstance.username()).toBe('AngularRobot');
});

Testing a @Output (EventEmitter)

it('should emit loginSubmit event on valid form submission', async () => {
  const loginSubmitSpy = vi.fn();

  await render(LoginFormComponent, {
    componentProperties: {
      loginSubmit: <any>{ emit: loginSubmitSpy },
    },
  });
  const user = userEvent.setup();

  await user.type(screen.getByLabelText(/username/i), 'valid_user');
  await user.type(screen.getByLabelText(/password/i), 'secure_password_123');
  await user.click(screen.getByRole('button', { name: /log in/i }));

  expect(loginSubmitSpy).toHaveBeenCalledTimes(1);
  expect(loginSubmitSpy).toHaveBeenCalledWith({
    username: 'valid_user',
    password: 'secure_password_123',
  });
});

Testing User Interactions

User interactions in the browser trigger trusted events that are not available programmatically. The solution: @testing-library/user-event.

userEvent vs fireEvent:

userEventfireEvent
SimulationComplete realistic interaction (hover, focus, keydown, input, keyup…)Fires only the targeted event
Accessibility✅ Simulates input device state❌ Partial
RecommendationPreferredSimple cases only
import userEvent from '@testing-library/user-event';

it('should handle form validation', async () => {
  await render(LoginFormComponent);
  
  // Always setup() — configures the input device state
  const user = userEvent.setup();

  // Simulate typing in a field
  const usernameInput = screen.getByLabelText(/username/i);
  await user.type(usernameInput, 'test');

  // Simulate a click
  const submitButton = screen.getByRole('button', { name: /log in/i });
  await user.click(submitButton);

  // Assert: error message is visible
  const error = await screen.findByText('Username must be at least 5 characters.');
  expect(error).toBeInTheDocument();
});

Complete example — LoginFormComponent

// login-form.spec.ts
import { render, screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import { LoginFormComponent } from './login-form';

const expectedError = 'Username must be at least 5 characters.';
const testUsername = 'valid_user';
const testPassword = 'secure_password_123';

const usernameReg = /username/i;
const passwordReg = /password/i;
const loginBtnReg = /log in/i;

describe('LoginFormComponent', () => {

  it('should render the form with labeled inputs and no error', async () => {
    await render(LoginFormComponent);

    expect(screen.getByLabelText(usernameReg)).toBeInTheDocument();
    expect(screen.getByLabelText(passwordReg)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: loginBtnReg })).toBeInTheDocument();
    expect(screen.queryByText(expectedError)).not.toBeInTheDocument();
  });

  it('should display an error when validation fails', async () => {
    await render(LoginFormComponent);
    const user = userEvent.setup();

    // Arrange
    const usernameInput = screen.getByLabelText(usernameReg);
    const submitButton = screen.getByRole('button', { name: loginBtnReg });

    // Act
    await user.type(usernameInput, 'test'); // < 5 characters
    await user.click(submitButton);

    // Assert
    const errorMessage = await screen.findByText(expectedError);
    expect(errorMessage).toBeInTheDocument();
  });

  it('should emit loginSubmit event with credentials on success', async () => {
    const loginSubmitSpy = vi.fn();

    await render(LoginFormComponent, {
      componentProperties: {
        loginSubmit: <any>{ emit: loginSubmitSpy },
      },
    });
    const user = userEvent.setup();

    await user.type(screen.getByLabelText(usernameReg), testUsername);
    await user.type(screen.getByLabelText(passwordReg), testPassword);
    await user.click(screen.getByRole('button', { name: loginBtnReg }));

    expect(loginSubmitSpy).toHaveBeenCalledTimes(1);
    expect(loginSubmitSpy).toHaveBeenCalledWith({
      username: testUsername,
      password: testPassword,
    });
    expect(screen.queryByText(expectedError)).not.toBeInTheDocument();
  });
});

Snapshot Testing

Snapshot testing is a technique to ensure the rendered output of a component does not change unexpectedly.

How it works:

  1. First run — Vitest saves the rendered HTML in a __snapshots__/ folder (the Record phase)
  2. Subsequent runs — Compares the current render to the saved snapshot
  3. Difference detected — The test fails until the change is explicitly approved
it('should be matching the snapshot', async () => {
  const { fixture } = await render(LoginFormComponent);
  expect(fixture.nativeElement).toMatchSnapshot();
});

// Inline variant (snapshot stored directly in the test file)
it('should match inline snapshot', async () => {
  const { fixture } = await render(LoginFormComponent);
  expect(fixture.nativeElement).toMatchInlineSnapshot();
});

⚠️ Important: The .snap file generated in __snapshots__/ must be committed to source control.

Snapshot testing ≠ replacement for unit tests. It is a safety net for large or complex UIs.


Module 3 — Testing Strategies for Dependencies, Directives, and Async Logic

Testing Services

The most common strategy when testing Angular services involves network calls. The golden rule: never make a real network call in a unit test.

Angular provides two dedicated tools:

ToolRole
provideHttpClientTesting()Configures HttpClient to use a testing backend
HttpTestingControllerIntercepts requests, allows inspecting and resolving them with mock data
sequenceDiagram
    participant T as Test
    participant S as WeatherService
    participant HC as HttpClient
    participant HTC as HttpTestingController

    T->>S: getWeather('Melbourne')
    S->>HC: this.http.get(url)
    HC->>HTC: Request intercepted (pending)
    T->>HTC: expectOne(urlMatcher)
    T->>HTC: req.flush(mockedResponse)
    HTC->>HC: Mock response
    HC->>S: Observable with data
    S->>T: Transformed WeatherData
    T->>T: expect(result).toEqual(expected)
// weather-service.spec.ts
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { WeatherService, WeatherData, WeatherDataRes } from './weather-service';
import { firstValueFrom } from 'rxjs';

const expected: WeatherData = { name: 'Melbourne', temp: 22 };
const mockedResponse: WeatherDataRes = {
  location: { name: 'Melbourne' },
  current: { temp_c: 22 }
};

describe('WeatherService', () => {
  let service: WeatherService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        WeatherService,
        provideHttpClient(),
        provideHttpClientTesting(),  // ← Replaces the real HttpBackend
      ],
    });
    service = TestBed.inject(WeatherService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify();  // ← Verifies there are no unresolved requests
  });

  it('should fetch and transform weather data', async () => {
    // Act: launch the request (it will be pending)
    const weatherPromise = firstValueFrom(service.getWeather('Melbourne'));

    // Intercept and validate the request
    const req = httpMock.expectOne((request) =>
      request.urlWithParams.includes('api.weatherapi.com') &&
      request.urlWithParams.includes('q=Melbourne')
    );
    expect(req.request.method).toBe('GET');

    // Resolve with mock data
    req.flush(mockedResponse);

    // Assert the transformed result
    const result = await weatherPromise;
    expect(result).toEqual(expected);
  });
});

Mocks and Spies for Dependency Isolation

Why isolate?

When a component depends on a service, using the real service turns a unit test into an integration test. The problems:

  • If the service is broken, the component test fails — hard to know what is really defective
  • Impossible to easily simulate edge cases (403 error, empty list, timeout)
graph LR
    subgraph "Unit Test (isolation)"
        C1[WeatherComponent] --> M1["MockWeatherService<br/>vi.fn().mockReturnValue..."]
    end
    subgraph "Integration Test (coupled)"
        C2[WeatherComponent] --> S2[REAL WeatherService] --> API[External API]
    end
    style M1 fill:#c8e6c9
    style API fill:#ffccbc

Creating a mock with Vitest

Simple approach — fixed return:

const getWeatherSpy = vi.fn().mockReturnValue(of({
  name: 'Melbourne',
  temp: 22
}));

Approach with dynamic implementation:

const getWeatherSpyImp = vi.fn().mockImplementation((city: string) => {
  if (city === 'Melbourne') return of({ name: 'Melbourne', temp: 22 });
  if (city === 'Paris')     return of({ name: 'Paris',     temp: 5  });
  return of({ name: 'London', temp: 15 });
});

Injecting the mock into the component

// app.spec.ts
describe('App', () => {

  it('should display Melbourne temperature after clicking button', async () => {
    const user = userEvent.setup();

    await render(App, {
      providers: [
        // Replaces the real WeatherService with our mock
        { provide: WeatherService, useValue: { getWeather: getWeatherSpy } }
      ]
    });

    const getMelbourneButton = screen.getByText('Get Weather (Melbourne)');
    await user.click(getMelbourneButton);

    const header = await screen.findByRole('heading', { level: 1 });
    expect(header).toHaveTextContent('Current Temperature in Melbourne: 22°C');
  });

  it('should switch city temperature correctly', async () => {
    const user = userEvent.setup();

    await render(App, {
      providers: [
        { provide: WeatherService, useValue: { getWeather: getWeatherSpyImp } }
      ]
    });

    await user.click(screen.getByText('Get Weather (Melbourne)'));
    expect(await screen.findByRole('heading', { level: 1 }))
      .toHaveTextContent('Current Temperature in Melbourne: 22°C');

    await user.click(screen.getByText('Get Weather (Paris)'));
    expect(screen.getByRole('heading', { level: 1 }))
      .toHaveTextContent('Current Temperature in Paris: 5°C');
  });
});

Best practices for mocks

✅ Do❌ Avoid
Type mocks with the real interfaceUsing any everywhere — loses type safety
Reset mocks between testsLetting state from one test leak into the next
Create centralized mock factoriesDuplicating mock configuration in each test
Implement the service interfaceCreating a mock that no longer matches the real service

Testing Directives

Directives have no template of their own — they attach to existing elements. They are always tested in a DOM context.

Attribute Directives

Example: highlightOnHover directive that changes the background to yellow on hover.

it('should turn yellow on hover and revert on mouse leave', () => {
  const { container } = render(
    `<div highlightOnHover>Hover Me</div>`,
    { imports: [HighlightOnHoverDirective] }
  );

  const div = screen.getByText('Hover Me');

  // Simulate hover
  fireEvent.mouseEnter(div);
  expect(div).toHaveStyle('background-color: yellow');

  // Simulate end of hover
  fireEvent.mouseLeave(div);
  expect(div).toHaveStyle('background-color: ''');
});

Structural Directives

Example: *appIfAdmin directive that shows content only for admins.

it('should show admin content when user is admin', async () => {
  await render(AdminPanelComponent, {
    componentProperties: { isAdmin: true }
  });
  expect(screen.getByText('Admin Panel')).toBeInTheDocument();
});

it('should hide admin content when user is not admin', async () => {
  await render(AdminPanelComponent, {
    componentProperties: { isAdmin: false }
  });
  expect(screen.queryByText('Admin Panel')).not.toBeInTheDocument();
});

Handling Async Logic and RxJS

Async logic is one of the most complex parts of modern web development. Without precautions, tests can finish before the async code executes, giving false positives.

Strategy 1 — Convert Observable to Promise

import { firstValueFrom } from 'rxjs';

it('should return transformed data', async () => {
  // Arrange
  const service = TestBed.inject(WeatherService);

  // Act: firstValueFrom waits for the first emission of the observable
  const result = await firstValueFrom(service.getWeather('Melbourne'));

  // Assert
  expect(result.name).toBe('Melbourne');
  expect(result.temp).toBe(22);
});

Why firstValueFrom?

  • More readable code — runs top-to-bottom like synchronous code
  • Avoids the “callback hell” often associated with RxJS testing
  • Easy to read and maintain for other team members

Strategy 2 — Fake Timers for time-based RxJS operators

For delay(), debounceTime(), throttleTime(), etc., you don’t want tests to actually wait N seconds.

describe('Search with debounce', () => {

  beforeEach(() => {
    vi.useFakeTimers();  // Take control of time
  });

  afterEach(() => {
    vi.useRealTimers();  // Restore real time — CRITICAL to avoid leaks
  });

  it('should debounce search input', async () => {
    const service = TestBed.inject(SearchService);
    const results: string[] = [];

    service.search$.subscribe(r => results.push(r));

    service.setQuery('A');
    service.setQuery('An');
    service.setQuery('Angular');

    // Advance virtual time by 300ms
    vi.advanceTimersByTime(300);

    expect(results).toHaveLength(1);
    expect(results[0]).toBe('Angular');
  });
});

Async strategies table

ScenarioStrategyTool
Simple ObservableConvert to PromisefirstValueFrom() + await
delay() / debounceTime()Fake timersvi.useFakeTimers() / vi.advanceTimersByTime()
HTTP callsMock backendHttpTestingController + req.flush()
Async DOMQuery with waitingawait screen.findBy...()

Module 4 — Maintaining a Scalable Testing Workflow

Organizing Tests at Scale

As the application grows from a few components to hundreds, organization becomes critical.

File colocation

In modern Angular development, the spec file lives in the same directory as the file it tests.

src/
└── app/
    ├── services/
    │   ├── geometry/
    │   │   ├── geometry-service.ts       ← Service
    │   │   └── geometry-service.spec.ts  ← Test (colocated ✅)
    │   └── weather/
    │       ├── weather-service.ts
    │       └── weather-service.spec.ts
    └── components/
        └── login-form/
            ├── login-form.ts
            ├── login-form.html
            ├── login-form.spec.ts        ← Test (colocated ✅)
            └── __snapshots__/
                └── login-form.spec.ts.snap

Why colocation?

  • If no .spec.ts → coverage gap is immediately visible
  • Imports are simple — no deep navigation through nested folders
  • If you move or rename a component, the test follows naturally

Approach by test type

graph TD
    A[Tests] --> B[Unit Tests]
    A --> C[Integration Tests]
    A --> D[E2E Tests]
    B --> B1["📁 Colocation\nnext to the feature file\nex: button.spec.ts"]
    C --> C1["📁 Neighborhood\ndedicated folder in feature\nex: login/__tests__/"]
    D --> D1["📁 Top-level directory\ne2e/ outside src/\nrun against deployed app"]

Reusable Helpers and Mock Factories

The boilerplate problem

Without helpers, the first lines of each test are nearly identical:

// Repetition in every spec file 😩
TestBed.configureTestingModule({
  imports: [CommonModule, ReactiveFormsModule, RouterModule],
  providers: [
    { provide: AuthService, useValue: mockAuthService },
    { provide: LoggerService, useValue: mockLogger },
  ]
});

Solution 1 — Custom Renderer

Create a wrapper around render that automatically provides the necessary foundations:

// test-utils/custom-render.ts
import { render, RenderComponentOptions } from '@testing-library/angular';
import { Type } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';

export async function renderWithDefaults<T>(
  component: Type<T>,
  options: RenderComponentOptions<T> = {}
) {
  return render(component, {
    imports: [CommonModule, ReactiveFormsModule],
    ...options,
    providers: [
      ...getDefaultProviders(),
      ...(options.providers ?? []),
    ],
  });
}

Solution 2 — Mock Factories

Centralized functions that return a fresh, pre-configured mock instance:

// test-utils/mock-factories.ts
export function createMockWeatherService(): jest.Mocked<WeatherService> {
  return {
    getWeather: vi.fn().mockReturnValue(of({ name: 'TestCity', temp: 20 })),
  } as any;
}

export function createMockAuthService(): jest.Mocked<AuthService> {
  return {
    login: vi.fn().mockResolvedValue({ token: 'fake-token' }),
    logout: vi.fn(),
    isAuthenticated: vi.fn().mockReturnValue(true),
  } as any;
}

Usage in tests:

it('should display weather', async () => {
  const mockWeather = createMockWeatherService(); // Fresh instance per test

  await render(WeatherComponent, {
    providers: [{ provide: WeatherService, useValue: mockWeather }]
  });

  expect(mockWeather.getWeather).toHaveBeenCalledWith('Melbourne');
});

Solution 3 — Global setup file

Move “plumbing” (localStorage mock, ResizeObserver mock, etc.) out of spec files into a global setup file referenced in vitest.config.ts:

// vitest.config.ts
export default defineConfig({
  test: {
    setupFiles: ['./src/test-setup.ts'],  // ← Global setup file
  }
});

// src/test-setup.ts
import '@testing-library/jest-dom';

// Global localStorage mock
Object.defineProperty(window, 'localStorage', {
  value: {
    getItem: vi.fn(),
    setItem: vi.fn(),
    removeItem: vi.fn(),
  },
  writable: true,
});

Configuring Coverage Reports

Code coverage is not just a vanity metric — it is a diagnostic tool to identify dark corners of business logic.

“70% coverage = 30% of the code that has never been executed during a test. That’s where bugs hide.”

Configuration in vitest.config.ts

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',          // ← Native v8 engine — analysis without overhead
      reporter: ['text', 'html', 'json', 'json-summary'],
      enabled: true,
      clean: true,              // ← Cleans the folder before each run
      reportsDirectory: './coverage',
      thresholds: {             // ← Minimum thresholds — fails if not met
        statements: 80,
        branches: 80,
        functions: 80,
        lines: 80,
      },
    },
  },
});

Installing the v8 provider:

npm install --save-dev @vitest/coverage-v8

Types of reporters

ReporterDescriptionUsage
textTerminal display after each runLocal development
htmlInteractive navigable HTML reportCoverage review
jsonDetailed JSON file of all metricsCustom analysis tools
json-summaryJSON summary — for README badgesCI/CD, documentation
// package.json
{
  "scripts": {
    "test": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:watch": "vitest --watch"
  }
}

Integration into a CI/CD Pipeline

The philosophy is simple: no code enters the main branch without validation by an automated, clean environment.

flowchart TD
    DEV["👨‍💻 Developer\nPush / Pull Request"] --> GA["⚡ GitHub Actions\ntrigger"]
    GA --> ENV["🔧 Build Environment\nUbuntu Image\nnpm ci"]
    ENV --> VITEST["🧪 Vitest Execution\nnpm run test:coverage"]
    VITEST --> QG{Quality Gate}
    QG -->|"✅ Tests OK\nCoverage ≥ 80%"| MERGE["🚀 Merge & Deploy"]
    QG -->|"❌ Test fail\nor Coverage < 80%"| FIX["🔨 Fix Required\nDeveloper intervention"]
    FIX --> DEV
    style MERGE fill:#c8e6c9
    style FIX fill:#ffccbc
    style QG fill:#fff3e0

GitHub Actions Workflow

# .github/workflows/angular-ci.yml
name: Angular CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: write
  pull-requests: write

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: package-lock.json

      - name: Install dependencies
        run: npm ci

      - name: Run tests with coverage
        run: npm run test:coverage -- --no-watch  # --no-watch for CI

      - name: Upload coverage artifacts
        uses: actions/upload-artifact@v4
        with:
          name: vitest-coverage-report
          path: coverage/

      - name: Deploy to GitHub Pages
        if: github.ref == 'refs/heads/main'  # Only on main
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: coverage
          keep_files: true  # Preserves report history

Maintenance Best Practices

The golden rules

mindmap
  root((Best Practices))
    Test Public Contract
      DOM queries
      Visible behavior
      Not private methods
    Eliminate Flakiness
      Fix flaky tests immediately
      Or delete them
      Never ignore failures
    AHA Principle
      Avoid Hasty Abstractions
      Readability over DRY
    Stay Current
      Update Vitest
      Update Angular
      Update ATL
    Type Safety
      Mock implements interface
      No any in mocks
    Clean State
      Reset mocks in afterEach
      No state leakage

The AHA Principle — Avoid Hasty Abstractions

In production code, we apply DRY (Don’t Repeat Yourself). In tests, it is different:

“Better to have a slightly longer, easy-to-read test than code buried under 5 layers of helpers that nobody understands.”

A test should be self-describing: reading the test alone should be enough to understand what it does.

Detecting and eliminating flaky tests

A flaky test — one that passes locally but fails randomly in CI — is a virus:

  1. It destroys the team’s confidence in the CI pipeline
  2. Developers start saying “oh, that test always fails, just re-run it”
  3. The quality gate loses all its power

Rule: If a test is flaky → stop, fix it or delete it. No compromise.

Maintenance checklist

PracticeDescription
✅ Test the public contractDOM queries, visible behaviors — not private properties
✅ Eliminate flaky testsA flaky test = a fix priority
✅ AHA principleReadability > abstraction in tests
✅ Stay currentAngular + Vitest + ATL — regular updates
✅ Type safety in mocksMocks must implement the real interfaces
✅ Clean stateafterEach to reset mocks — zero state leakage
✅ One test = one thingEach it() proves a single behavior

Reference Tables

Vitest / Jasmine test structure

FunctionVitestJasmine (legacy)Description
Suitedescribe()describe()Group related tests
Test caseit() or test()it()Individual case
Setup before each testbeforeEach()beforeEach()Initialization
Teardown after each testafterEach()afterEach()Cleanup
Setup oncebeforeAll()beforeAll()Suite-level setup
Function mockvi.fn()jasmine.createSpy()Create a spy/mock
Return mockvi.fn().mockReturnValue()spy.and.returnValue()Set the returned value
Fake timersvi.useFakeTimers()jasmine.clock().install()Control time

Common matchers

MatcherDescription
expect(x).toBe(y)Strict equality (===)
expect(x).toEqual(y)Deep equality (objects)
expect(x).toBeTruthy()Truthy value
expect(x).toBeFalsy()Falsy value
expect(x).toBeNull()null value
expect(x).toBeUndefined()undefined value
expect(x).toBeCloseTo(n)Numeric approximation
expect(x).toContain(item)Array or string contains item
expect(x).toHaveLength(n)Exact length
expect(x).toThrow()Function throws an exception
expect(fn).toHaveBeenCalled()The spy was called
expect(fn).toHaveBeenCalledTimes(n)Called exactly n times
expect(fn).toHaveBeenCalledWith(...)Called with these arguments

DOM matchers (@testing-library/jest-dom)

MatcherDescription
toBeInTheDocument()Present in the DOM
not.toBeInTheDocument()Absent from the DOM
toBeVisible()Visible to the user
toBeDisabled()Element is disabled
toBeEnabled()Element is enabled
toHaveValue(val)Field value
toHaveTextContent(txt)Text content
toHaveClass(cls)CSS class present
toHaveAttribute(attr, val)HTML attribute
toHaveFocus()Element has focus
toBeChecked()Checkbox/radio is checked

TestBed utilities

MethodDescription
TestBed.configureTestingModule({})Configures the testing module
TestBed.inject(Token)Injects a service from TestBed
TestBed.createComponent(Comp)Creates a component without ATL
fixture.detectChanges()Forces change detection
fixture.componentInstanceAccess to the component instance
fixture.nativeElementAccess to the root DOM element
provideHttpClientTesting()Provider for HTTP tests
provideZonelessChangeDetection()Change detection without Zone.js

ATL Queries — Priority and prefixes

PriorityQueryAvailable prefixes
1ByRolegetBy, queryBy, findBy, getAllBy, queryAllBy, findAllBy
2ByLabelTextSame
3ByPlaceholderTextSame
4ByTextSame
5ByDisplayValueSame
6ByAltTextSame
7ByTitleSame
8ByTestIdSame

Testing pyramid

graph TD
    E2E["🔺 E2E Tests\nCypress / Playwright\nSlow, expensive\nTests the entire application\n~10% of tests"] --- INT
    INT["🔷 Integration Tests\nATL + TestBed\nTests multiple units together\n~20% of tests"] --- UNIT
    UNIT["🟩 Unit Tests\nVitest + ATL\nFast, isolated, focused\n~70% of tests"]
    style E2E fill:#ff8a65
    style INT fill:#ffcc02
    style UNIT fill:#66bb6a

Course Summary

This course covers the modern approach to Angular unit testing, focused on:

  1. Karma → Vitest migration — Execution in Node.js, elimination of browser startup, near-instant feedback.

  2. Angular Testing Library (ATL) — Test what the user sees and interacts with. Accessibility-centered approach. Internal refactoring does not break tests.

  3. AAA Pattern — Arrange, Act, Assert. Clear and readable structure for all tests. One test = one behavior.

  4. Isolation through mocks and spies — Never make a real network or DB call in a unit test. Use HttpTestingController for services, vi.fn() for dependencies.

  5. Scalable tests — Spec file colocation, centralized mock factories, custom renderers, global setup file.

  6. Coverage reports — v8 provider, configured minimum thresholds, integration into the CI/CD pipeline with GitHub Actions.

  7. Best practices — Test the public contract, eliminate flaky tests, AHA principle, type safety in mocks.

The main lesson: Testing is not about finding bugs — it is about gaining confidence to ship quickly. Cover the dark corners of your code with tests.


Search Terms

angular · unit · testing · frontend · development · mock · vitest · atl · directives · matchers · test · assertions · async · tests · act · arrange · assert · component · configuring · dom · factories · karma · logic · maintenance

Interested in this course?

Contact us to book it or get a custom training plan for your team.