← All articles

Angular unit testing with Karma and Jasmine: a practical guide

Test Angular services, HTTP calls, standalone components and async code with Jasmine, Karma and TestBed, plus habits that keep a test suite fast.

  • Angular
  • Unit testing
  • Jasmine
  • Karma

Most Angular teams don't skip tests because they think testing is a bad idea. They skip them because the first few attempts felt slow and fragile: a component test fails because of a missing provider, a service test makes a real HTTP call, an async test passes for the wrong reason. After writing Angular tests across several enterprise projects, I've found that almost all of that pain comes from a handful of patterns. Learn those and Karma and Jasmine become a quiet safety net instead of a chore.

This guide covers the setup, the three kinds of tests you will write most often (services, HTTP and components), async code, and the habits that keep a test suite fast and trustworthy.

Karma and Jasmine: who does what

The two tools are often mentioned together, but they do different jobs.

  • Jasmine is the testing framework. It gives you describe, it, expect, matchers such as toEqual, and spies for faking functions.
  • Karma is the test runner. It starts a real browser, loads your compiled tests into it and reports the results back to the terminal or your CI system.

Angular's own testing utilities, mainly TestBed, sit on top. TestBed builds a small Angular environment for each test, so you can create components and inject services the same way the real app does.

Running the suite

In a Karma-based project the CLI already wires everything up. Two commands cover daily work and CI:

# Watch mode while developing
ng test
 
# One run in a headless browser, with a coverage report, for CI
ng test --watch=false --browsers=ChromeHeadless --code-coverage

The coverage report lands in the coverage/ folder. Treat the number as a conversation starter, not a target. A file at 95% coverage with no meaningful assertions protects you less than a file at 70% whose tests check real behaviour.

Testing a service: start with plain classes

The fastest tests don't need TestBed at all. If a service only contains logic, create it with new and test it directly:

// price.service.ts
export class PriceService {
  withTax(amount: number, rate: number): number {
    if (amount < 0) throw new Error('Amount cannot be negative');
    return Math.round(amount * (1 + rate) * 100) / 100;
  }
}
// price.service.spec.ts
describe('PriceService', () => {
  let service: PriceService;
 
  beforeEach(() => {
    service = new PriceService();
  });
 
  it('adds tax and rounds to two decimals', () => {
    expect(service.withTax(99.99, 0.18)).toBe(117.99);
  });
 
  it('rejects negative amounts', () => {
    expect(() => service.withTax(-1, 0.18)).toThrowError('Amount cannot be negative');
  });
});

Reach for TestBed only when the class really depends on Angular, for example when it injects other services or HttpClient.

Faking dependencies with spies

When a service depends on another service, replace the dependency with a spy. Jasmine's createSpyObj builds an object whose methods record their calls and return whatever you tell them to:

describe('CheckoutService', () => {
  let checkout: CheckoutService;
  let prices: jasmine.SpyObj<PriceService>;
 
  beforeEach(() => {
    prices = jasmine.createSpyObj<PriceService>('PriceService', ['withTax']);
 
    TestBed.configureTestingModule({
      providers: [CheckoutService, { provide: PriceService, useValue: prices }],
    });
 
    checkout = TestBed.inject(CheckoutService);
  });
 
  it('uses the tax-inclusive price for the order total', () => {
    prices.withTax.and.returnValue(118);
 
    expect(checkout.total([{ amount: 100 }])).toBe(118);
    expect(prices.withTax).toHaveBeenCalledOnceWith(100, 0.18);
  });
});

Two things make this test useful. It controls the dependency completely, so a bug in PriceService cannot make it fail. And it asserts on the interaction, so if someone later forgets to apply tax, the test says exactly what went wrong.

Testing HTTP calls without a network

Never let a unit test hit a real server. Angular ships a testing backend that intercepts requests so you can check what was sent and decide what comes back:

import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
 
describe('AccountApi', () => {
  let api: AccountApi;
  let http: HttpTestingController;
 
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [AccountApi, provideHttpClient(), provideHttpClientTesting()],
    });
    api = TestBed.inject(AccountApi);
    http = TestBed.inject(HttpTestingController);
  });
 
  afterEach(() => http.verify()); // fails if any request was not handled
 
  it('loads an account by id', () => {
    let result: Account | undefined;
    api.getAccount(42).subscribe((account) => (result = account));
 
    const request = http.expectOne('/api/accounts/42');
    expect(request.request.method).toBe('GET');
    request.flush({ id: 42, status: 'ACTIVE' });
 
    expect(result).toEqual({ id: 42, status: 'ACTIVE' });
  });
 
  it('surfaces server errors', () => {
    let error: unknown;
    api.getAccount(42).subscribe({ error: (e) => (error = e) });
 
    http.expectOne('/api/accounts/42').flush('Not found', { status: 404, statusText: 'Not Found' });
 
    expect(error).toBeTruthy();
  });
});

The http.verify() call in afterEach is easy to forget and very valuable. It catches code that fires an extra request you never expected, such as a duplicate call on every keystroke.

Testing a standalone component

Component tests answer one question: does the template show the right thing and react to the user correctly? For a standalone component, import it directly into the testing module:

// status-badge.component.ts
@Component({
  selector: 'app-status-badge',
  template: `<span class="badge" [class.badge--active]="status() === 'ACTIVE'">{{
    status()
  }}</span>`,
})
export class StatusBadgeComponent {
  status = input.required<string>();
}
describe('StatusBadgeComponent', () => {
  let fixture: ComponentFixture<StatusBadgeComponent>;
 
  beforeEach(async () => {
    await TestBed.configureTestingModule({ imports: [StatusBadgeComponent] }).compileComponents();
    fixture = TestBed.createComponent(StatusBadgeComponent);
  });
 
  it('highlights active accounts', () => {
    fixture.componentRef.setInput('status', 'ACTIVE');
    fixture.detectChanges();
 
    const badge: HTMLElement = fixture.nativeElement.querySelector('.badge');
    expect(badge.textContent?.trim()).toBe('ACTIVE');
    expect(badge.classList).toContain('badge--active');
  });
});

Notice what the test checks: visible text and a CSS class, the same things a user would notice. It does not reach into private fields. Tests written from the outside survive refactors; tests that inspect internals break every time someone renames a variable.

fixture.componentRef.setInput() works with signal inputs and classic @Input() alike, and it triggers the same lifecycle as a real parent binding, so prefer it over assigning to the component instance.

Async code: fakeAsync and tick

Debounced search boxes, timers and delayed UI are where flaky tests usually live. fakeAsync replaces real time with a virtual clock that you move forward yourself:

it('searches 300 ms after the user stops typing', fakeAsync(() => {
  const search = spyOn(component, 'search');
  const input: HTMLInputElement = fixture.nativeElement.querySelector('input');
 
  input.value = 'ang';
  input.dispatchEvent(new Event('input'));
  tick(299);
  expect(search).not.toHaveBeenCalled();
 
  tick(1);
  expect(search).toHaveBeenCalledOnceWith('ang');
}));

The test runs instantly and checks the exact timing rule. If you find yourself adding real setTimeout waits to a test, that is the signal to switch to fakeAsync.

Habits that keep a suite healthy

One behaviour per test. A test called "creates the component" proves very little. Name tests after the rule they protect: "disables submit while the form is invalid".

Arrange, act, assert. Keep the three steps visible in every test. When a test fails months later, someone should understand it in under a minute.

Mock at the boundary, not everywhere. Fake HTTP, time and browser APIs. Keep real collaborators when they are cheap and deterministic, or your tests will only prove that your mocks agree with each other.

Make failures cheap to read. toHaveBeenCalledOnceWith tells you more than toHaveBeenCalled. A custom message in a complex expect saves a debugging session.

Run tests in CI on every pull request. A suite that only runs on one developer's laptop is a suite that slowly rots. Headless Chrome in the pipeline, with failures blocking the merge, changes team behaviour faster than any guideline.

Where unit tests stop

Unit tests are great at rules, edge cases and component behaviour. They are poor at proving that pages, routing and the real backend work together. Pair a solid unit suite with a small number of end-to-end tests in a tool such as Playwright or Cypress for the critical user journeys, and you get confidence without a slow, brittle pipeline.

Summary

  • Jasmine writes the tests, Karma runs them in a browser, and TestBed gives each test a small Angular environment.
  • Test plain logic without TestBed. Use spies to isolate dependencies.
  • Use provideHttpClientTesting() and HttpTestingController.verify() so no test touches a real server.
  • Test components through their rendered output and set inputs with setInput().
  • Control time with fakeAsync and tick instead of real waits.
  • Run the suite headless in CI and treat coverage as a guide, not a goal.

Testing discipline pays off well beyond the front end. On a backend data pipeline I led, automated tests with Jest were a large part of cutting production defects by about 15%. You can read about that system in the eHUB case study.