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
Migrating to Jest (Recommended)
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
Playwright (Recommended)
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.