Angular Testing Basics
Angular comes with a built-in testing environment, providing functionality equivalent to React + Jest/Testing Library setup out of the box.
1. Testing Environment Overview
A project generated with ng new already has a test runner, a test framework, and testing utilities (TestBed) configured. Simply run ng test to execute your tests.
| Role | Standard setup (legacy) | Recent options |
|---|---|---|
| Test framework | Jasmine | Jest / Vitest |
| Test runner | Karma (runs in a real browser) | Jest (jsdom) / Vitest (jsdom, happy-dom, browser mode) |
| Angular utilities | TestBed, ComponentFixture | Same (shared) |
| E2E | (Protractor has been retired) | Playwright / Cypress |
Karma Deprecation and the Move to Jest / Vitest
Karma was deprecated in 2023 (around Angular 15), and the Angular team is exploring a new standard. The current situation is as follows.
- Depending on the version, new projects may still default to Jasmine + Karma. Check what
ng newgenerates for the Angular version you use. - The
@angular-devkit/build-angular:jestbuilder is offered as experimental support. - The
@angular/build:unit-testbuilder for Vitest is also experimental, and is a strong candidate for the new standard. - Existing Jest setups (such as
jest-preset-angular) are a community staple and remain widely used.
TestBed and ComponentFixture have the same API regardless of whether you use Jasmine, Jest, or Vitest, so most test code does not change when you migrate. What mainly changes is how you write spies and mocks: spyOn (Jasmine) versus jest.fn() / vi.fn().
# Run all tests in watch mode
ng test
# For CI: run once (headless Chrome with Karma)
ng test --no-watch --browsers=ChromeHeadless
# With coverage
ng test --no-watch --code-coverage
Comparison with React
| Aspect | Angular | React / Next.js |
|---|---|---|
| Initial setup | Preconfigured by the CLI | Install Jest / Vitest + Testing Library yourself (Next.js has an official guide) |
| Swapping dependencies | providers in TestBed | jest.mock or provider wrappers |
| Rendering a component | TestBed.createComponent | render() |
| Querying elements | fixture.debugElement.query / nativeElement.querySelector | screen.getByRole, etc. |
| Change detection | Call fixture.detectChanges() explicitly | render and act wait for re-rendering automatically |
2. Unit Testing (Services)
To test a service, create a test DI environment with TestBed.configureTestingModule and retrieve the service with TestBed.inject.
// counter.service.ts
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CounterService {
readonly count = signal(0);
increment(): void {
this.count.update((n) => n + 1);
}
}
// counter.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { CounterService } from './counter.service';
describe('CounterService', () => {
let service: CounterService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CounterService);
});
it('increments count by 1 on increment', () => {
service.increment();
expect(service.count()).toBe(1);
});
});
TestBed is reset for every test, so state does not leak between tests even for providedIn: 'root' services. For a simple service with no dependencies, you can also instantiate it directly with new CounterService().
Mocking DI (useValue / useClass)
Dependencies are swapped through providers. This is the biggest advantage of Angular testing.
// user-store.service.ts
import { Injectable, inject } from '@angular/core';
import { UserApiService } from './user-api.service';
@Injectable({ providedIn: 'root' })
export class UserStoreService {
private readonly api = inject(UserApiService);
async loadNames(): Promise<string[]> {
const users = await this.api.fetchUsers();
return users.map((u) => u.name);
}
}
// user-store.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { UserStoreService } from './user-store.service';
import { UserApiService } from './user-api.service';
describe('UserStoreService', () => {
it('can inject a stub with useValue', async () => {
const apiStub = {
fetchUsers: jasmine
.createSpy('fetchUsers')
.and.resolveTo([{ id: 1, name: 'Alice' }]),
};
TestBed.configureTestingModule({
providers: [{ provide: UserApiService, useValue: apiStub }],
});
const store = TestBed.inject(UserStoreService);
expect(await store.loadNames()).toEqual(['Alice']);
expect(apiStub.fetchUsers).toHaveBeenCalledTimes(1);
});
});
With Jest / Vitest, replace jasmine.createSpy(...).and.resolveTo(x) with jest.fn().mockResolvedValue(x) (or vi.fn().mockResolvedValue(x) for Vitest).
| Option | Use case |
|---|---|
useValue | Pass a hand-written stub object or spy |
useClass | Replace with a fake implementation class for tests |
useFactory | Build the mock depending on the situation |
class FakeUserApiService implements Pick<UserApiService, 'fetchUsers'> {
async fetchUsers() {
return [{ id: 1, name: 'Fake' }];
}
}
TestBed.configureTestingModule({
providers: [{ provide: UserApiService, useClass: FakeUserApiService }],
});
In React you replace things per module with jest.mock('./api'), whereas in Angular you replace per DI token, so you do not need to worry about import order or hoisting.
3. Component Testing
In a component test, you create a ComponentFixture with TestBed.createComponent and verify both the rendered DOM and the component instance. Standalone components are specified directly in imports.
// greeting.component.ts
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-greeting',
standalone: true,
template: `
<h2 data-testid="title">Hello, {{ name() }}</h2>
<button type="button" (click)="clicked.emit(name())">Greet</button>
`,
})
export class GreetingComponent {
readonly name = input.required<string>();
readonly clicked = output<string>();
}
// greeting.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GreetingComponent } from './greeting.component';
describe('GreetingComponent', () => {
let fixture: ComponentFixture<GreetingComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GreetingComponent],
}).compileComponents();
fixture = TestBed.createComponent(GreetingComponent);
});
it('displays the given name', () => {
fixture.componentRef.setInput('name', 'Alice');
fixture.detectChanges();
const title: HTMLElement = fixture.nativeElement.querySelector('[data-testid="title"]');
expect(title.textContent).toContain('Alice');
});
});
Main ComponentFixture APIs
| API | Description |
|---|---|
fixture.componentInstance | The component instance |
fixture.nativeElement | The root DOM element |
fixture.debugElement | Angular's debugging wrapper (query / queryAll / triggerEventHandler) |
fixture.componentRef.setInput(name, value) | Sets an input (@Input / input()) |
fixture.detectChanges() | Runs change detection once and updates the DOM |
fixture.whenStable() | Waits for asynchronous work to finish |
The key point is that the DOM is not updated until you call fixture.detectChanges() in the test (the default manual mode). After changing an input, call detectChanges() before asserting on the DOM. You can automate this with ComponentFixtureAutoDetect or fixture.autoDetectChanges().
Testing @Input / @Output
Verify inputs with setInput, and outputs with subscribe (the signal-based output() can also be subscribed to via OutputEmitterRef.subscribe) or with spies. Trigger DOM interactions with click() or dispatchEvent.
it('emits name via clicked when the button is clicked', () => {
fixture.componentRef.setInput('name', 'Bob');
fixture.detectChanges();
const emitted: string[] = [];
fixture.componentInstance.clicked.subscribe((v) => emitted.push(v));
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
button.click();
expect(emitted).toEqual(['Bob']);
});
For traditional components using the @Input() decorator, assigning directly with fixture.componentInstance.name = 'Bob' also works, but updates may not be reflected in OnPush components, so using setInput is safer.
Testing the New Control Flow and Signals
Built-in control flow such as @if / @for can be tested as usual by asserting on the rendered DOM. For signals, change the value directly and call detectChanges() to re-render.
@Component({
standalone: true,
template: `
@for (item of items(); track item) {
<li>{{ item }}</li>
} @empty {
<li class="empty">No data</li>
}
`,
})
class ListComponent {
items = signal<string[]>([]);
}
it('shows @empty when the list is empty', () => {
const fixture = TestBed.createComponent(ListComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.empty')).not.toBeNull();
fixture.componentInstance.items.set(['a', 'b']);
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(2);
});
Mocking Child Components
If you do not want to depend on the details of a child component, swap it for a stub component. Use overrideComponent to replace imports.
@Component({ selector: 'app-user-card', standalone: true, template: '' })
class UserCardStubComponent {
readonly user = input<unknown>();
}
TestBed.configureTestingModule({ imports: [UserListComponent] });
TestBed.overrideComponent(UserListComponent, {
remove: { imports: [UserCardComponent] },
add: { imports: [UserCardStubComponent] },
});
That said, Angular testing often favors integration-leaning tests that render child components too. Limiting mocks to heavy child components or those with external dependencies makes tests less brittle.
Comparison with React Testing Library
| Operation | Angular (TestBed) | React Testing Library |
|---|---|---|
| Render | TestBed.createComponent(X) | render(<X />) |
| props / inputs | fixture.componentRef.setInput | render(<X name="a" />) / rerender |
| Query elements | fixture.nativeElement.querySelector / debugElement.query(By.css(...)) | screen.getByRole / getByText |
| Events | button.click() / triggerEventHandler | fireEvent.click / userEvent.click |
| Re-render | fixture.detectChanges() | Automatic (handled within act) |
| Async waiting | await fixture.whenStable() | await screen.findBy... / waitFor |
In Angular, installing @testing-library/angular also lets you write in a style close to render / screen / fireEvent, making user-centric tests easier to write.
4. Testing HTTP Requests
Use provideHttpClientTesting to verify request contents and inject responses without making real network calls. The former HttpClientTestingModule is deprecated; use the functional providers today.
// user.service.ts
@Injectable({ providedIn: 'root' })
export class UserService {
private readonly http = inject(HttpClient);
getUser(id: number): Observable<User> {
return this.http.get<User>(`/api/users/${id}`);
}
createUser(body: Omit<User, 'id'>): Observable<User> {
return this.http.post<User>('/api/users', body);
}
}
// user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Ensure no outstanding requests remain
});
it('calls GET /api/users/1 and returns the result', () => {
let result: User | undefined;
service.getUser(1).subscribe((u) => (result = u));
const req = httpMock.expectOne('/api/users/1');
expect(req.request.method).toBe('GET');
expect(result?.name).toBe('Alice');
});
it('verifies the POST body', () => {
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('POST');
});
it('handles HTTP errors', () => {
let status: number | undefined;
service.getUser(99).subscribe({ error: (e) => (status = e.status) });
httpMock
.expectOne('/api/users/99')
.flush('Not Found', { status: 404, statusText: 'Not Found' });
expect(status).toBe(404);
});
});
Specify provideHttpClient() and provideHttpClientTesting() in this order. To verify interceptors as well, specify provideHttpClient(withInterceptors([...])).
HttpTestingController method | Description |
|---|---|
expectOne(url or predicate) | Asserts that exactly one matching request exists and returns it |
match(predicate) | Returns all matching requests |
expectNone(predicate) | Asserts that no matching request exists |
verify() | Asserts that there are no outstanding requests |
req.flush(data) returns a response, and req.error(new ProgressEvent('error')) simulates a network error. In React, mocking the network layer with MSW (Mock Service Worker) is the mainstream approach, while in Angular HttpTestingController is provided as the standard mechanism.
5. E2E Testing
While unit and component tests verify components on top of TestBed, E2E tests drive and verify the whole application in a real browser.
- Protractor is deprecated (support ended in 2023) and is not included in new projects. Existing projects need to migrate to another tool.
- The main options today are Playwright and Cypress. Neither depends on Angular-specific features, and you can use them just as you would for any web application.
- If the project has no E2E builder installed,
ng e2ewalks you through choosing and installing a tool.
| Aspect | Playwright | Cypress |
|---|---|---|
| Browsers | Chromium / Firefox / WebKit | Chromium-based / Firefox / WebKit (experimental) |
| Parallel execution | Strong out of the box | Paid cloud or separate setup |
| Language | TypeScript / JavaScript, etc. | TypeScript / JavaScript |
| Auto-waiting | Yes | Yes |
// e2e/users.spec.ts (Playwright)
import { test, expect } from '@playwright/test';
test('shows the user list', async ({ page }) => {
await page.goto('/users');
await expect(page.getByRole('heading', { name: 'User List' })).toBeVisible();
await expect(page.getByRole('listitem')).toHaveCount(3);
});
React / Next.js uses the same Playwright / Cypress, so your E2E knowledge carries over as is.
6. Testing Best Practices
Design Code to Be Testable
- Receive dependencies via
inject()or the constructor: With DI, you can mock simply by swappingproviders. Avoid implementations that create their own instances, such asnew HttpClient(). - Put logic in services or pure functions: Keeping components thin and placing business logic in services lets you test quickly without
TestBed. - Test functional guards and resolvers with
TestBed.runInInjectionContext: Functions that useinject()require an injection context.
it('redirects to /login when not logged in', () => {
TestBed.configureTestingModule({
providers: [{ provide: AuthService, useValue: { isLoggedIn: () => false } }],
});
const result = TestBed.runInInjectionContext(() =>
authGuard({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot),
);
expect(result.toString()).toBe('/login');
});
- Do not tie selectors to implementation details: Using
data-testid, roles (role), or labels is less brittle than CSS classes. - For async, use
fakeAsync/tickorawait fixture.whenStable(): Avoid tests that depend on real wait times (setTimeout).
Snapshot Testing
With Jest, DOM snapshots via toMatchSnapshot are possible, but they are not widely recommended in Angular. The generated DOM contains many attributes and comment nodes, so diffs tend to become noise. If you use them, limit them to small, stable UI parts or serialized output (such as configuration objects), and verify behavior with ordinary assertions.
Setting Coverage Goals
ng test --code-coverage outputs a coverage report to coverage/.
- If you set a numeric target, aim for roughly 70-80% overall, and do not make 100% the goal.
- Thresholds can be set with
coverageReporter.checkinkarma.conf.js(Karma) orcoverageThresholdin Jest, so CI can fail when coverage drops below them. - Business logic (services, guards, pipes) should be high; template-only components can be lower.
- Use coverage to find untested areas, not as a measure of quality itself.
Test Structure Guidelines
| Type | Target | Speed | Proportion |
|---|---|---|---|
| Unit | Services, pipes, pure functions, guards | Fast | Many |
| Component | A single component + its template | Medium | Medium |
| E2E | Key user flows | Slow | Few |