Angular Intermediate #7: Testing — TestBed and Component Tests

10 min read

Last time we covered Guards and Resolvers — the tools that intercept route flow. This time is the final post in the Angular Intermediate series: testing. Unlike most other frameworks, Angular has had testing baked in from the start. ng new drops a test runner and a starter spec file alongside the project, and every CLI generator creates a spec file with each artifact. The “I’ll write tests later” culture barely exists.

In this post we walk through where the tooling sits, then cover unit-testing a Service, component testing, mock injection, HttpClient testing, and ComponentHarness — all in one go.

Where Angular’s testing tools sit #

The default setup is Jasmine + Karma. Jasmine provides the test syntax — describe/it/expect — plus spy/mock utilities; Karma plays the role of running those specs in a real browser. Run ng test and Karma launches Chrome and runs the Jasmine-written tests inside it.

The recent trend has shifted, though. From Angular 16, there’s a clear move toward Node-based runners like Jest and Vitest. Karma itself is deprecated, and more new projects are picking Jest/Vitest. But the way you write tests (TestBed, fixture, inject) is the same. Whatever runner you use, the contents of this post still apply. The post itself uses the default Jasmine syntax.

Unit-testing a Service — without TestBed #

For a pure class with no Angular dependencies, just new it and verify. If you don’t need DI or rendering, the simplest approach is the best.

src/app/cart.service.spec.ts
import { CartService } from './cart.service';

describe('CartService', () => {
  it('adding items accumulates the total', () => {
    const cart = new CartService();
    cart.add({ id: 1, price: 1000 });
    cart.add({ id: 2, price: 2500 });

    expect(cart.items().length).toBe(2);
    expect(cart.total()).toBe(3500);
  });
});

You don’t need to drag in TestBed. With signals, you just call them as functions to read values. For a Service that doesn’t depend on DI or other Angular features, this kind of unit test is the fastest and lightest (assume CartService itself is a regular class computing the total via signal + computed).

TestBed introduction #

Once you’re testing a Service that gets injected with other Services or HttpClient, the story changes. You could new it up and pass dependencies by hand, but it’s much more natural to use Angular’s DI system as-is. That’s what TestBed is for.

Think of TestBed as a tool that spins up a minimal test-only Angular application. With configureTestingModule, you register imports (standalone components or modules) and providers (service mocks, etc.), then pull instances out via TestBed.inject(...).

src/app/logger.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { LoggerService } from './logger.service';

describe('LoggerService (TestBed)', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [LoggerService],
    });
  });

  it('the injected instance is a singleton', () => {
    const a = TestBed.inject(LoggerService);
    const b = TestBed.inject(LoggerService);
    expect(a).toBe(b);
  });
});

The biggest weapon TestBed gives you is swapping in a mock through providers with the form { provide: SomeService, useValue: fakeService }. We use this pattern in earnest in component tests next.

Note
A Service registered with providedIn: 'root' can be retrieved via TestBed.inject without listing it in providers. Just register it in providers only when you want to swap it for a mock.

Component testing (1) — setup #

A component has a screen, so a unit test alone isn’t enough. Render it once for real and verify the result. TestBed gives you that environment.

Start with a simple CounterComponent (standalone, signal-based) that has <p data-testid="value">Current: {{ count() }}</p> and a +1 button. The test flow looks like this.

src/app/counter.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';

describe('CounterComponent', () => {
  let fixture: ComponentFixture<CounterComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [CounterComponent], // standalone goes in imports
    });
    fixture = TestBed.createComponent(CounterComponent);
    fixture.detectChanges(); // first render
  });

  it('initial value is 0', () => {
    const el: HTMLElement = fixture.nativeElement;
    expect(el.textContent).toContain('Current: 0');
  });
});

Three things to remember.

  1. Standalone components go in imports. In the module era they went in declarations, but new code doesn’t use that slot anymore.
  2. TestBed.createComponent(...) returns a ComponentFixture. That fixture is your handle to the component instance (fixture.componentInstance) and the DOM (fixture.nativeElement).
  3. You must call fixture.detectChanges() to run a change-detection cycle so the template renders. Call it again after signal/@Input changes to refresh the screen.

Component testing (2) — DOM verification #

Now to verify the rendered DOM. The simplest approach is to find an element with querySelector on fixture.nativeElement and check its text, attributes, or classes.

counter.component.spec.ts
it('clicking +1 sets the value to 1', () => {
  const el: HTMLElement = fixture.nativeElement;

  const button = el.querySelector('button')!;
  button.click();
  fixture.detectChanges();

  const value = el.querySelector('[data-testid="value"]')!;
  expect(value.textContent).toContain('Current: 1');
});

Which selector you use is a matter of taste, but I’d recommend the pattern of adding test-only attributes like data-testid. Selecting by CSS class or text is fragile when design changes; data-testid stays stable as long as the meaning doesn’t change.

Component testing (3) — user events #

As shown above with button.click(), for user events you can call DOM methods directly or fire them through dispatchEvent. For input events, the safest path is to set value and then dispatch an input event manually. Suppose there’s a GreetComponent (with a name signal) that updates its greeting based on what’s typed in an <input>.

simulating an input event
it('typing in input updates the greeting', () => {
  TestBed.configureTestingModule({ imports: [GreetComponent] });
  const fixture = TestBed.createComponent(GreetComponent);
  fixture.detectChanges();

  const input: HTMLInputElement =
    fixture.nativeElement.querySelector('[data-testid="name"]');

  input.value = 'Curtis';
  input.dispatchEvent(new Event('input'));
  fixture.detectChanges();

  const hello = fixture.nativeElement.querySelector('[data-testid="hello"]');
  expect(hello.textContent).toContain('Hello, Curtis!');
});

Just assigning value doesn’t notify Angular that anything changed. You have to fire a real input event with dispatchEvent(new Event('input')) so the bound handler runs, and then call detectChanges() again to update the greeting.

Mocking Service dependencies #

Real components mostly receive Services via injection. Using the real Service makes tests heavy and lets external dependencies (HTTP, time, etc.) leak in. The fix is the pattern we glanced at in Basics #5swap in a mock.

Suppose UserListComponent injects UserService, renders userService.users(), and calls userService.refresh() from a button. In the test, replace the real UserService implementation with a fake.

src/app/user-list.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { UserListComponent } from './user-list.component';
import { UserService } from './user.service';

describe('UserListComponent', () => {
  it('renders the users held by the Service', () => {
    const fakeUserService = {
      users: signal([{ id: 1, name: 'Curtis' }]).asReadonly(),
      refresh: jasmine.createSpy('refresh'),
    };

    TestBed.configureTestingModule({
      imports: [UserListComponent],
      providers: [{ provide: UserService, useValue: fakeUserService }],
    });

    const fixture = TestBed.createComponent(UserListComponent);
    fixture.detectChanges();

    const items =
      fixture.nativeElement.querySelectorAll('[data-testid="user"]');
    expect(items.length).toBe(1);
    expect(items[0].textContent).toContain('Curtis');
  });

  it('the refresh button calls Service.refresh', () => {
    const refresh = jasmine.createSpy('refresh');
    TestBed.configureTestingModule({
      imports: [UserListComponent],
      providers: [
        {
          provide: UserService,
          useValue: { users: signal([]).asReadonly(), refresh },
        },
      ],
    });
    const fixture = TestBed.createComponent(UserListComponent);
    fixture.detectChanges();

    fixture.nativeElement.querySelector('button').click();
    expect(refresh).toHaveBeenCalledTimes(1);
  });
});

For simple data, a hand-rolled object is enough. When you need to verify method calls, use jasmine.createSpy — or jasmine.createSpyObj<UserService>('UserService', ['refresh', 'add']) to bundle multiple methods into a spy object at once. Spies can verify whether a method was called, how many times, and with what arguments — they’re powerful.

Testing HttpClient #

What about testing a Service that uses HttpClient? Letting it hit the real network makes tests slow and flaky. Angular ships HttpTestingController for this — intercepts requests without ever touching the network and lets you flush fake responses.

In test modules, alongside (or instead of) provideHttpClient, register provideHttpClientTesting (Angular 18+).

src/app/user-api.service.spec.ts
import { TestBed } from '@angular/core/testing';
import {
  provideHttpClient,
  HttpClient,
} from '@angular/common/http';
import {
  provideHttpClientTesting,
  HttpTestingController,
} from '@angular/common/http/testing';
import { UserApiService } from './user-api.service';

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

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        UserApiService,
        provideHttpClient(),
        provideHttpClientTesting(),
      ],
    });
    service = TestBed.inject(UserApiService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify(); // fails if any request is left unhandled
  });

  it('issues GET /users and passes the response through', () => {
    const fake = [{ id: 1, name: 'Curtis' }];

    service.getUsers().subscribe((users) => {
      expect(users).toEqual(fake);
    });

    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('GET');
    req.flush(fake); // flush a fake response
  });
});

The flow:

  1. Call the Service method and subscribe to the result (no response yet, so the callback is parked).
  2. Use httpMock.expectOne(url) to verify that the request actually went out.
  3. req.flush(fake data) flushes a fake response, which finally fires the subscribe callback and lets you verify the data.
  4. In afterEach, httpMock.verify() checks no requests were left unhandled.

Error responses can be simulated similarly — req.flush('boom', { status: 500, statusText: 'Server Error' }) lets you verify that the catchError + of([]) fallback pattern from Basics #7 actually works.

ComponentHarness — if you use Material #

Once you start dealing with complex UI components (Material’s mat-select, mat-dialog — anything with a big internal DOM) via querySelector, your test code gets messy fast. Angular provides an abstraction layer for this called Component Test Harness.

harness usage example
const fixture = TestBed.createComponent(SaveFormComponent);
const loader = TestbedHarnessEnvironment.loader(fixture);

const saveButton = await loader.getHarness(
  MatButtonHarness.with({ text: 'Save' })
);
await saveButton.click();

expect(fixture.componentInstance.onSave).toHaveBeenCalled();

A harness is a small API layer that exposes only “what a user of this component can do.” Even if the internal DOM structure changes, your tests don’t break as long as the harness API stays the same. In projects using Angular Material it’s effectively standard, and you can apply the same pattern to your own design-system components.

Tip
Start fast with querySelector, and once you find yourself selecting the same element for the same behavior across multiple tests, that’s when to consider a harness. The right time to bring in a tool is usually when you’re thinking, “wait, am I writing this for the third time?”

Wrapping up — closing out the intermediate series #

In this post we did one sweep through Angular’s testing tools.

  • Jasmine + Karma is the default; the trend toward Jest/Vitest is real but writing style is the same
  • For Services with no Angular dependencies, just new and verify
  • Use TestBed to set up a DI context and swap in mocks via providers
  • Component tests: createComponentdetectChanges → verify on nativeElement; events go through dispatchEvent
  • HttpTestingController to verify HTTP flows without the real network
  • In Material/CDK environments, use ComponentHarness for one more layer of robustness

That wraps up the final post of the Angular Intermediate series. Starting from #1 with Reactive Forms, through RxJS in depth, lifecycle and change detection, lazy loading and route guards, and today’s testing — if the basics series was about “how to use the tools,” the intermediate series has been about “the patterns for actually shipping products with those tools.” You now have the fundamentals to take Angular through one full cycle of a real product, not just a small demo.

The next step is the “Angular Advanced Course.” It’ll work through the deeper topics deliberately left out of intermediate.

  • Change Detection in depth — the truth about Zone.js, how the OnPush strategy actually works, and the path toward Zoneless Angular
  • Signals in depth — the fine-grained behavior of effect and computed, signal-based component inputs (input(), model()), the Resource API
  • Standalone API in depth — the design of provide* functions, environment injectors, working directly with the DI tree
  • RxJS in depth — the differences among higher-order operators (switchMap vs mergeMap vs concatMap vs exhaustMap), custom operators, multicasting and backpressure
  • NgRx and state management — Store, Effects, Entity, Component Store, all the way to Signal Store — how to handle state in a large app
  • SSR in depth and hydration strategies — partial hydration, integration with deferrable views, performance tuning
  • Performance and bundle optimization — build analysis, real-world use of deferrable views, polishing Core Web Vitals

To everyone who followed along through basics and intermediate — well done, truly. The hardest part of any course is finishing it, and you did the hard part. I’ll meet you again with deeper material in the advanced course. Until then, I strongly recommend building a small Angular app from scratch, end to end — one 200-line app you built yourself is a far greater asset than reading seven posts.

X