Angular Testing — TestBed, Mocking Services, and Component Tests

Testing is the discipline that transforms Angular code from “it seems to work” to “it provably works under defined conditions.” Angular’s testing toolkit — Jasmine for test structure, Karma/Jest for execution, and TestBed for Angular-aware component and service testing — provides everything you need to write unit tests for components, integration tests for services, and end-to-end tests with Cypress or Playwright. This lesson focuses on the tests that provide the most value for the least effort: unit testing services with mocked dependencies, testing components with TestBed, and testing reactive forms.

Testing Levels in Angular

Level Tests Tools Speed
Unit tests Individual service methods, pure functions, pipes, validators Jasmine/Jest — no DOM Very fast (< 1ms each)
Component tests Component template rendering, bindings, user interaction TestBed — real Angular DI + DOM Fast (1–10ms each)
Integration tests Multiple components working together TestBed with real dependencies Medium (10–100ms each)
E2E tests Full user flows in a real browser Cypress, Playwright Slow (seconds each)

TestBed API

Method Purpose
TestBed.configureTestingModule(config) Set up the test module with imports, declarations, providers
TestBed.createComponent(ComponentClass) Create a component instance with its host element
TestBed.inject(Token) Get a service or value from the test injector
fixture.detectChanges() Run change detection — update the DOM
fixture.debugElement Angular’s wrapper around the host element
fixture.nativeElement Raw DOM element
fixture.debugElement.query(By.css('.sel')) Query descendant elements
fixture.debugElement.queryAll(By.directive(Dir)) Query all matching descendants
Note: Angular 16+ supports Jest as an alternative to Karma/Jasmine. Jest is significantly faster (no browser process — runs in Node.js with jsdom), has better TypeScript support, and uses a simpler configuration. Add it with ng add jest or configure manually. The test syntax is nearly identical to Jasmine: describe, it, expect, beforeEach — but Jest’s matchers are slightly different (toHaveBeenCalledWith vs Jasmine’s toHaveBeenCalledOnceWith).
Tip: Mock services with jasmine.createSpyObj('ServiceName', ['method1', 'method2']) — it creates an object with all specified methods as spy functions that return undefined by default. Use spy.and.returnValue(of(mockData)) to make a method return an Observable. This approach is faster than HttpClientTestingModule for component tests where you just want to control what the service returns without testing HTTP behaviour.
Warning: Always call fixture.detectChanges() after setting up the component and after making changes to trigger template rendering. Without it, the template is not rendered, and queries for DOM elements return null. For async operations, use fakeAsync() + tick() or waitForAsync() + fixture.whenStable() to wait for all pending async operations before asserting. Testing async code without proper async handling leads to false positives.

Complete Testing Examples

// ── Testing a service ─────────────────────────────────────────────────────
import { TestBed }          from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TaskService }      from './task.service';
import { Task }             from '../models/task.model';
import { API_BASE_URL }     from '../tokens';

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

    beforeEach(() => {
        TestBed.configureTestingModule({
            imports:   [HttpClientTestingModule],
            providers: [
                TaskService,
                { provide: API_BASE_URL, useValue: 'http://localhost:3000/api/v1' },
            ],
        });
        service  = TestBed.inject(TaskService);
        httpMock = TestBed.inject(HttpTestingController);
    });

    afterEach(() => {
        httpMock.verify();  // assert no unexpected HTTP requests
    });

    it('should fetch all tasks', () => {
        const mockTasks: Task[] = [
            { _id: '1', title: 'Task 1', status: 'pending', priority: 'medium' } as Task,
        ];

        service.getAll().subscribe(tasks => {
            expect(tasks.length).toBe(1);
            expect(tasks[0].title).toBe('Task 1');
        });

        const req = httpMock.expectOne('http://localhost:3000/api/v1/tasks');
        expect(req.request.method).toBe('GET');
        req.flush({ success: true, data: mockTasks });  // respond to the pending request
    });

    it('should handle 404 errors', () => {
        service.getById('nonexistent').subscribe({
            error: err => expect(err.status).toBe(404),
        });

        const req = httpMock.expectOne('http://localhost:3000/api/v1/tasks/nonexistent');
        req.flush({ message: 'Not found' }, { status: 404, statusText: 'Not Found' });
    });
});

// ── Testing a component with mocked service ───────────────────────────────
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { By }                                         from '@angular/platform-browser';
import { of, throwError }                             from 'rxjs';
import { TaskListComponent }   from './task-list.component';
import { TaskCardComponent }   from '../task-card/task-card.component';
import { TaskStore }           from '../../core/stores/task.store';
import { RouterTestingModule } from '@angular/router/testing';

describe('TaskListComponent', () => {
    let fixture: ComponentFixture<TaskListComponent>;
    let component: TaskListComponent;
    let taskStoreMock: jasmine.SpyObj<TaskStore>;

    const mockTasks = [
        { _id: '1', title: 'First Task',  status: 'pending',   priority: 'high'   } as Task,
        { _id: '2', title: 'Second Task', status: 'completed', priority: 'medium' } as Task,
    ];

    beforeEach(async () => {
        taskStoreMock = jasmine.createSpyObj('TaskStore', ['loadAll', 'delete', 'complete'], {
            // Spy on signal getters
            tasks:     jasmine.createSpy().and.returnValue(mockTasks),
            isLoading: jasmine.createSpy().and.returnValue(false),
            error:     jasmine.createSpy().and.returnValue(null),
        });

        await TestBed.configureTestingModule({
            imports: [
                TaskListComponent,       // standalone component
                RouterTestingModule,
            ],
            providers: [
                { provide: TaskStore, useValue: taskStoreMock },
            ],
        }).compileComponents();

        fixture   = TestBed.createComponent(TaskListComponent);
        component = fixture.componentInstance;
        fixture.detectChanges();  // run ngOnInit, render template
    });

    it('should create', () => {
        expect(component).toBeTruthy();
    });

    it('should call loadAll on init', () => {
        expect(taskStoreMock.loadAll).toHaveBeenCalledOnce();
    });

    it('should render task cards for each task', () => {
        const cards = fixture.debugElement.queryAll(By.directive(TaskCardComponent));
        expect(cards.length).toBe(2);
    });

    it('should render task titles', () => {
        const titles = fixture.nativeElement.querySelectorAll('.task-title');
        expect(titles[0].textContent).toContain('First Task');
    });

    it('should call store.delete when task is deleted', () => {
        const card = fixture.debugElement.query(By.directive(TaskCardComponent));
        card.triggerEventHandler('deleted', '1');  // simulate @Output event
        expect(taskStoreMock.delete).toHaveBeenCalledWith('1');
    });

    it('should show loading spinner when loading', () => {
        taskStoreMock.isLoading.and.returnValue(true);
        fixture.detectChanges();
        const spinner = fixture.nativeElement.querySelector('app-spinner');
        expect(spinner).toBeTruthy();
    });
});

// ── Testing a reactive form ───────────────────────────────────────────────
import { ReactiveFormsModule } from '@angular/forms';
import { TaskFormComponent }   from './task-form.component';

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

    beforeEach(async () => {
        await TestBed.configureTestingModule({
            imports: [TaskFormComponent, RouterTestingModule],
        }).compileComponents();

        fixture   = TestBed.createComponent(TaskFormComponent);
        component = fixture.componentInstance;
        fixture.detectChanges();
    });

    it('should be invalid when title is empty', () => {
        component.form.get('title')!.setValue('');
        expect(component.form.invalid).toBeTrue();
    });

    it('should be valid when required fields are filled', () => {
        component.form.patchValue({ title: 'New Task', priority: 'high' });
        expect(component.form.valid).toBeTrue();
    });

    it('should show title error after submitting with empty title', () => {
        component.form.get('title')!.setValue('');
        component.onSubmit();  // calls markAllAsTouched
        fixture.detectChanges();

        const error = fixture.nativeElement.querySelector('[data-testid="title-error"]');
        expect(error?.textContent).toContain('required');
    });

    it('should disable submit button when form is invalid', () => {
        component.form.get('title')!.setValue('');
        fixture.detectChanges();

        const btn = fixture.nativeElement.querySelector('button[type=submit]');
        expect(btn.disabled).toBeTrue();
    });
});

// ── Testing a pure pipe ───────────────────────────────────────────────────
import { RelativeDatePipe } from './relative-date.pipe';

describe('RelativeDatePipe', () => {
    const pipe = new RelativeDatePipe();

    it('returns "just now" for very recent dates', () => {
        expect(pipe.transform(new Date())).toBe('just now');
    });

    it('returns "Xm ago" for minutes', () => {
        const fiveMinAgo = new Date(Date.now() - 5 * 60000);
        expect(pipe.transform(fiveMinAgo)).toBe('5m ago');
    });

    it('returns empty string for null', () => {
        expect(pipe.transform(null)).toBe('');
    });
});

How It Works

Step 1 — TestBed Creates an Angular Testing Module

TestBed.configureTestingModule() sets up a minimal Angular module for testing. You declare only what the component under test needs — its own imports (for standalone), mock services in providers, and any necessary testing utilities like RouterTestingModule. The testing module is isolated from the application module — each test suite gets a fresh module with a fresh injector.

Step 2 — fixture.detectChanges() Drives the Template

Angular’s testing environment does not automatically run change detection. You control when the template is rendered by calling fixture.detectChanges(). The first call triggers ngOnInit() and renders the template. Subsequent calls re-run change detection to apply changes you made (setting signal values, patching form controls). This explicit control makes tests predictable — you always know when the DOM was last updated.

Step 3 — Spy Objects Mock Services Without Real Implementations

jasmine.createSpyObj('Name', ['method1', 'method2']) creates a mock object with all specified methods as spies. Spies record how many times they are called, with what arguments, and can be configured to return specific values. spy.and.returnValue(of(data)) makes the spy return an Observable. This isolates the component from real HTTP calls and database operations, making tests fast and reliable.

Step 4 — By.css() and By.directive() Query the Template

fixture.debugElement.query(By.css('.my-class')) finds the first descendant element matching a CSS selector. queryAll(By.directive(TaskCardComponent)) finds all instances of a specific directive/component. The debugElement wraps the DOM element with Angular metadata — you can trigger Angular-aware events with debugElement.triggerEventHandler('click', {}), which goes through Angular’s event binding system rather than DOM events.

Step 5 — fakeAsync and tick Control Time

fakeAsync(() => { ... }) wraps a test in a zone that tracks async operations. Inside, tick(ms) advances the virtual clock by the specified milliseconds — resolving any pending setTimeouts, intervals, or Promises that would complete in that time. tick() (no argument) flushes all pending microtasks. This makes testing debounced form validation, animated state changes, and delayed API calls deterministic without real waiting.

Common Mistakes

Mistake 1 — Not calling fixture.detectChanges() before asserting

❌ Wrong — template not rendered, DOM queries return null:

fixture = TestBed.createComponent(MyComponent);
// Missing: fixture.detectChanges();
const el = fixture.nativeElement.querySelector('.title');
expect(el.textContent).toBe('Hello');  // el is null — template not rendered!

✅ Correct — always detectChanges() before asserting on the DOM:

fixture = TestBed.createComponent(MyComponent);
fixture.detectChanges();  // renders template, calls ngOnInit
const el = fixture.nativeElement.querySelector('.title');
expect(el?.textContent).toBe('Hello');

Mistake 2 — Testing implementation details instead of behaviour

❌ Wrong — tests break when refactoring even though behaviour is unchanged:

expect(component.privateFilterMethod()).toEqual([...]);  // testing internal method

✅ Correct — test observable behaviour (template output, events):

component.filterStatus.set('pending');
fixture.detectChanges();
const cards = fixture.debugElement.queryAll(By.directive(TaskCardComponent));
expect(cards.length).toBe(2);  // test the result, not the mechanism

Mistake 3 — Not using fakeAsync for async operations

❌ Wrong — test passes before async completes:

it('should debounce search', () => {
    component.searchInput.setValue('task');
    expect(mockService.search).toHaveBeenCalled();  // false! debounce not elapsed
});

✅ Correct — use fakeAsync + tick to advance time:

it('should debounce search', fakeAsync(() => {
    component.searchInput.setValue('task');
    tick(300);  // advance past debounce
    expect(mockService.search).toHaveBeenCalledWith('task');
}));

Quick Reference

Task Code
Configure test module TestBed.configureTestingModule({ imports: [...], providers: [...] })
Create component fixture = TestBed.createComponent(MyComponent)
Get service service = TestBed.inject(MyService)
Render template fixture.detectChanges()
Query DOM fixture.debugElement.query(By.css('.cls'))
Query all fixture.debugElement.queryAll(By.directive(MyComp))
Trigger event debugEl.triggerEventHandler('click', {})
Mock service jasmine.createSpyObj('Svc', ['method'])
Mock Observable return spy.method.and.returnValue(of(data))
Test async fakeAsync(() => { ...; tick(300); expect(...) })

🧠 Test Yourself

A component test creates a component with TestBed.createComponent() but never calls fixture.detectChanges(). A test tries to query fixture.nativeElement.querySelector('.task-list'). What is the result?