# Angular Testing Strategies Comprehensive guide to testing strategies, patterns, and best practices for Angular applications. ## Table of Contents 1. [Testing Pyramid](#testing-pyramid) 2. [Unit Testing Strategies](#unit-testing-strategies) 3. [Integration Testing](#integration-testing) 4. [E2E Testing](#e2e-testing) 5. [Test Organization](#test-organization) 6. [AAA Pattern](#aaa-pattern) 7. [Test Doubles](#test-doubles) 8. [Common Anti-Patterns](#common-anti-patterns) 9. [Performance Optimization](#performance-optimization) 10. [CI/CD Integration](#cicd-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: ```typescript describe('UserProfileComponent (Shallow)', () => { let component: UserProfileComponent; let userService: jasmine.SpyObj; 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: ```typescript describe('UserProfileComponent (Deep)', () => { let component: UserProfileComponent; let fixture: ComponentFixture; let userService: jasmine.SpyObj; 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; 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) ```typescript 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 ```typescript 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 ```typescript describe('AuthService', () => { let service: AuthService; let http: jasmine.SpyObj; let router: jasmine.SpyObj; 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; router = TestBed.inject(Router) as jasmine.SpyObj; }); 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 ```typescript 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 ```typescript describe('HighlightDirective', () => { let fixture: ComponentFixture; let element: DebugElement; @Component({ template: '
Test
' }) 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 ```typescript describe('ProductListComponent Integration', () => { let component: ProductListComponent; let fixture: ComponentFixture; 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 ```typescript describe('AuthGuard Integration', () => { let guard: AuthGuard; let authService: jasmine.SpyObj; 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; 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 ```typescript // 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 ```typescript // 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 ```typescript // src/app/testing/helpers/test-utils.ts /** * Create a mock Observable that emits immediately */ export function createMockObservable(data: T): Observable { return of(data); } /** * Create a mock Observable that throws an error */ export function createMockError(error: any): Observable { return throwError(() => error); } /** * Click a button and wait for async operations */ export async function clickButton( fixture: ComponentFixture, selector: string ): Promise { 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( baseName: string, methodNames: (keyof T)[] ): jasmine.SpyObj { return jasmine.createSpyObj(baseName, methodNames as string[]); } ``` ### Test Fixtures ```typescript // 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 { return { ...UserFixtures.VALID_USER, ...overrides }; } } ``` --- ## AAA Pattern ### Arrange-Act-Assert Structure ```typescript 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 ```typescript // 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 1. **Dummy**: Passed but never used 2. **Stub**: Provides preset responses 3. **Spy**: Records how it was called 4. **Mock**: Pre-programmed with expectations 5. **Fake**: Working implementation (simplified) ### Jasmine Spies ```typescript // 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 ```typescript 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```json // 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 ```typescript // 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; 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 ```typescript // 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 ```yaml 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 ```json { "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 ```typescript 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 ```typescript // 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! 🛡️*