Angular Component and Service Tests — TestBed, Spies, and HTTP Mocking

Angular’s testing ecosystem — TestBed for component wiring, Jasmine/Jest for assertions, and the async testing utilities — provides everything needed to test the complete Angular frontend in isolation from the backend. Well-tested Angular code focuses on what users see and interact with, not on implementation details: does this template show the right data? Does clicking this button trigger the right action? Does this form show errors when submitted empty? This lesson completes the frontend testing picture with component tests, service tests, and async form validation tests.

Angular Testing Setup with Jest

Package Purpose
jest-preset-angular Configure Jest to understand Angular decorators, templates, and TypeScript
@angular/core/testing TestBed, ComponentFixture, fakeAsync, tick, waitForAsync
@angular/platform-browser/testing BrowserDynamicTestingModule
@angular/router/testing RouterTestingModule — stub router for component tests
@testing-library/angular User-centric testing utilities (optional but recommended)

Key Angular Testing Utilities

Utility Purpose
TestBed.configureTestingModule() Create a minimal Angular module for the test
TestBed.createComponent(C) Instantiate a component with its host DOM element
TestBed.inject(Token) Retrieve a service from the test injector
fixture.detectChanges() Trigger change detection and update the DOM
fixture.debugElement.query(By.css('sel')) Query descendant DOM elements
fakeAsync(fn) Run test in a zone that controls time
tick(ms) Advance fake clock by ms milliseconds
waitForAsync(fn) Wait for all async operations to complete
provideHttpClientTesting() Provide HttpClient with interceptable test backend
Note: Angular 16+ supports Jest via jest-preset-angular as an official alternative to Karma. Jest is preferred for new projects: it runs in Node.js (not a real browser), is significantly faster, has better CI support, and provides superior snapshot testing. The test syntax is identical to Karma/Jasmine except for a few matcher naming differences. Add it with ng add jest using the @briebug/jest-schematic or configure manually with jest-preset-angular.
Tip: Use jasmine.createSpyObj (Jasmine) or jest.fn() (Jest) to mock Angular services — never rely on the real service implementation in component tests. A component test for TaskListComponent should mock TaskStore with controlled signal values and spy methods. If the test uses the real TaskStore which uses the real TaskService which calls the real HTTP API — you are writing an E2E test, not a component test.
Warning: Angular standalone components require their imports to be explicitly declared in TestBed.configureTestingModule({ imports: [MyComponent, ...] }). The component’s own imports array (CommonModule, ReactiveFormsModule, child components) must all be available. If a child component is not imported in the test module, Angular throws an “unknown element” error. For large components, import only what you need and use NO_ERRORS_SCHEMA to suppress errors for unknown child components in unit tests.

Complete Angular Test Examples

// ── Testing a smart component ─────────────────────────────────────────────
// task-list.component.spec.ts
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { By }                    from '@angular/platform-browser';
import { RouterTestingModule }   from '@angular/router/testing';
import { signal }                from '@angular/core';
import { TaskListComponent }     from './task-list.component';
import { TaskStore }             from '../../../core/stores/task.store';
import { TaskCardComponent }     from '../task-card/task-card.component';
import { Task }                  from '../../../shared/models/task.model';

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

    const mockTasks: Task[] = [
        { _id: '1', title: 'Task One',  status: 'pending',   priority: 'high',   tags: [], user: 'u1', createdAt: '', updatedAt: '' },
        { _id: '2', title: 'Task Two',  status: 'completed', priority: 'medium', tags: [], user: 'u1', createdAt: '', updatedAt: '' },
    ];

    beforeEach(async () => {
        // Create spy object with spy methods + signal getters
        taskStore = jasmine.createSpyObj('TaskStore', ['load', 'delete', 'complete'], {
            tasks:     jasmine.createSpy('tasks').and.returnValue(mockTasks),
            isLoading: jasmine.createSpy('isLoading').and.returnValue(false),
            hasError:  jasmine.createSpy('hasError').and.returnValue(false),
            isEmpty:   jasmine.createSpy('isEmpty').and.returnValue(false),
            error:     jasmine.createSpy('error').and.returnValue(null),
        });

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

        fixture   = TestBed.createComponent(TaskListComponent);
        component = fixture.componentInstance;
    });

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

    it('should call store.load() on init', () => {
        fixture.detectChanges();   // triggers ngOnInit
        expect(taskStore.load).toHaveBeenCalledOnce();
    });

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

    it('should show spinner when loading', () => {
        taskStore.isLoading.and.returnValue(true);
        fixture.detectChanges();
        const spinner = fixture.debugElement.query(By.css('app-spinner'));
        expect(spinner).toBeTruthy();
    });

    it('should show empty state when no tasks', () => {
        taskStore.isEmpty.and.returnValue(true);
        taskStore.tasks.and.returnValue([]);
        fixture.detectChanges();
        const empty = fixture.debugElement.query(By.css('.empty-state'));
        expect(empty).toBeTruthy();
    });

    it('should call store.delete when task card emits deleted event', () => {
        fixture.detectChanges();
        const firstCard = fixture.debugElement.query(By.directive(TaskCardComponent));
        firstCard.triggerEventHandler('deleted', '1');
        expect(taskStore.delete).toHaveBeenCalledWith('1');
    });
});

// ── Testing a reactive form component ─────────────────────────────────────
// task-form.component.spec.ts
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { TaskFormComponent }   from './task-form.component';
import { RouterTestingModule } from '@angular/router/testing';
import { TaskStore }           from '../../../core/stores/task.store';

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

    beforeEach(async () => {
        taskStore = jasmine.createSpyObj('TaskStore', ['create', 'update']);

        await TestBed.configureTestingModule({
            imports:   [TaskFormComponent, RouterTestingModule],
            providers: [{ provide: TaskStore, useValue: taskStore }],
        }).compileComponents();

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

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

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

    it('should show required error after submit with empty form', () => {
        component.onSubmit();   // marks all as touched
        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();
    });

    it('should call store.create with form values on valid submit', () => {
        component.form.patchValue({ title: 'New Task', priority: 'medium' });
        component.onSubmit();
        expect(taskStore.create).toHaveBeenCalledWith(
            jasmine.objectContaining({ title: 'New Task', priority: 'medium' })
        );
    });

    // Async validator test
    it('should debounce async validation', fakeAsync(() => {
        const titleCtrl = component.form.get('title')!;
        titleCtrl.setValue('ab');   // triggers async validator
        tick(499);                   // not yet
        expect(titleCtrl.status).toBe('PENDING');
        tick(1);                     // 500ms elapsed — validator fires
        expect(titleCtrl.status).not.toBe('PENDING');
    }));
});

// ── Testing a service ─────────────────────────────────────────────────────
// task.service.spec.ts
import { TestBed }               from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TaskService }           from './task.service';
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();  // fail if any requests were not handled
    });

    it('should fetch tasks with correct URL', () => {
        const mockResponse = { success: true, data: [], meta: { total: 0 } };

        service.getAll({ page: 1, limit: 10 }).subscribe(res => {
            expect(res.data).toEqual([]);
        });

        const req = httpMock.expectOne(
            r => r.url.includes('/tasks') && r.params.get('page') === '1'
        );
        expect(req.request.method).toBe('GET');
        req.flush(mockResponse);
    });

    it('should send task data on create', () => {
        const dto = { title: 'New Task', priority: 'high' as const };
        const mockTask = { _id: '1', ...dto, status: 'pending', tags: [], user: 'u1', createdAt: '', updatedAt: '' };

        service.create(dto).subscribe(task => {
            expect(task.title).toBe('New Task');
        });

        const req = httpMock.expectOne('http://localhost:3000/api/v1/tasks');
        expect(req.request.method).toBe('POST');
        expect(req.request.body).toEqual(dto);
        req.flush({ success: true, data: mockTask });
    });
});

How It Works

Step 1 — jasmine.createSpyObj Mocks the Entire Store

jasmine.createSpyObj('TaskStore', ['load', 'delete'], { tasks: spy }) creates an object with load and delete as spy methods, and tasks as a spy property getter. This allows controlling what tasks() returns in each test — simulating different states (loading, empty, error) without running any real logic. Spy methods record calls for assertion: expect(taskStore.load).toHaveBeenCalledOnce().

Step 2 — detectChanges() Drives the Template Lifecycle

The first fixture.detectChanges() call runs ngOnInit() and renders the template for the first time. Subsequent calls re-run change detection to apply changes. Between creating the component and the first detectChanges(), the component class exists but the template has not been processed. DOM queries before detectChanges() return null.

Step 3 — triggerEventHandler Fires Angular Output Events

debugElement.triggerEventHandler('deleted', '1') invokes the (deleted)="handler($event)" binding in the parent’s template as if the child component emitted the event with value '1'. This tests that the parent correctly handles the event without requiring the child component to actually implement it — the child can be a stub or fully mocked.

Step 4 — fakeAsync and tick Test Debounced Validators

Async form validators that use timer(500) for debouncing cannot be tested without controlling time. fakeAsync() creates a controlled time environment. tick(499) advances 499ms — the validator is still pending. tick(1) advances the final millisecond — the validator fires. Without fakeAsync, the test would need to actually wait 500ms in real time.

Step 5 — HttpTestingController Intercepts HTTP Calls

httpMock.expectOne(url) intercepts the pending HTTP request and gives you a test request object. req.flush(responseData) responds to the request. httpMock.verify() in afterEach fails the test if any expected requests were not made or if unexpected requests were made. This allows testing the Angular service’s HTTP behaviour without a real server.

Common Mistakes

Mistake 1 — Testing with real services (not mocks)

❌ Wrong — component test pulls in real TaskStore which makes real HTTP calls:

await TestBed.configureTestingModule({
    imports: [TaskListComponent, HttpClientModule],
    // No TaskStore mock — real store makes real HTTP requests!
})

✅ Correct — provide a mock for every injected service:

providers: [{ provide: TaskStore, useValue: taskStoreMock }]

Mistake 2 — Forgetting to call detectChanges() after state changes

❌ Wrong — DOM still shows previous state:

taskStore.isLoading.and.returnValue(true);
// Missing: fixture.detectChanges()
const spinner = fixture.debugElement.query(By.css('app-spinner'));
expect(spinner).toBeTruthy();  // null — DOM not updated!

✅ Correct — detectChanges() after any state change:

taskStore.isLoading.and.returnValue(true);
fixture.detectChanges();   // re-render with new loading state
const spinner = fixture.debugElement.query(By.css('app-spinner'));
expect(spinner).toBeTruthy();

Mistake 3 — Not verifying httpMock after each test

❌ Wrong — unexpected or unmade HTTP requests go undetected:

// Missing afterEach:
// afterEach(() => httpMock.verify())
// Result: a test that expects 2 HTTP calls might pass with 1 (silent failure)

✅ Correct — always verify in afterEach:

afterEach(() => httpMock.verify());

Quick Reference

Task Angular Testing Code
Create spy object jasmine.createSpyObj('Name', ['method'], { prop: spy })
Provide mock { provide: MyService, useValue: mockService }
Create component fixture = TestBed.createComponent(MyComponent)
Render template fixture.detectChanges()
Query element fixture.debugElement.query(By.css('.class'))
Query component fixture.debugElement.queryAll(By.directive(TaskCard))
Trigger output debugEl.triggerEventHandler('deleted', id)
Set form value component.form.patchValue({ title: 'Test' })
Test debounced async fakeAsync(() => { setValue(...); tick(500); expect(...) })
Mock HTTP call const req = httpMock.expectOne(url); req.flush(data)

🧠 Test Yourself

A component test needs to verify that clicking a “Delete” button on a task card calls taskStore.delete('task-id-1'). The task card emits a (deleted) output event. How is this tested?