26 KiB
26 KiB
Angular Testing Strategies
Comprehensive guide to testing strategies, patterns, and best practices for Angular applications.
Table of Contents
- Testing Pyramid
- Unit Testing Strategies
- Integration Testing
- E2E Testing
- Test Organization
- AAA Pattern
- Test Doubles
- Common Anti-Patterns
- Performance Optimization
- CI/CD Integration
Testing Pyramid
The ideal test distribution for Angular applications:
╱╲
╱ E2E ╲ (5% - Critical user journeys)
╱────────╲
╱Integration╲ (15% - Component + Service)
╱──────────────╲
╱ Unit Tests ╲ (80% - Pure logic, services)
╱──────────────────╲
Why This Distribution?
- Unit Tests (80%): Fast, isolated, easy to maintain
- Integration Tests (15%): Test component interactions
- E2E Tests (5%): Slow but verify complete user flows
Cost vs Coverage
| Test Type | Speed | Cost | Confidence | Maintenance |
|---|---|---|---|---|
| Unit | ⚡⚡⚡ | 💰 | ⭐⭐ | ✅ Easy |
| Integration | ⚡⚡ | 💰💰 | ⭐⭐⭐ | ⚠️ Medium |
| E2E | ⚡ | 💰💰💰 | ⭐⭐⭐⭐ | ❌ Hard |
Unit Testing Strategies
What to Unit Test
✅ DO Test:
- Pure functions and business logic
- Service methods
- Component methods (isolated)
- Pipes and custom validators
- Utility functions
- State management (reducers, selectors)
❌ DON'T Test:
- Angular framework internals
- Third-party libraries
- Simple getters/setters
- Generated code
Component Testing Approaches
1. Shallow Testing (Isolated)
Test component logic without rendering:
describe('UserProfileComponent (Shallow)', () => {
let component: UserProfileComponent;
let userService: jasmine.SpyObj<UserService>;
beforeEach(() => {
userService = jasmine.createSpyObj('UserService', ['getUser', 'updateUser']);
component = new UserProfileComponent(userService);
});
it('should call updateUser with correct data', () => {
const userData = { name: 'John', email: 'john@example.com' };
userService.updateUser.and.returnValue(of({ success: true }));
component.saveProfile(userData);
expect(userService.updateUser).toHaveBeenCalledWith(userData);
});
});
Pros: Fast, focused on logic
Cons: Doesn't test template integration
Use for: Complex business logic, services
2. Deep Testing (TestBed)
Test component with template and dependencies:
describe('UserProfileComponent (Deep)', () => {
let component: UserProfileComponent;
let fixture: ComponentFixture<UserProfileComponent>;
let userService: jasmine.SpyObj<UserService>;
beforeEach(async () => {
const spy = jasmine.createSpyObj('UserService', ['getUser', 'updateUser']);
await TestBed.configureTestingModule({
declarations: [ UserProfileComponent ],
imports: [ ReactiveFormsModule, HttpClientTestingModule ],
providers: [
{ provide: UserService, useValue: spy }
]
}).compileComponents();
userService = TestBed.inject(UserService) as jasmine.SpyObj<UserService>;
fixture = TestBed.createComponent(UserProfileComponent);
component = fixture.componentInstance;
});
it('should display user name in template', () => {
const userData = { name: 'John Doe', email: 'john@example.com' };
userService.getUser.and.returnValue(of(userData));
fixture.detectChanges(); // Trigger change detection
const compiled = fixture.nativeElement;
const nameElement = compiled.querySelector('.user-name');
expect(nameElement.textContent).toContain('John Doe');
});
});
Pros: Tests template + logic integration
Cons: Slower, more complex setup
Use for: UI interactions, template binding
Service Testing Patterns
Simple Service (No HTTP)
describe('CalculatorService', () => {
let service: CalculatorService;
beforeEach(() => {
service = new CalculatorService();
});
it('should add two numbers', () => {
expect(service.add(2, 3)).toBe(5);
});
it('should throw error for division by zero', () => {
expect(() => service.divide(10, 0)).toThrow();
});
});
HTTP Service Testing
describe('UserApiService', () => {
let service: UserApiService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [UserApiService]
});
service = TestBed.inject(UserApiService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Ensure no outstanding requests
});
it('should fetch users', () => {
const mockUsers = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
];
service.getUsers().subscribe(users => {
expect(users).toEqual(mockUsers);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush(mockUsers);
});
it('should handle HTTP errors', () => {
service.getUsers().subscribe({
next: () => fail('should have failed'),
error: (error) => {
expect(error.status).toBe(500);
expect(error.statusText).toBe('Server Error');
}
});
const req = httpMock.expectOne('/api/users');
req.flush('Error', { status: 500, statusText: 'Server Error' });
});
});
Service with Dependencies
describe('AuthService', () => {
let service: AuthService;
let http: jasmine.SpyObj<HttpClient>;
let router: jasmine.SpyObj<Router>;
beforeEach(() => {
const httpSpy = jasmine.createSpyObj('HttpClient', ['post', 'get']);
const routerSpy = jasmine.createSpyObj('Router', ['navigate']);
TestBed.configureTestingModule({
providers: [
AuthService,
{ provide: HttpClient, useValue: httpSpy },
{ provide: Router, useValue: routerSpy }
]
});
service = TestBed.inject(AuthService);
http = TestBed.inject(HttpClient) as jasmine.SpyObj<HttpClient>;
router = TestBed.inject(Router) as jasmine.SpyObj<Router>;
});
it('should login and navigate to dashboard', (done) => {
const mockResponse = { token: 'fake-token' };
http.post.and.returnValue(of(mockResponse));
service.login('test@example.com', 'password').subscribe(() => {
expect(http.post).toHaveBeenCalledWith(
'/api/auth/login',
{ email: 'test@example.com', password: 'password' }
);
expect(router.navigate).toHaveBeenCalledWith(['/dashboard']);
done();
});
});
});
Pipe Testing
describe('FilterPipe', () => {
let pipe: FilterPipe;
beforeEach(() => {
pipe = new FilterPipe();
});
it('should filter array by search term', () => {
const items = [
{ name: 'Apple' },
{ name: 'Banana' },
{ name: 'Orange' }
];
const result = pipe.transform(items, 'app', 'name');
expect(result.length).toBe(1);
expect(result[0].name).toBe('Apple');
});
it('should return empty array when no matches', () => {
const items = [{ name: 'Apple' }];
const result = pipe.transform(items, 'xyz', 'name');
expect(result.length).toBe(0);
});
it('should return original array when search is empty', () => {
const items = [{ name: 'Apple' }, { name: 'Banana' }];
const result = pipe.transform(items, '', 'name');
expect(result).toEqual(items);
});
});
Directive Testing
describe('HighlightDirective', () => {
let fixture: ComponentFixture<TestComponent>;
let element: DebugElement;
@Component({
template: '<div appHighlight="yellow">Test</div>'
})
class TestComponent {}
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ HighlightDirective, TestComponent ]
}).compileComponents();
fixture = TestBed.createComponent(TestComponent);
element = fixture.debugElement.query(By.directive(HighlightDirective));
fixture.detectChanges();
});
it('should apply background color', () => {
const bgColor = element.nativeElement.style.backgroundColor;
expect(bgColor).toBe('yellow');
});
it('should change color on hover', () => {
element.nativeElement.dispatchEvent(new MouseEvent('mouseenter'));
fixture.detectChanges();
expect(element.nativeElement.style.backgroundColor).toBe('orange');
});
});
Integration Testing
Component + Service Integration
describe('ProductListComponent Integration', () => {
let component: ProductListComponent;
let fixture: ComponentFixture<ProductListComponent>;
let productService: ProductService;
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ProductListComponent ],
imports: [ HttpClientTestingModule, CommonModule ],
providers: [ ProductService ]
}).compileComponents();
fixture = TestBed.createComponent(ProductListComponent);
component = fixture.componentInstance;
productService = TestBed.inject(ProductService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should load and display products', () => {
const mockProducts = [
{ id: 1, name: 'Product 1', price: 100 },
{ id: 2, name: 'Product 2', price: 200 }
];
fixture.detectChanges(); // Trigger ngOnInit
const req = httpMock.expectOne('/api/products');
req.flush(mockProducts);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const productElements = compiled.querySelectorAll('.product-item');
expect(productElements.length).toBe(2);
expect(productElements[0].textContent).toContain('Product 1');
});
it('should handle loading state', () => {
expect(component.isLoading).toBeFalsy();
fixture.detectChanges(); // Trigger ngOnInit
expect(component.isLoading).toBeTruthy();
const req = httpMock.expectOne('/api/products');
req.flush([]);
expect(component.isLoading).toBeFalsy();
});
});
Router + Guard Integration
describe('AuthGuard Integration', () => {
let guard: AuthGuard;
let authService: jasmine.SpyObj<AuthService>;
let router: Router;
let location: Location;
beforeEach(async () => {
const authServiceSpy = jasmine.createSpyObj('AuthService', ['isAuthenticated']);
await TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes([
{ path: 'dashboard', component: DummyComponent, canActivate: [AuthGuard] },
{ path: 'login', component: DummyComponent }
])],
providers: [
AuthGuard,
{ provide: AuthService, useValue: authServiceSpy }
]
}).compileComponents();
guard = TestBed.inject(AuthGuard);
authService = TestBed.inject(AuthService) as jasmine.SpyObj<AuthService>;
router = TestBed.inject(Router);
location = TestBed.inject(Location);
});
it('should allow navigation when authenticated', fakeAsync(() => {
authService.isAuthenticated.and.returnValue(true);
router.navigate(['/dashboard']);
tick();
expect(location.path()).toBe('/dashboard');
}));
it('should redirect to login when not authenticated', fakeAsync(() => {
authService.isAuthenticated.and.returnValue(false);
router.navigate(['/dashboard']);
tick();
expect(location.path()).toBe('/login');
}));
});
E2E Testing
Cypress E2E Strategy
// cypress/e2e/user-journey.cy.ts
describe('Complete User Journey', () => {
beforeEach(() => {
// Reset database state
cy.task('db:seed');
cy.visit('/');
});
it('should complete purchase flow', () => {
// Login
cy.getBySel('login-button').click();
cy.getBySel('email-input').type('test@example.com');
cy.getBySel('password-input').type('password123');
cy.getBySel('submit-button').click();
// Browse products
cy.url().should('include', '/products');
cy.getBySel('product-card').first().click();
// Add to cart
cy.getBySel('add-to-cart-button').click();
cy.getBySel('cart-badge').should('contain', '1');
// Checkout
cy.getBySel('cart-icon').click();
cy.getBySel('checkout-button').click();
// Fill shipping info
cy.getBySel('address-input').type('123 Main St');
cy.getBySel('city-input').type('New York');
cy.getBySel('zipcode-input').type('10001');
// Complete purchase
cy.getBySel('complete-order-button').click();
cy.url().should('include', '/order-confirmation');
cy.getBySel('success-message').should('be.visible');
});
});
Playwright E2E Strategy
// e2e/user-journey.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Complete User Journey', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should complete purchase flow', async ({ page }) => {
// Login
await page.click('[data-testid="login-button"]');
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="submit-button"]');
// Wait for navigation
await page.waitForURL('**/products');
// Browse products
await page.click('[data-testid="product-card"] >> nth=0');
// Add to cart
await page.click('[data-testid="add-to-cart-button"]');
await expect(page.locator('[data-testid="cart-badge"]')).toHaveText('1');
// Checkout
await page.click('[data-testid="cart-icon"]');
await page.click('[data-testid="checkout-button"]');
// Fill shipping info
await page.fill('[data-testid="address-input"]', '123 Main St');
await page.fill('[data-testid="city-input"]', 'New York');
await page.fill('[data-testid="zipcode-input"]', '10001');
// Complete purchase
await page.click('[data-testid="complete-order-button"]');
await expect(page).toHaveURL(/.*order-confirmation/);
await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
});
});
Test Organization
File Structure
src/app/
├── components/
│ ├── user-profile/
│ │ ├── user-profile.component.ts
│ │ ├── user-profile.component.spec.ts
│ │ ├── user-profile.component.html
│ │ └── user-profile.component.scss
│ └── shared/
│ └── button/
│ ├── button.component.ts
│ └── button.component.spec.ts
├── services/
│ ├── auth.service.ts
│ ├── auth.service.spec.ts
│ ├── user.service.ts
│ └── user.service.spec.ts
├── guards/
│ ├── auth.guard.ts
│ └── auth.guard.spec.ts
├── pipes/
│ ├── filter.pipe.ts
│ └── filter.pipe.spec.ts
└── testing/
├── mocks/
│ ├── mock-auth.service.ts
│ └── mock-user.service.ts
├── fixtures/
│ ├── user.fixture.ts
│ └── product.fixture.ts
└── helpers/
├── test-utils.ts
└── custom-matchers.ts
Test Helper Functions
// src/app/testing/helpers/test-utils.ts
/**
* Create a mock Observable that emits immediately
*/
export function createMockObservable<T>(data: T): Observable<T> {
return of(data);
}
/**
* Create a mock Observable that throws an error
*/
export function createMockError(error: any): Observable<never> {
return throwError(() => error);
}
/**
* Click a button and wait for async operations
*/
export async function clickButton(
fixture: ComponentFixture<any>,
selector: string
): Promise<void> {
const button = fixture.nativeElement.querySelector(selector);
button.click();
fixture.detectChanges();
await fixture.whenStable();
}
/**
* Set form values
*/
export function setFormValues(
form: FormGroup,
values: { [key: string]: any }
): void {
Object.keys(values).forEach(key => {
const control = form.get(key);
if (control) {
control.setValue(values[key]);
control.markAsTouched();
}
});
}
/**
* Create spy object with typed methods
*/
export function createSpyObj<T>(
baseName: string,
methodNames: (keyof T)[]
): jasmine.SpyObj<T> {
return jasmine.createSpyObj(baseName, methodNames as string[]);
}
Test Fixtures
// src/app/testing/fixtures/user.fixture.ts
export class UserFixtures {
static readonly VALID_USER = {
id: 1,
email: 'test@example.com',
name: 'Test User',
role: 'user'
};
static readonly ADMIN_USER = {
id: 2,
email: 'admin@example.com',
name: 'Admin User',
role: 'admin'
};
static readonly USER_LIST = [
UserFixtures.VALID_USER,
UserFixtures.ADMIN_USER,
{
id: 3,
email: 'user3@example.com',
name: 'User Three',
role: 'user'
}
];
static createUser(overrides: Partial<User> = {}): User {
return {
...UserFixtures.VALID_USER,
...overrides
};
}
}
AAA Pattern
Arrange-Act-Assert Structure
describe('ShoppingCartService', () => {
it('should add item to cart', () => {
// ===== ARRANGE =====
// Set up test data and dependencies
const service = new ShoppingCartService();
const product = {
id: 1,
name: 'Test Product',
price: 99.99
};
// ===== ACT =====
// Execute the behavior being tested
service.addToCart(product);
// ===== ASSERT =====
// Verify the expected outcome
expect(service.getCartItems().length).toBe(1);
expect(service.getCartItems()[0]).toEqual(product);
expect(service.getTotal()).toBe(99.99);
});
});
Why AAA Pattern?
✅ Readability: Clear test structure
✅ Maintainability: Easy to update
✅ Debugging: Quickly identify failures
✅ Consistency: Uniform test style
Common Variations
// Given-When-Then (BDD style)
it('should add item to cart', () => {
// GIVEN: A shopping cart and a product
const service = new ShoppingCartService();
const product = { id: 1, name: 'Product', price: 99.99 };
// WHEN: Adding the product to cart
service.addToCart(product);
// THEN: Cart should contain the product
expect(service.getCartItems()).toContain(product);
});
Test Doubles
Types of Test Doubles
- Dummy: Passed but never used
- Stub: Provides preset responses
- Spy: Records how it was called
- Mock: Pre-programmed with expectations
- Fake: Working implementation (simplified)
Jasmine Spies
// Creating spies
const spy = jasmine.createSpy('myFunction');
const spyObj = jasmine.createSpyObj('MyService', ['method1', 'method2']);
// Spy on existing method
spyOn(service, 'getData').and.returnValue(of(mockData));
// Spy configurations
spy.and.returnValue(42);
spy.and.returnValues(1, 2, 3);
spy.and.callFake(() => 'custom logic');
spy.and.callThrough(); // Call actual method
spy.and.throwError('error message');
// Assertions
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledWith('arg1', 'arg2');
expect(spy).toHaveBeenCalledBefore(anotherSpy);
Mock HTTP Responses
it('should handle successful API call', () => {
service.getUsers().subscribe(users => {
expect(users.length).toBe(2);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush([
{ id: 1, name: 'User 1' },
{ id: 2, name: 'User 2' }
]);
});
it('should handle API errors', () => {
service.getUsers().subscribe({
error: (error) => {
expect(error.status).toBe(404);
}
});
const req = httpMock.expectOne('/api/users');
req.flush('Not Found', { status: 404, statusText: 'Not Found' });
});
Common Anti-Patterns
❌ Testing Implementation Details
// BAD: Testing private methods
it('should call private method', () => {
spyOn(component as any, 'privateMethod');
component.publicMethod();
expect((component as any).privateMethod).toHaveBeenCalled();
});
// GOOD: Test public behavior
it('should update user list', () => {
component.refreshUsers();
expect(component.users.length).toBeGreaterThan(0);
});
❌ Flaky Tests
// BAD: Time-dependent tests
it('should update after delay', (done) => {
service.delayedUpdate();
setTimeout(() => {
expect(service.value).toBe(42);
done();
}, 100); // Magic number!
});
// GOOD: Use fakeAsync
it('should update after delay', fakeAsync(() => {
service.delayedUpdate();
tick(1000);
expect(service.value).toBe(42);
}));
❌ Test Interdependence
// BAD: Tests depend on each other
describe('UserService', () => {
let userId: number;
it('should create user', () => {
userId = service.createUser({ name: 'Test' });
expect(userId).toBeDefined();
});
it('should get user', () => {
const user = service.getUser(userId); // Depends on previous test!
expect(user.name).toBe('Test');
});
});
// GOOD: Independent tests
describe('UserService', () => {
beforeEach(() => {
// Reset state before each test
service.clear();
});
it('should create user', () => {
const userId = service.createUser({ name: 'Test' });
expect(userId).toBeDefined();
});
it('should get user', () => {
const userId = service.createUser({ name: 'Test' });
const user = service.getUser(userId);
expect(user.name).toBe('Test');
});
});
❌ Over-Mocking
// BAD: Mocking everything
it('should calculate total', () => {
spyOn(Math, 'round').and.returnValue(100);
spyOn(Math, 'floor').and.returnValue(99);
// Too many mocks!
});
// GOOD: Only mock external dependencies
it('should calculate total', () => {
const cart = new ShoppingCart();
cart.addItem({ price: 10, quantity: 3 });
expect(cart.getTotal()).toBe(30);
});
Performance Optimization
Run Tests in Parallel
// karma.conf.js
module.exports = function(config) {
config.set({
browsers: ['ChromeHeadless'],
concurrency: 5, // Run 5 instances in parallel
browserNoActivityTimeout: 60000
});
};
Use Shallow Testing When Possible
// Fast: Shallow test
describe('Component (Shallow)', () => {
it('should calculate total', () => {
const component = new PriceCalculatorComponent();
component.price = 100;
component.quantity = 2;
expect(component.total).toBe(200);
});
});
// Slow: Full TestBed
describe('Component (Deep)', () => {
let fixture: ComponentFixture<PriceCalculatorComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ PriceCalculatorComponent ],
imports: [ CommonModule, FormsModule ]
}).compileComponents();
fixture = TestBed.createComponent(PriceCalculatorComponent);
});
it('should calculate total', () => {
// Much slower setup
});
});
Skip Heavy Tests in Watch Mode
// Use fdescribe/fit for focused testing during development
fdescribe('UserComponent', () => {
fit('should create', () => {
expect(component).toBeTruthy();
});
});
// Or use xdescribe/xit to skip tests
xdescribe('SlowE2ETests', () => {
// These tests won't run
});
CI/CD Integration
GitHub Actions
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:ci
- name: Generate coverage
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
- name: Run E2E tests
run: npm run e2e:ci
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: |
coverage/
cypress/screenshots/
cypress/videos/
Package.json Scripts
{
"scripts": {
"test": "ng test",
"test:ci": "ng test --watch=false --browsers=ChromeHeadless --code-coverage",
"test:coverage": "ng test --code-coverage --watch=false",
"test:watch": "ng test --watch=true",
"e2e": "cypress open",
"e2e:ci": "cypress run --browser chrome"
}
}
Best Practices Summary
✅ DO:
- Follow AAA pattern
- Test behavior, not implementation
- Keep tests isolated and independent
- Use descriptive test names
- Mock external dependencies
- Aim for 80% code coverage
- Run tests before committing
- Use CI/CD for automated testing
❌ DON'T:
- Test framework internals
- Create dependent tests
- Use magic numbers or strings
- Test private methods
- Ignore flaky tests
- Skip edge cases
- Over-mock everything
Quick Reference
Test Types
| Type | Purpose | Speed | Coverage |
|---|---|---|---|
| Unit | Test isolated logic | ⚡⚡⚡ | 80% |
| Integration | Test component + service | ⚡⚡ | 15% |
| E2E | Test user workflows | ⚡ | 5% |
Common Matchers
expect(value).toBe(42);
expect(value).toEqual({ id: 1 });
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(array).toContain(item);
expect(fn).toThrow();
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(arg);
Async Testing
// Using done()
it('async test', (done) => {
service.getData().subscribe(data => {
expect(data).toBeTruthy();
done();
});
});
// Using fakeAsync
it('async test', fakeAsync(() => {
service.delayedAction();
tick(1000);
expect(service.value).toBe(42);
}));
// Using async/await
it('async test', async () => {
const data = await service.getData().toPromise();
expect(data).toBeTruthy();
});
Master these strategies to build bulletproof Angular applications! 🛡️