Angular Testing Tools — Jasmine, Karma, TestBed and Testing Library

📋 Table of Contents
  1. Angular Testing Setup
  2. Common Mistakes

Angular’s testing setup ships with every new project — Jasmine as the test framework, Karma as the test runner, and TestBed as the Angular-specific testing module. Angular Testing Library provides a higher-level API that encourages testing components from the user’s perspective (find by text, click, assert visible text) rather than by internal implementation details (call lifecycle hooks, inspect private properties). Both approaches coexist and are used for different scenarios.

Angular Testing Setup

// ── Standard spec file generated by Angular CLI ────────────────────────────
// src/app/features/posts/post-list.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PostListComponent }         from './post-list.component';
import { PostsApiService }           from '@core/services/posts-api.service';
import { of, throwError }            from 'rxjs';
import { provideRouter }             from '@angular/router';

describe('PostListComponent', () => {
  let fixture:   ComponentFixture<PostListComponent>;
  let component: PostListComponent;
  let postsApi:  jasmine.SpyObj<PostsApiService>;

  beforeEach(async () => {
    // Create a spy object for the service (replaces Moq in .NET)
    postsApi = jasmine.createSpyObj('PostsApiService', ['getPublished']);
    postsApi.getPublished.and.returnValue(of({
      items: [
        { id: 1, title: 'Test Post', slug: 'test-post', viewCount: 100,
          authorName: 'Alice', publishedAt: '2024-01-01T00:00:00Z' }
      ],
      total: 1, page: 1, pageSize: 10, hasNextPage: false, hasPrevPage: false,
    }));

    await TestBed.configureTestingModule({
      imports:   [PostListComponent],          // standalone component
      providers: [
        provideRouter([]),                     // stub router
        { provide: PostsApiService, useValue: postsApi },
        { provide: APP_CONFIG, useValue: { apiUrl: '', production: false } },
      ],
    }).compileComponents();

    fixture   = TestBed.createComponent(PostListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();               // triggers ngOnInit
  });

  it('should display post titles from the API', () => {
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector('h2')!.textContent).toContain('Test Post');
  });

  it('should show an error state when the API fails', () => {
    postsApi.getPublished.and.returnValue(throwError(() => new Error('Network error')));
    fixture.detectChanges();
    const errorEl = fixture.nativeElement.querySelector('.error');
    expect(errorEl).not.toBeNull();
  });
});

// ── Angular Testing Library — test from the user's perspective ────────────
// npm install --save-dev @testing-library/angular @testing-library/jest-dom
import { render, screen, fireEvent } from '@testing-library/angular';

describe('PostListComponent (Testing Library)', () => {
  it('shows post title in the list', async () => {
    await render(PostListComponent, {
      providers: [
        { provide: PostsApiService, useValue: {
          getPublished: () => of({ items: [{ title: 'TL Post' }], total: 1, ... })
        }},
      ],
    });

    // Query by visible text — as a user would see it
    expect(screen.getByText('TL Post')).toBeInTheDocument();
  });

  it('shows empty state when no posts exist', async () => {
    await render(PostListComponent, {
      providers: [
        { provide: PostsApiService, useValue: {
          getPublished: () => of({ items: [], total: 0, ... })
        }},
      ],
    });

    expect(screen.getByText('No posts found.')).toBeInTheDocument();
  });
});
Note: fixture.detectChanges() manually triggers Angular’s change detection in tests — it does not happen automatically. Call it after each state change that should update the template: after setting component inputs, after an Observable emits, or after calling a component method that updates signals. Without detectChanges(), the template reflects the initial state only and your assertions on the rendered HTML will fail unexpectedly. In Angular 18 with Signal-based components, change detection is more granular but detectChanges() is still required in TestBed tests.
Tip: Use Angular Material harnesses (MatButtonHarness, MatInputHarness) instead of raw DOM queries when testing Material components. Harnesses provide a stable API that works correctly regardless of Material’s internal DOM structure, which can change between versions. Example: const input = await loader.getHarness(MatInputHarness); await input.setValue('test');. Access the harness loader via TestbedHarnessEnvironment.loader(fixture).
Warning: Do not test implementation details — avoid asserting which service methods were called, which signals were set, or which private methods were invoked. Test the observable behaviour: what the user sees (rendered HTML), what the component emits (Output events), and what side effects happen (navigation, notification). Testing implementation details creates fragile tests that break on refactoring without any actual regression in behaviour.

Common Mistakes

Mistake 1 — Forgetting fixture.detectChanges() after state change (stale template assertions)

❌ Wrong — setting component.posts.set([]) then asserting template without fixture.detectChanges(); template not updated.

✅ Correct — always call fixture.detectChanges() after any state change before asserting on the template.

Mistake 2 — Not providing all dependencies in TestBed (test fails with injection error)

❌ Wrong — missing provideRouter([]) for a component that uses RouterLink; test fails with NullInjectorError.

✅ Correct — provide all dependencies (real or mock); use stub providers (useValue: {}) for services whose behaviour is not under test.

🧠 Test Yourself

A component uses a signal: posts = signal([]). After the test sets component.posts.set([post]), the test asserts on the DOM. Is fixture.detectChanges() needed?