Angular Testing: Unit Tests, Component Tests & E2E Guide (2026)

Angular Testing: Unit Tests, Component Tests & E2E Guide (2026)

Angular testing uses Jasmine + Karma by default for unit/component tests, but Jest is faster and increasingly preferred. Component testing uses Angular's TestBed to render components in isolation. For E2E testing, Playwright or Cypress replace the deprecated Protractor. The testing pyramid for Angular: many unit tests → some integration tests → few E2E tests. Modern teams also add HelpMeTest for AI-generated E2E tests and production monitoring.

Key Takeaways

Angular's built-in testing tools are mature but verbose. TestBed, ComponentFixture, and detectChanges() work well but require more setup than React Testing Library. Many teams switch to Jest for speed and add jest-auto-mocking to reduce boilerplate.

Replace Karma with Jest. Karma is deprecated in Angular 17+. Jest runs tests in milliseconds (no browser overhead), has better watch mode, and produces cleaner output. Migrate to Jest as soon as possible.

Protractor is dead — use Playwright for E2E. Protractor was deprecated in Angular 12 and removed in Angular 15. The Angular team officially recommends Playwright and Cypress as replacements.

Component tests are the sweet spot. Pure unit tests are fast but test implementation details. Full E2E tests are slow. Component tests with TestBed hit the right balance: test behavior through the template, isolate from network calls, run in under a second.

Testing Services and HTTP with HttpClientTestingModule. Never make real HTTP calls in tests. HttpClientTestingModule provides a mock HTTP backend that lets you control exactly what the API returns in each test.

Angular Testing Fundamentals

Angular projects come with a testing setup out of the box. When you run ng new, you get:

  • Jasmine — test framework (describe/it/expect syntax)
  • Karma — test runner (launches a browser to run tests) — deprecated in Angular 17
  • TestBed — Angular-specific testing utilities for component/service tests

For E2E tests, Angular previously used Protractor (deprecated 2021). Modern projects use Playwright or Cypress.


Setting Up Testing in Angular

Default Setup (Jasmine + Karma)

New Angular projects come ready to test:

ng new my-app --no-standalone
cd my-app
ng <span class="hljs-built_in">test  <span class="hljs-comment"># Runs unit tests in watch mode

Jest is faster, has better error messages, and doesn't require a browser to run:

# Remove Karma
ng remove @angular-devkit/build-angular

<span class="hljs-comment"># Install Jest
npm install -D jest @jest/globals jest-environment-jsdom jest-preset-angular @types/jest

<span class="hljs-comment"># Configure Jest
<span class="hljs-built_in">cat > jest.config.ts << <span class="hljs-string">'EOF'
import <span class="hljs-built_in">type { Config } from <span class="hljs-string">'jest';

const config: Config = {
  preset: <span class="hljs-string">'jest-preset-angular',
  setupFilesAfterFramework: [<span class="hljs-string">'<rootDir>/setup-jest.ts'],
  testEnvironment: <span class="hljs-string">'jsdom',
};

<span class="hljs-built_in">export default config;
EOF

<span class="hljs-comment"># Setup file
<span class="hljs-built_in">cat > setup-jest.ts << <span class="hljs-string">'EOF'
import <span class="hljs-string">'jest-preset-angular/setup-jest';
EOF

Update tsconfig.spec.json:

{
  "compilerOptions": {
    "types": ["jest"]
  }
}

Unit Testing Angular Components

Basic Component Test

// counter.component.ts
@Component({
  selector: 'app-counter',
  template: `
    <p>Count: {{ count }}</p>
    <button (click)="increment()">+</button>
    <button (click)="decrement()">-</button>
  `
})
export class CounterComponent {
  count = 0;
  increment() { this.count++; }
  decrement() { this.count--; }
}
// counter.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';

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

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [CounterComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(CounterComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();  // Trigger initial change detection
  });

  it('should display initial count of 0', () => {
    const para = fixture.nativeElement.querySelector('p');
    expect(para.textContent).toContain('Count: 0');
  });

  it('should increment count when + button is clicked', () => {
    const button = fixture.nativeElement.querySelector('button');
    button.click();
    fixture.detectChanges();

    expect(component.count).toBe(1);
    const para = fixture.nativeElement.querySelector('p');
    expect(para.textContent).toContain('Count: 1');
  });

  it('should decrement count when - button is clicked', () => {
    component.count = 5;
    fixture.detectChanges();

    const buttons = fixture.nativeElement.querySelectorAll('button');
    buttons[1].click();  // Decrement button
    fixture.detectChanges();

    expect(component.count).toBe(4);
  });
});

Testing with Input Properties

// product-card.component.ts
@Component({
  selector: 'app-product-card',
  template: `
    <div class="card">
      <h2>{{ product.name }}</h2>
      <p>{{ product.price | currency }}</p>
      <button [disabled]="!product.inStock" (click)="addToCart()">
        {{ product.inStock ? 'Add to Cart' : 'Out of Stock' }}
      </button>
    </div>
  `
})
export class ProductCardComponent {
  @Input() product!: { name: string; price: number; inStock: boolean };
  @Output() cartAdd = new EventEmitter<void>();

  addToCart() { this.cartAdd.emit(); }
}
// product-card.component.spec.ts
describe('ProductCardComponent', () => {
  let fixture: ComponentFixture<ProductCardComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ProductCardComponent],
      imports: [CommonModule]  // For currency pipe
    }).compileComponents();

    fixture = TestBed.createComponent(ProductCardComponent);
  });

  it('should display product name and price', () => {
    fixture.componentInstance.product = {
      name: 'Widget',
      price: 29.99,
      inStock: true
    };
    fixture.detectChanges();

    const el = fixture.nativeElement;
    expect(el.querySelector('h2').textContent).toBe('Widget');
    expect(el.querySelector('p').textContent).toContain('$29.99');
  });

  it('should disable cart button when out of stock', () => {
    fixture.componentInstance.product = { name: 'Widget', price: 10, inStock: false };
    fixture.detectChanges();

    const button = fixture.nativeElement.querySelector('button');
    expect(button.disabled).toBe(true);
    expect(button.textContent.trim()).toBe('Out of Stock');
  });

  it('should emit cartAdd event when button clicked', () => {
    fixture.componentInstance.product = { name: 'Widget', price: 10, inStock: true };
    fixture.detectChanges();

    const cartAddSpy = jest.spyOn(fixture.componentInstance.cartAdd, 'emit');
    fixture.nativeElement.querySelector('button').click();

    expect(cartAddSpy).toHaveBeenCalledTimes(1);
  });
});

Testing Angular Services

Pure Service (No HTTP)

// cart.service.ts
@Injectable({ providedIn: 'root' })
export class CartService {
  private items: Product[] = [];

  addItem(product: Product) {
    this.items.push(product);
  }

  removeItem(id: string) {
    this.items = this.items.filter(item => item.id !== id);
  }

  getTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }

  getItems(): Product[] {
    return [...this.items];
  }
}
// cart.service.spec.ts
describe('CartService', () => {
  let service: CartService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(CartService);
  });

  it('should start with empty cart', () => {
    expect(service.getItems()).toEqual([]);
    expect(service.getTotal()).toBe(0);
  });

  it('should add items and calculate total', () => {
    service.addItem({ id: '1', name: 'Widget', price: 10 });
    service.addItem({ id: '2', name: 'Gadget', price: 25.50 });

    expect(service.getItems()).toHaveLength(2);
    expect(service.getTotal()).toBe(35.50);
  });

  it('should remove items by id', () => {
    service.addItem({ id: '1', name: 'Widget', price: 10 });
    service.addItem({ id: '2', name: 'Gadget', price: 25 });

    service.removeItem('1');

    expect(service.getItems()).toHaveLength(1);
    expect(service.getItems()[0].id).toBe('2');
  });
});

HTTP Service with HttpClientTestingModule

// user.service.ts
@Injectable({ providedIn: 'root' })
export class UserService {
  constructor(private http: HttpClient) {}

  getUsers(): Observable<User[]> {
    return this.http.get<User[]>('/api/users');
  }

  createUser(data: CreateUserDTO): Observable<User> {
    return this.http.post<User>('/api/users', data);
  }
}
// user.service.spec.ts
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

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

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [UserService]
    });

    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    // Verify no unexpected requests were made
    httpMock.verify();
  });

  it('should fetch users', () => {
    const mockUsers: User[] = [
      { id: 1, name: 'Alice', email: 'alice@example.com' },
      { id: 2, name: 'Bob', email: 'bob@example.com' }
    ];

    let result: User[] = [];
    service.getUsers().subscribe(users => result = users);

    // Expect and flush the HTTP request
    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('GET');
    req.flush(mockUsers);

    expect(result).toEqual(mockUsers);
    expect(result).toHaveLength(2);
  });

  it('should handle HTTP errors', () => {
    let errorMessage = '';
    service.getUsers().pipe(
      catchError(err => {
        errorMessage = err.message;
        return EMPTY;
      })
    ).subscribe();

    const req = httpMock.expectOne('/api/users');
    req.flush('Server error', { status: 500, statusText: 'Internal Server Error' });

    expect(errorMessage).toContain('500');
  });
});

Testing Angular Forms

Reactive Forms

// login.component.ts
@Component({
  selector: 'app-login',
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <input formControlName="email" type="email" />
      <div *ngIf="form.get('email')?.hasError('required') && form.get('email')?.touched">
        Email is required
      </div>
      <input formControlName="password" type="password" />
      <button type="submit" [disabled]="form.invalid">Login</button>
    </form>
  `
})
export class LoginComponent {
  form = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]]
  });

  constructor(private fb: FormBuilder) {}

  onSubmit() {
    if (this.form.valid) {
      // Submit logic
    }
  }
}
// login.component.spec.ts
describe('LoginComponent', () => {
  let fixture: ComponentFixture<LoginComponent>;
  let component: LoginComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [LoginComponent],
      imports: [ReactiveFormsModule]
    }).compileComponents();

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

  it('should disable submit button when form is invalid', () => {
    const button = fixture.nativeElement.querySelector('button[type="submit"]');
    expect(button.disabled).toBe(true);
  });

  it('should enable submit button with valid inputs', () => {
    component.form.setValue({
      email: 'test@example.com',
      password: 'password123'
    });
    fixture.detectChanges();

    const button = fixture.nativeElement.querySelector('button[type="submit"]');
    expect(button.disabled).toBe(false);
  });

  it('should show error when email is touched and empty', () => {
    component.form.get('email')?.markAsTouched();
    fixture.detectChanges();

    const errorEl = fixture.nativeElement.querySelector('div');
    expect(errorEl.textContent).toContain('Email is required');
  });

  it('should validate email format', () => {
    component.form.get('email')?.setValue('not-an-email');
    expect(component.form.get('email')?.hasError('email')).toBe(true);

    component.form.get('email')?.setValue('valid@example.com');
    expect(component.form.get('email')?.hasError('email')).toBe(false);
  });
});

E2E Testing for Angular

Install Playwright alongside your Angular project:

npm init playwright@latest
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Login flow', () => {
  test('successful login redirects to dashboard', async ({ page }) => {
    await page.goto('/login');

    await page.fill('[name="email"]', 'test@example.com');
    await page.fill('[name="password"]', 'Password123!');
    await page.click('[type="submit"]');

    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.locator('h1')).toContainText('Welcome');
  });

  test('invalid credentials shows error message', async ({ page }) => {
    await page.goto('/login');

    await page.fill('[name="email"]', 'test@example.com');
    await page.fill('[name="password"]', 'wrongpassword');
    await page.click('[type="submit"]');

    await expect(page.locator('.error-message')).toContainText('Invalid credentials');
    await expect(page).toHaveURL(/\/login/);
  });

  test('empty form shows validation errors', async ({ page }) => {
    await page.goto('/login');

    await page.click('[type="submit"]');
    fixture.detectChanges();

    await expect(page.locator('[data-error="email"]')).toBeVisible();
    await expect(page.locator('[data-error="password"]')).toBeVisible();
  });
});

Cypress (Alternative)

// cypress/e2e/login.cy.ts
describe('Login', () => {
  it('logs in successfully', () => {
    cy.visit('/login')
    cy.get('[name="email"]').type('test@example.com')
    cy.get('[name="password"]').type('Password123!')
    cy.get('[type="submit"]').click()
    cy.url().should('include', '/dashboard')
    cy.get('h1').should('contain', 'Welcome')
  })
})

Angular Playwright Integration (Angular Schematics)

# Add Playwright via Angular schematics
ng add playwright-ng-schematics

<span class="hljs-comment"># Run E2E tests
ng e2e

Running Angular Tests in CI/CD

GitHub Actions

name: Angular Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Run unit tests
        run: npm test -- --no-watch --no-progress --browsers=ChromeHeadless
        # If using Jest:
        # run: npm test -- --ci --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage/lcov.info

  e2e-tests:
    runs-on: ubuntu-latest
    needs: unit-tests  # Only run E2E if unit tests pass
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - run: npm ci
      - run: npx playwright install chromium --with-deps

      - name: Build Angular app
        run: npm run build

      - name: Start server and run E2E tests
        run: |
          npx serve -s dist/my-app -p 4200 &
          sleep 5
          npx playwright test

      - name: Upload E2E artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/

Angular Testing Best Practices

1. Follow the Testing Pyramid

  • Unit tests (most): Pure functions, services with no dependencies
  • Component tests (medium): Components with mock services, form validation
  • Integration tests (fewer): Multiple components interacting, real services
  • E2E tests (fewest): Critical user journeys only

2. Use TestBed Minimally

Only configure what the test needs:

// ❌ Over-configured — slows down tests
await TestBed.configureTestingModule({
  imports: [AppModule]  // Imports everything
}).compileComponents();

// ✅ Minimal setup — faster tests
await TestBed.configureTestingModule({
  declarations: [MyComponent],
  providers: [{ provide: MyService, useValue: mockService }]
}).compileComponents();

3. Mock Services, Not HTTP

Mock at the service level, not the HTTP level, for component tests:

const mockUserService = {
  getUser: jest.fn().mockReturnValue(of({ id: 1, name: 'Test' })),
  updateUser: jest.fn().mockReturnValue(of({ success: true }))
};

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

4. Test Behavior, Not Implementation

// ❌ Tests implementation — brittle
expect(component.isLoading).toBe(true);

// ✅ Tests behavior — what the user sees
expect(fixture.nativeElement.querySelector('.spinner')).toBeTruthy();

5. Use data-testid Attributes for Selectors

<!-- ✅ Stable selector — doesn't break when styling changes -->
<button data-testid="submit-btn">Submit</button>

<!-- ❌ Brittle selector — breaks when class changes -->
<button class="btn btn-primary">Submit</button>

Modern Angular Testing with HelpMeTest

For E2E testing Angular apps in production monitoring, HelpMeTest provides AI-powered test generation and self-healing:

*** Test Cases ***
Angular Dashboard Loads
    Go To    https://myangularapp.com/dashboard
    Verify   Data table is visible
    Verify   No error messages displayed
    Check For Visual Flaws

Angular Form Submission
    Go To    https://myangularapp.com/contact
    Fill In  name field    Test User
    Fill In  email field    test@example.com
    Fill In  message field    This is a test message
    Click    Submit button
    Verify   Success message is visible

HelpMeTest handles Angular's client-side routing, dynamic content loading, and HTTP requests automatically — no manual waitForElement calls needed.

Benefits over pure Playwright for E2E:

  • Self-healing: tests auto-update when Angular component templates change
  • AI generation: describe the flow in English, get Robot Framework test code
  • Built-in monitoring: runs tests continuously against production
  • $100/month flat — no per-seat pricing

Try HelpMeTest free — 10 tests included.


Angular Testing Quick Reference

What to test How to test Example
Pure function Direct call, no TestBed expect(formatDate('2026-01-01')).toBe('Jan 1, 2026')
Service logic TestBed.inject const svc = TestBed.inject(CartService)
HTTP service HttpClientTestingModule httpMock.expectOne('/api/users').flush(data)
Component render ComponentFixture fixture.nativeElement.querySelector('h1')
Component input Set componentInstance property component.product = mockProduct
User interaction Click nativeElement button.click(); fixture.detectChanges()
Output emission jest.spyOn jest.spyOn(component.added, 'emit')
Form validation Set control value form.get('email')?.setValue('bad')
E2E browser Playwright page.fill('[name="email"]', value)
Production monitoring HelpMeTest Verify URL contains /dashboard

Angular's testing tooling is mature and comprehensive. Start with Jest for unit/component tests, Playwright for E2E, and HelpMeTest for production monitoring and AI-assisted test generation.

Read more