Table of Contents
- Module 1 — Getting Started with Angular Testing
- Module 2 — Testing Components with Angular Testing Library
- Module 3 — Testing Strategies for Dependencies, Directives, and Async Logic
- Module 4 — Maintaining a Scalable Testing Workflow
- Reference Tables
- Course Summary
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:
TimeRecordermodule for formatting work hoursInvoicemodule 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:
| Benefit | Description |
|---|---|
| Regression detection | 24/7 monitoring of expected code behavior |
| Living documentation | Spec files show exactly how the code is supposed to work |
| Binding contract | Single source of truth, unlike READMEs that go stale |
| Long-term velocity | Upfront 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:
resultoractual - Each test proves only one thing
ASSERT — Verify
- Compare
actualwithexpected - 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
| Function | Description |
|---|---|
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
| Concept | Definition | When to use |
|---|---|---|
| Mock | Completely replaces a dependency with a fake implementation | Isolate an external service, control return values |
| Spy | Wraps an existing function to observe its behavior | Verify 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:
| Property | Description |
|---|---|
fixture | Component wrapper — access to instance, change detection |
container | The root DOM element of the rendered component |
| Queries | getByText, 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
| Prefix | Behavior | Typical usage |
|---|---|---|
getBy... | Synchronous — throws if the element is missing | Verify presence of an element |
queryBy... | Synchronous — returns null if missing | Verify absence of an element |
findBy... | Async with built-in waiting | Elements 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
| Matcher | Description | Example |
|---|---|---|
toBeInTheDocument() | Element is present in the DOM | expect(btn).toBeInTheDocument() |
not.toBeInTheDocument() | Element is absent from the DOM | expect(err).not.toBeInTheDocument() |
toBeVisible() | Element is visible to the user | expect(modal).toBeVisible() |
toBeDisabled() | Element is disabled | expect(submitBtn).toBeDisabled() |
toHaveValue(val) | Element has the specified value | expect(input).toHaveValue('admin') |
toHaveTextContent(txt) | Element contains this text | expect(h1).toHaveTextContent('Hello') |
toHaveClass(cls) | Element has this CSS class | expect(div).toHaveClass('active') |
toHaveBeenCalledTimes(n) | A spy was called n times | expect(spy).toHaveBeenCalledTimes(1) |
toHaveBeenCalledWith(...) | A spy was called with these args | expect(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:
userEvent | fireEvent | |
|---|---|---|
| Simulation | Complete realistic interaction (hover, focus, keydown, input, keyup…) | Fires only the targeted event |
| Accessibility | ✅ Simulates input device state | ❌ Partial |
| Recommendation | ✅ Preferred | Simple 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:
- First run — Vitest saves the rendered HTML in a
__snapshots__/folder (the Record phase) - Subsequent runs — Compares the current render to the saved snapshot
- 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
.snapfile 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:
| Tool | Role |
|---|---|
provideHttpClientTesting() | Configures HttpClient to use a testing backend |
HttpTestingController | Intercepts 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 interface | Using any everywhere — loses type safety |
| Reset mocks between tests | Letting state from one test leak into the next |
| Create centralized mock factories | Duplicating mock configuration in each test |
| Implement the service interface | Creating 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
| Scenario | Strategy | Tool |
|---|---|---|
| Simple Observable | Convert to Promise | firstValueFrom() + await |
delay() / debounceTime() | Fake timers | vi.useFakeTimers() / vi.advanceTimersByTime() |
| HTTP calls | Mock backend | HttpTestingController + req.flush() |
| Async DOM | Query with waiting | await 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
| Reporter | Description | Usage |
|---|---|---|
text | Terminal display after each run | Local development |
html | Interactive navigable HTML report | Coverage review |
json | Detailed JSON file of all metrics | Custom analysis tools |
json-summary | JSON summary — for README badges | CI/CD, documentation |
Recommended npm script
// 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:
- It destroys the team’s confidence in the CI pipeline
- Developers start saying “oh, that test always fails, just re-run it”
- The quality gate loses all its power
Rule: If a test is flaky → stop, fix it or delete it. No compromise.
Maintenance checklist
| Practice | Description |
|---|---|
| ✅ Test the public contract | DOM queries, visible behaviors — not private properties |
| ✅ Eliminate flaky tests | A flaky test = a fix priority |
| ✅ AHA principle | Readability > abstraction in tests |
| ✅ Stay current | Angular + Vitest + ATL — regular updates |
| ✅ Type safety in mocks | Mocks must implement the real interfaces |
| ✅ Clean state | afterEach to reset mocks — zero state leakage |
| ✅ One test = one thing | Each it() proves a single behavior |
Reference Tables
Vitest / Jasmine test structure
| Function | Vitest | Jasmine (legacy) | Description |
|---|---|---|---|
| Suite | describe() | describe() | Group related tests |
| Test case | it() or test() | it() | Individual case |
| Setup before each test | beforeEach() | beforeEach() | Initialization |
| Teardown after each test | afterEach() | afterEach() | Cleanup |
| Setup once | beforeAll() | beforeAll() | Suite-level setup |
| Function mock | vi.fn() | jasmine.createSpy() | Create a spy/mock |
| Return mock | vi.fn().mockReturnValue() | spy.and.returnValue() | Set the returned value |
| Fake timers | vi.useFakeTimers() | jasmine.clock().install() | Control time |
Common matchers
| Matcher | Description |
|---|---|
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)
| Matcher | Description |
|---|---|
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
| Method | Description |
|---|---|
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.componentInstance | Access to the component instance |
fixture.nativeElement | Access to the root DOM element |
provideHttpClientTesting() | Provider for HTTP tests |
provideZonelessChangeDetection() | Change detection without Zone.js |
ATL Queries — Priority and prefixes
| Priority | Query | Available prefixes |
|---|---|---|
| 1 | ByRole | getBy, queryBy, findBy, getAllBy, queryAllBy, findAllBy |
| 2 | ByLabelText | Same |
| 3 | ByPlaceholderText | Same |
| 4 | ByText | Same |
| 5 | ByDisplayValue | Same |
| 6 | ByAltText | Same |
| 7 | ByTitle | Same |
| 8 | ByTestId | Same |
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:
-
Karma → Vitest migration — Execution in Node.js, elimination of browser startup, near-instant feedback.
-
Angular Testing Library (ATL) — Test what the user sees and interacts with. Accessibility-centered approach. Internal refactoring does not break tests.
-
AAA Pattern — Arrange, Act, Assert. Clear and readable structure for all tests. One test = one behavior.
-
Isolation through mocks and spies — Never make a real network or DB call in a unit test. Use
HttpTestingControllerfor services,vi.fn()for dependencies. -
Scalable tests — Spec file colocation, centralized mock factories, custom renderers, global setup file.
-
Coverage reports — v8 provider, configured minimum thresholds, integration into the CI/CD pipeline with GitHub Actions.
-
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