Getting Started With Angular Testing: Karma, Jasmine, TestBed and HttpClientTestingModule
Karma and Jasmine with ng test in Angular 12, the structure of a spec file, component tests with TestBed and ComponentFixture, service tests with fake dependencies and HTTP mocking with HttpClientTestingModule.
Version note: The code and commands in this post are for Angular 12.
What Are Those spec Files For?
Every time you generated a component or service with the CLI, a .spec.ts file appeared next to it, and so far we haven't touched it. Those files are the application's tests. They aren't written to prove the code works today, but to make sure it still works tomorrow when someone changes something.
Think of a trapeze artist. They can jump without a net, but with a net they move more boldly. Tests are that net. When you refactor a service or add a feature to a component, you find out whether you broke something before your users do.
ng test: Karma and Jasmine
In an Angular 12 project, one command is enough to run the tests:
ng test
This command runs two tools together. Jasmine gives you the language and rules for writing tests: functions like describe, it and expect come from it. Karma is the tool that runs those tests in a real browser. When you run the command, a Chrome window opens, the tests run there, and the results show up both in the browser and in the terminal.
The settings live in karma.conf.js at the project root and in src/test.ts. test.ts finds and loads every .spec.ts file in the project; normally you don't need to change either one. ng test runs in watch mode: when you save a file, the tests run again on their own.
The Anatomy of a spec File
Let's start with a simple service that doesn't depend on Angular at all:
import { Injectable } from '@angular/core';
export interface CartItem {
name: string;
price: number;
quantity: number;
}
@Injectable({ providedIn: 'root' })
export class CartService {
private items: CartItem[] = [];
add(item: CartItem): void {
this.items.push(item);
}
getTotal(): number {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}
And here is its test:
import { CartService } from './cart.service';
describe('CartService', () => {
let service: CartService;
beforeEach(() => {
service = new CartService();
});
it('should return 0 for an empty cart', () => {
expect(service.getTotal()).toBe(0);
});
it('should calculate the total from price and quantity', () => {
service.add({ name: 'Book', price: 50, quantity: 2 });
service.add({ name: 'Pen', price: 10, quantity: 3 });
expect(service.getTotal()).toBe(130);
});
});
describe opens a group of tests; you usually give it the name of the class under test. Each it tests a single behavior, and its description should read like a sentence. beforeEach runs before every it; here we give each test a brand-new CartService, so the tests don't affect each other.
expect(...).toBe(...) is the actual check. In Jasmine these are called matchers. The ones you will use most are toBe (strict equality), toEqual (content equality for objects and arrays), toBeTruthy, toContain and toThrow. Watch the difference between toBe and toEqual: two separate objects with the same content will still fail a toBe check.
Component Testing With TestBed
The spec file the CLI generates for every component looks like this:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LikeButtonComponent } from './like-button.component';
describe('LikeButtonComponent', () => {
let component: LikeButtonComponent;
let fixture: ComponentFixture<LikeButtonComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ LikeButtonComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(LikeButtonComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
Components can't be created with new like a service; they have a template, dependencies and a module. That is why TestBed sets up a small Angular environment for the test. configureTestingModule is a miniature copy of the @NgModule we saw earlier, built just for the test. You put the component in declarations, the modules it needs in imports and services in providers.
createComponent returns a ComponentFixture. The fixture holds the component mounted on a test bench: componentInstance gives you the class, nativeElement the rendered DOM. fixture.detectChanges() runs change detection manually. In tests, Angular doesn't do it by itself; the first detectChanges call also triggers ngOnInit.
Querying and Clicking the DOM
Let the component under test be a simple like button:
@Component({
selector: 'app-like-button',
template: `
<button (click)="like()">Like</button>
<span class="count">{{ count }}</span>
`
})
export class LikeButtonComponent {
count = 0;
like(): void {
this.count++;
}
}
Let's test it the way a user sees it: when the button is clicked, does the number on the screen go up?
it('should increase the counter when the button is clicked', () => {
const element = fixture.nativeElement as HTMLElement;
const button = element.querySelector('button') as HTMLButtonElement;
button.click();
expect(element.querySelector('.count')?.textContent).toBe('0');
fixture.detectChanges();
expect(element.querySelector('.count')?.textContent).toBe('1');
});
We take nativeElement as an HTMLElement and move around inside it with the familiar querySelector. button.click() fires a real click event and like() runs. Look at the first expect: count in the class is now 1, but the screen still says 0. The DOM doesn't update until you call detectChanges(). This is where beginners trip up most often; if you find yourself saying "I changed the value but the test sees the old one," a missing detectChanges is the likely culprit.
Testing Services and Faking Dependencies
The spec file the CLI generates for a service uses TestBed.inject instead of new:
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CartService);
});
For a service with no dependencies, there is no difference between the two. But if the service depends on other services, TestBed resolves them through the dependency injection we saw earlier. And here is the best part: you can swap a real dependency for a fake. Say CheckoutService uses CartService, and its getShippingFee() method returns 0 if the cart total is over 500 TL and 40 otherwise:
describe('CheckoutService', () => {
let service: CheckoutService;
let cartSpy: jasmine.SpyObj<CartService>;
beforeEach(() => {
cartSpy = jasmine.createSpyObj('CartService', ['getTotal']);
TestBed.configureTestingModule({
providers: [{ provide: CartService, useValue: cartSpy }]
});
service = TestBed.inject(CheckoutService);
});
it('should offer free shipping for orders over 500 TL', () => {
cartSpy.getTotal.and.returnValue(650);
expect(service.getShippingFee()).toBe(0);
expect(cartSpy.getTotal).toHaveBeenCalled();
});
});
jasmine.createSpyObj creates a fake object with the method names you give it. With the useValue we saw earlier, we hand over this fake in place of CartService. and.returnValue(650) means "if anyone calls getTotal, return 650." That way we test CheckoutService on its own, without dealing with what's inside the cart. toHaveBeenCalled verifies that the method was actually called. The same technique works for replacing a service with a fake in a component test.
Faking HttpClient
When testing a service that makes HTTP requests, we don't want to hit a real server; the server might be down, the data might change, and the tests get slow. Angular provides HttpClientTestingModule for this:
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { Product, ProductService } from './product.service';
describe('ProductService', () => {
let service: ProductService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule]
});
service = TestBed.inject(ProductService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should fetch products with a GET request', () => {
const fakeProducts: Product[] = [
{ id: 1, name: 'Mechanical Keyboard', price: 1450 },
{ id: 2, name: 'Wireless Mouse', price: 600 }
];
service.getProducts().subscribe(products => {
expect(products.length).toBe(2);
expect(products[0].name).toBe('Mechanical Keyboard');
});
const req = httpMock.expectOne('/api/products');
expect(req.request.method).toBe('GET');
req.flush(fakeProducts);
});
});
Here we assume ProductService has a getProducts() method that calls this.http.get<Product[]>('/api/products'). HttpClientTestingModule replaces the real HttpClient backend with a fake that never touches the network. Requests aren't sent; they are held to one side.
The flow goes like this: first we subscribe and write down what we expect. expectOne verifies that exactly one request was made to this URL and hands that request to us. req.flush(...) simulates the server's response; the expect calls inside subscribe only run after this line. verify() in afterEach checks that no unexpected or unanswered requests are left.
The error case is just as easy: call req.flush('Server error', { status: 500, statusText: 'Server Error' }) and the error side of subscribe runs, so you can test the catchError logic you wrote earlier.
When Tests Turn Red
A failing test shows up in red in Karma's browser window and in the terminal. The message usually tells you what it expected and what it found, something like Expected 120 to be 130. Which it failed, and on which line, is written below it.
When you want to focus on a single test, write fit instead of it and fdescribe instead of describe; only those will run. To switch a test off temporarily, there are xit and xdescribe. Don't forget to revert them before you commit.
In a CI environment you don't want the browser to stay open. ng test --watch=false runs the tests once and exits. ng test --code-coverage produces a coverage/ folder at the project root; the HTML report inside shows which lines were never executed by any test. Angular's testing setup changes in later versions; we will cover that separately in the version posts.
A Small Experiment
Add an "Undo like" button to the LikeButtonComponent above and don't let the number drop below zero. Then write three tests: the undo button should decrease the number by one, undo should do nothing when the number is 0, and after two likes and one undo the screen should show 1.
Once the tests are written, introduce a bug in the component on purpose; for example, write count -= 2 instead of count--. See which test turns red and what the error message tells you. Then run ng test --watch=false --code-coverage and open the report.
When all the tests are green, it's time to put the application in front of users. In the next post we will talk about producing a production bundle with ng build, environment files, and deploying the application to a server.