Files
2025-11-29 18:25:05 +08:00

27 KiB

Angular Mocking Patterns

Comprehensive guide to mocking patterns, test doubles, spies, and HTTP testing in Angular applications.

Table of Contents

  1. Test Doubles Overview
  2. Jasmine Spies
  3. Service Mocking
  4. HTTP Mocking
  5. Observable Mocking
  6. Component Mocking
  7. Router & Location Mocking
  8. Form Mocking
  9. LocalStorage & SessionStorage
  10. Advanced Patterns

Test Doubles Overview

Types of Test Doubles

// 1. DUMMY - Passed but never used
class DummyLogger implements Logger {
  log(message: string): void {
    // Does nothing
  }
}

// 2. STUB - Returns predefined responses
class StubUserService {
  getUser(id: number): Observable<User> {
    return of({ id, name: 'Test User', email: 'test@example.com' });
  }
}

// 3. SPY - Records interactions
const spy = jasmine.createSpy('getData');
spy.and.returnValue('mock data');

// 4. MOCK - Pre-programmed with expectations
const mock = jasmine.createSpyObj('UserService', ['getUser', 'updateUser']);
mock.getUser.and.returnValue(of({ id: 1, name: 'John' }));

// 5. FAKE - Working simplified implementation
class FakeAuthService {
  private authenticated = false;

  login(): Observable<boolean> {
    this.authenticated = true;
    return of(true);
  }

  isAuthenticated(): boolean {
    return this.authenticated;
  }
}

When to Use Each

Type Use When Example
Dummy Need to pass parameters Logger that's never called
Stub Need consistent responses API returning test data
Spy Need to verify calls Track method invocations
Mock Need behavior verification Service with expectations
Fake Need working alternative In-memory database

Jasmine Spies

Creating Spies

// Method 1: Standalone spy
const spy = jasmine.createSpy('myFunction');

// Method 2: Spy on existing object
const service = new UserService();
spyOn(service, 'getData');

// Method 3: Spy object (multiple methods)
const spyObj = jasmine.createSpyObj('UserService', [
  'getUser',
  'updateUser',
  'deleteUser'
]);

// Method 4: Spy object with properties
const spyObjWithProps = jasmine.createSpyObj('UserService', 
  ['getUser'], // methods
  { currentUser: { id: 1, name: 'John' } } // properties
);

Spy Return Values

// Return single value
spy.and.returnValue('mock value');

// Return multiple values in sequence
spy.and.returnValues('first', 'second', 'third');

// Return Observable
spy.and.returnValue(of({ id: 1, name: 'John' }));

// Custom implementation
spy.and.callFake((arg) => {
  if (arg === 'admin') {
    return of({ role: 'admin' });
  }
  return of({ role: 'user' });
});

// Call original method
spy.and.callThrough();

// Throw error
spy.and.throwError('Something went wrong');

// Return promise
spy.and.resolveTo({ id: 1 }); // Promise.resolve
spy.and.rejectWith(new Error('Failed')); // Promise.reject

Spy Assertions

// Basic assertions
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).not.toHaveBeenCalled();

// Argument assertions
expect(spy).toHaveBeenCalledWith('arg1', 'arg2');
expect(spy).toHaveBeenCalledWith(jasmine.any(String));
expect(spy).toHaveBeenCalledWith(jasmine.objectContaining({ id: 1 }));

// Order assertions
expect(spy1).toHaveBeenCalledBefore(spy2);

// Most recent call
expect(spy).toHaveBeenCalledWith('last call args');

// Reset spy
spy.calls.reset();

Spy Properties

const spy = jasmine.createSpy('myFunction');
spy('arg1', 'arg2');

// Access call information
spy.calls.count();           // 1
spy.calls.argsFor(0);        // ['arg1', 'arg2']
spy.calls.allArgs();         // [['arg1', 'arg2']]
spy.calls.all();             // Full call details
spy.calls.mostRecent();      // Last call details
spy.calls.first();           // First call details

Service Mocking

Simple Service Mock

describe('UserComponent', () => {
  let component: UserComponent;
  let userService: jasmine.SpyObj<UserService>;

  beforeEach(() => {
    // Create spy object with all methods
    const spy = jasmine.createSpyObj('UserService', [
      'getUser',
      'getUsers',
      'updateUser',
      'deleteUser'
    ]);

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

    userService = TestBed.inject(UserService) as jasmine.SpyObj<UserService>;
    component = new UserComponent(userService);
  });

  it('should load user on init', () => {
    const mockUser = { id: 1, name: 'John Doe' };
    userService.getUser.and.returnValue(of(mockUser));

    component.ngOnInit();

    expect(userService.getUser).toHaveBeenCalledWith(1);
    expect(component.user).toEqual(mockUser);
  });
});

Service with Properties

describe('AuthComponent', () => {
  let authService: jasmine.SpyObj<AuthService>;

  beforeEach(() => {
    authService = jasmine.createSpyObj(
      'AuthService',
      ['login', 'logout'],
      {
        currentUser: of({ id: 1, name: 'John' }),
        isAuthenticated: true
      }
    );
  });

  it('should display current user', () => {
    expect(authService.isAuthenticated).toBe(true);
    
    authService.currentUser.subscribe(user => {
      expect(user.name).toBe('John');
    });
  });
});

Mocking Service Dependencies

describe('ProductService', () => {
  let service: ProductService;
  let http: jasmine.SpyObj<HttpClient>;
  let cache: jasmine.SpyObj<CacheService>;
  let logger: jasmine.SpyObj<LoggerService>;

  beforeEach(() => {
    const httpSpy = jasmine.createSpyObj('HttpClient', ['get', 'post', 'put', 'delete']);
    const cacheSpy = jasmine.createSpyObj('CacheService', ['get', 'set', 'clear']);
    const loggerSpy = jasmine.createSpyObj('LoggerService', ['log', 'error']);

    TestBed.configureTestingModule({
      providers: [
        ProductService,
        { provide: HttpClient, useValue: httpSpy },
        { provide: CacheService, useValue: cacheSpy },
        { provide: LoggerService, useValue: loggerSpy }
      ]
    });

    service = TestBed.inject(ProductService);
    http = TestBed.inject(HttpClient) as jasmine.SpyObj<HttpClient>;
    cache = TestBed.inject(CacheService) as jasmine.SpyObj<CacheService>;
    logger = TestBed.inject(LoggerService) as jasmine.SpyObj<LoggerService>;
  });

  it('should use cached data when available', () => {
    const cachedProduct = { id: 1, name: 'Cached Product' };
    cache.get.and.returnValue(cachedProduct);

    service.getProduct(1).subscribe(product => {
      expect(product).toEqual(cachedProduct);
      expect(http.get).not.toHaveBeenCalled();
      expect(logger.log).toHaveBeenCalledWith('Using cached product');
    });
  });
});

HTTP Mocking

HttpClientTestingModule Setup

import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

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

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

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

  afterEach(() => {
    httpMock.verify(); // Verify no outstanding requests
  });
});

GET Request Mocking

it('should fetch users', () => {
  const mockUsers = [
    { id: 1, name: 'John' },
    { id: 2, name: 'Jane' }
  ];

  service.getUsers().subscribe(users => {
    expect(users).toEqual(mockUsers);
    expect(users.length).toBe(2);
  });

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

it('should handle query parameters', () => {
  service.getUsers({ role: 'admin', active: true }).subscribe();

  const req = httpMock.expectOne(req => 
    req.url === '/api/users' &&
    req.params.get('role') === 'admin' &&
    req.params.get('active') === 'true'
  );
  req.flush([]);
});

POST/PUT/DELETE Mocking

it('should create user', () => {
  const newUser = { name: 'John', email: 'john@example.com' };
  const createdUser = { id: 1, ...newUser };

  service.createUser(newUser).subscribe(user => {
    expect(user).toEqual(createdUser);
  });

  const req = httpMock.expectOne('/api/users');
  expect(req.request.method).toBe('POST');
  expect(req.request.body).toEqual(newUser);
  req.flush(createdUser);
});

it('should update user', () => {
  const updatedUser = { id: 1, name: 'John Updated' };

  service.updateUser(1, updatedUser).subscribe();

  const req = httpMock.expectOne('/api/users/1');
  expect(req.request.method).toBe('PUT');
  req.flush(updatedUser);
});

it('should delete user', () => {
  service.deleteUser(1).subscribe();

  const req = httpMock.expectOne('/api/users/1');
  expect(req.request.method).toBe('DELETE');
  req.flush(null);
});

HTTP Headers Mocking

it('should send authorization header', () => {
  service.getProtectedData().subscribe();

  const req = httpMock.expectOne('/api/protected');
  expect(req.request.headers.get('Authorization')).toBe('Bearer fake-token');
  req.flush({ data: 'secret' });
});

it('should set custom headers', () => {
  service.uploadFile(new FormData()).subscribe();

  const req = httpMock.expectOne('/api/upload');
  expect(req.request.headers.get('Content-Type')).toContain('multipart/form-data');
  req.flush({ success: true });
});

HTTP Error Handling

it('should handle 404 error', () => {
  service.getUser(999).subscribe({
    next: () => fail('should have failed'),
    error: (error) => {
      expect(error.status).toBe(404);
      expect(error.statusText).toBe('Not Found');
    }
  });

  const req = httpMock.expectOne('/api/users/999');
  req.flush('User not found', { 
    status: 404, 
    statusText: 'Not Found' 
  });
});

it('should handle 500 error with message', () => {
  service.updateUser(1, {}).subscribe({
    error: (error) => {
      expect(error.status).toBe(500);
      expect(error.error.message).toBe('Server error');
    }
  });

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

it('should handle network error', () => {
  service.getUsers().subscribe({
    error: (error) => {
      expect(error.error.type).toBe('error');
    }
  });

  const req = httpMock.expectOne('/api/users');
  req.error(new ErrorEvent('Network error'));
});

Multiple HTTP Requests

it('should handle multiple simultaneous requests', () => {
  service.getUser(1).subscribe();
  service.getUser(2).subscribe();
  service.getUser(3).subscribe();

  const requests = httpMock.match(req => req.url.startsWith('/api/users/'));
  expect(requests.length).toBe(3);

  requests[0].flush({ id: 1, name: 'User 1' });
  requests[1].flush({ id: 2, name: 'User 2' });
  requests[2].flush({ id: 3, name: 'User 3' });
});

it('should handle sequential dependent requests', fakeAsync(() => {
  let userData: any;
  let postsData: any;

  // First request - get user
  service.getUser(1).subscribe(user => {
    userData = user;
  });

  let req = httpMock.expectOne('/api/users/1');
  req.flush({ id: 1, name: 'John' });
  tick();

  // Second request - get user's posts (depends on first)
  service.getUserPosts(userData.id).subscribe(posts => {
    postsData = posts;
  });

  req = httpMock.expectOne('/api/users/1/posts');
  req.flush([{ id: 1, title: 'Post 1' }]);
  tick();

  expect(userData).toBeDefined();
  expect(postsData.length).toBe(1);
}));

Observable Mocking

Basic Observable Mocking

import { of, throwError, EMPTY, NEVER } from 'rxjs';
import { delay } from 'rxjs/operators';

// Success response
service.getData.and.returnValue(of({ data: 'test' }));

// Error response
service.getData.and.returnValue(
  throwError(() => new Error('Failed'))
);

// Empty observable
service.getData.and.returnValue(EMPTY);

// Never completing observable
service.getData.and.returnValue(NEVER);

// Delayed response
service.getData.and.returnValue(
  of({ data: 'test' }).pipe(delay(1000))
);

Subject Mocking

import { Subject, BehaviorSubject, ReplaySubject } from 'rxjs';

describe('NotificationService', () => {
  let service: NotificationService;
  let notificationSubject: Subject<string>;

  beforeEach(() => {
    notificationSubject = new Subject<string>();
    
    const spy = jasmine.createSpyObj('NotificationService', 
      ['notify'],
      { notifications$: notificationSubject.asObservable() }
    );

    service = spy;
  });

  it('should receive notifications', (done) => {
    const messages: string[] = [];

    service.notifications$.subscribe(msg => {
      messages.push(msg);
      
      if (messages.length === 2) {
        expect(messages).toEqual(['Hello', 'World']);
        done();
      }
    });

    notificationSubject.next('Hello');
    notificationSubject.next('World');
  });
});

// BehaviorSubject with initial value
it('should have initial value', () => {
  const subject = new BehaviorSubject<number>(0);
  const spy = jasmine.createSpyObj('CounterService', 
    ['increment'],
    { count$: subject.asObservable() }
  );

  spy.count$.subscribe(count => {
    expect(count).toBe(0);
  });

  subject.next(1);
  
  spy.count$.subscribe(count => {
    expect(count).toBe(1);
  });
});

Async Observable Testing

it('should handle async data', fakeAsync(() => {
  let result: any;

  service.getData().pipe(
    delay(1000)
  ).subscribe(data => {
    result = data;
  });

  expect(result).toBeUndefined();

  tick(1000); // Advance time

  expect(result).toEqual({ data: 'test' });
}));

it('should handle debounced input', fakeAsync(() => {
  const spy = jasmine.createSpy('callback');

  component.searchInput.pipe(
    debounceTime(300)
  ).subscribe(spy);

  component.searchInput.next('a');
  tick(100);
  component.searchInput.next('ab');
  tick(100);
  component.searchInput.next('abc');
  tick(300);

  expect(spy).toHaveBeenCalledTimes(1);
  expect(spy).toHaveBeenCalledWith('abc');
}));

Component Mocking

Mock Child Components

// Create mock component
@Component({
  selector: 'app-child',
  template: '<div>Mock Child</div>'
})
class MockChildComponent {
  @Input() data: any;
  @Output() action = new EventEmitter();
}

describe('ParentComponent', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [
        ParentComponent,
        MockChildComponent // Use mock instead of real
      ]
    }).compileComponents();
  });

  it('should pass data to child', () => {
    const fixture = TestBed.createComponent(ParentComponent);
    const component = fixture.componentInstance;
    const childDebugElement = fixture.debugElement.query(
      By.directive(MockChildComponent)
    );
    const childComponent = childDebugElement.componentInstance as MockChildComponent;

    component.parentData = { value: 'test' };
    fixture.detectChanges();

    expect(childComponent.data).toEqual({ value: 'test' });
  });

  it('should handle child events', () => {
    const fixture = TestBed.createComponent(ParentComponent);
    const component = fixture.componentInstance;
    const childDebugElement = fixture.debugElement.query(
      By.directive(MockChildComponent)
    );
    const childComponent = childDebugElement.componentInstance as MockChildComponent;

    spyOn(component, 'handleAction');

    childComponent.action.emit('test-data');

    expect(component.handleAction).toHaveBeenCalledWith('test-data');
  });
});

NO_ERRORS_SCHEMA

// Alternative: Use NO_ERRORS_SCHEMA to ignore child components
import { NO_ERRORS_SCHEMA } from '@angular/core';

describe('ParentComponent', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ParentComponent],
      schemas: [NO_ERRORS_SCHEMA] // Ignore unknown elements
    }).compileComponents();
  });

  it('should work without child component implementation', () => {
    const fixture = TestBed.createComponent(ParentComponent);
    expect(fixture.componentInstance).toBeTruthy();
  });
});

Router & Location Mocking

Router Mocking

import { Router } from '@angular/router';

describe('NavigationComponent', () => {
  let component: NavigationComponent;
  let router: jasmine.SpyObj<Router>;

  beforeEach(() => {
    const routerSpy = jasmine.createSpyObj('Router', [
      'navigate',
      'navigateByUrl'
    ]);

    TestBed.configureTestingModule({
      declarations: [NavigationComponent],
      providers: [
        { provide: Router, useValue: routerSpy }
      ]
    });

    router = TestBed.inject(Router) as jasmine.SpyObj<Router>;
    component = new NavigationComponent(router);
  });

  it('should navigate to dashboard', () => {
    component.goToDashboard();

    expect(router.navigate).toHaveBeenCalledWith(['/dashboard']);
  });

  it('should navigate with query params', () => {
    component.goToProducts({ category: 'electronics' });

    expect(router.navigate).toHaveBeenCalledWith(
      ['/products'],
      { queryParams: { category: 'electronics' } }
    );
  });
});

ActivatedRoute Mocking

import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';

describe('ProductDetailComponent', () => {
  let component: ProductDetailComponent;
  let route: ActivatedRoute;

  beforeEach(() => {
    const routeMock = {
      snapshot: {
        paramMap: {
          get: (key: string) => '123'
        }
      },
      params: of({ id: '123' }),
      queryParams: of({ tab: 'details' }),
      data: of({ product: { id: 1, name: 'Test' } })
    };

    TestBed.configureTestingModule({
      declarations: [ProductDetailComponent],
      providers: [
        { provide: ActivatedRoute, useValue: routeMock }
      ]
    });

    route = TestBed.inject(ActivatedRoute);
    component = new ProductDetailComponent(route);
  });

  it('should read route params', () => {
    expect(component.productId).toBe('123');
  });

  it('should subscribe to query params', () => {
    component.ngOnInit();
    expect(component.activeTab).toBe('details');
  });
});

RouterTestingModule

import { RouterTestingModule } from '@angular/router/testing';
import { Location } from '@angular/common';
import { Router } from '@angular/router';

describe('Navigation Integration', () => {
  let router: Router;
  let location: Location;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [
        RouterTestingModule.withRoutes([
          { path: 'home', component: HomeComponent },
          { path: 'about', component: AboutComponent }
        ])
      ],
      declarations: [HomeComponent, AboutComponent]
    }).compileComponents();

    router = TestBed.inject(Router);
    location = TestBed.inject(Location);
  });

  it('should navigate to home', fakeAsync(() => {
    router.navigate(['/home']);
    tick();

    expect(location.path()).toBe('/home');
  }));
});

Form Mocking

FormBuilder & FormControl Mocking

import { FormBuilder, FormGroup, Validators } from '@angular/forms';

describe('UserFormComponent', () => {
  let component: UserFormComponent;
  let fb: FormBuilder;

  beforeEach(() => {
    fb = new FormBuilder();
    component = new UserFormComponent(fb);
    component.ngOnInit();
  });

  it('should create form with controls', () => {
    expect(component.userForm.get('name')).toBeDefined();
    expect(component.userForm.get('email')).toBeDefined();
  });

  it('should validate required fields', () => {
    const name = component.userForm.get('name');
    expect(name?.valid).toBeFalsy();
    expect(name?.hasError('required')).toBeTruthy();

    name?.setValue('John Doe');
    expect(name?.valid).toBeTruthy();
  });

  it('should validate email format', () => {
    const email = component.userForm.get('email');
    
    email?.setValue('invalid-email');
    expect(email?.hasError('email')).toBeTruthy();

    email?.setValue('valid@example.com');
    expect(email?.valid).toBeTruthy();
  });
});

Custom Validators Mocking

// Custom validator
export function ageValidator(min: number, max: number): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const age = control.value;
    if (age < min || age > max) {
      return { ageRange: { min, max, actual: age } };
    }
    return null;
  };
}

// Testing custom validator
describe('Age Validator', () => {
  it('should validate age range', () => {
    const control = new FormControl(25);
    const validator = ageValidator(18, 65);

    expect(validator(control)).toBeNull(); // Valid

    control.setValue(10);
    expect(validator(control)).toEqual({
      ageRange: { min: 18, max: 65, actual: 10 }
    });

    control.setValue(70);
    expect(validator(control)).toEqual({
      ageRange: { min: 18, max: 65, actual: 70 }
    });
  });
});

LocalStorage & SessionStorage

Storage Mocking

describe('StorageService', () => {
  let service: StorageService;
  let store: { [key: string]: string };

  beforeEach(() => {
    store = {};

    spyOn(localStorage, 'getItem').and.callFake((key: string) => {
      return store[key] || null;
    });

    spyOn(localStorage, 'setItem').and.callFake((key: string, value: string) => {
      store[key] = value;
    });

    spyOn(localStorage, 'removeItem').and.callFake((key: string) => {
      delete store[key];
    });

    spyOn(localStorage, 'clear').and.callFake(() => {
      store = {};
    });

    service = new StorageService();
  });

  it('should save data to localStorage', () => {
    service.setItem('key', 'value');
    
    expect(localStorage.setItem).toHaveBeenCalledWith('key', 'value');
    expect(store['key']).toBe('value');
  });

  it('should retrieve data from localStorage', () => {
    store['key'] = 'value';
    
    const result = service.getItem('key');
    
    expect(localStorage.getItem).toHaveBeenCalledWith('key');
    expect(result).toBe('value');
  });

  it('should remove item from localStorage', () => {
    store['key'] = 'value';
    
    service.removeItem('key');
    
    expect(localStorage.removeItem).toHaveBeenCalledWith('key');
    expect(store['key']).toBeUndefined();
  });
});

Advanced Patterns

Partial Mocking

// Mock only specific methods, keep others real
describe('ComplexService', () => {
  let service: ComplexService;

  beforeEach(() => {
    service = new ComplexService();
    spyOn(service, 'expensiveOperation').and.returnValue('mocked');
    // Other methods use real implementation
  });

  it('should use mocked method', () => {
    const result = service.doSomething();
    expect(service.expensiveOperation).toHaveBeenCalled();
  });
});

Spy Chaining

it('should chain multiple service calls', () => {
  const userService = jasmine.createSpyObj('UserService', ['getUser']);
  const orderService = jasmine.createSpyObj('OrderService', ['getOrders']);

  userService.getUser.and.returnValue(of({ id: 1, name: 'John' }));
  orderService.getOrders.and.returnValue(of([{ id: 1, total: 100 }]));

  component.loadUserData(1);

  expect(userService.getUser).toHaveBeenCalledWith(1);
  expect(orderService.getOrders).toHaveBeenCalledWith(1);
});

Conditional Mocking

it('should mock based on arguments', () => {
  const service = jasmine.createSpyObj('ApiService', ['getData']);

  service.getData.and.callFake((id: number) => {
    if (id === 1) {
      return of({ data: 'admin' });
    } else if (id === 2) {
      return of({ data: 'user' });
    } else {
      return throwError(() => new Error('Not found'));
    }
  });

  service.getData(1).subscribe(result => {
    expect(result.data).toBe('admin');
  });

  service.getData(2).subscribe(result => {
    expect(result.data).toBe('user');
  });

  service.getData(999).subscribe({
    error: (error) => {
      expect(error.message).toBe('Not found');
    }
  });
});

Dynamic Mock Configuration

describe('ConfigurableService', () => {
  let service: MyService;
  let apiService: jasmine.SpyObj<ApiService>;

  function configureTest(config: {
    shouldSucceed: boolean;
    delay?: number;
    returnValue?: any;
  }) {
    if (config.shouldSucceed) {
      let obs = of(config.returnValue || { success: true });
      if (config.delay) {
        obs = obs.pipe(delay(config.delay));
      }
      apiService.getData.and.returnValue(obs);
    } else {
      apiService.getData.and.returnValue(
        throwError(() => new Error('Failed'))
      );
    }
  }

  beforeEach(() => {
    apiService = jasmine.createSpyObj('ApiService', ['getData']);
    service = new MyService(apiService);
  });

  it('should handle success case', () => {
    configureTest({ 
      shouldSucceed: true,
      returnValue: { data: 'test' }
    });

    service.loadData().subscribe(result => {
      expect(result.data).toBe('test');
    });
  });

  it('should handle error case', () => {
    configureTest({ shouldSucceed: false });

    service.loadData().subscribe({
      error: (error) => {
        expect(error.message).toBe('Failed');
      }
    });
  });
});

Best Practices

DO

// Use spy objects for dependencies
const spy = jasmine.createSpyObj('Service', ['method']);

// Reset spies between tests
afterEach(() => {
  spy.calls.reset();
});

// Use meaningful test data
const mockUser = { id: 1, name: 'John Doe', email: 'john@example.com' };

// Mock at the right level
// Component tests: Mock services
// Service tests: Mock HTTP
// E2E tests: Mock nothing

// Verify spy calls explicitly
expect(spy.method).toHaveBeenCalledWith('expected', 'arguments');

DON'T

// Don't use real services in unit tests
TestBed.configureTestingModule({
  providers: [RealDatabaseService] // ❌ Bad
});

// Don't over-mock
spyOn(Math, 'random');  // ❌ Don't mock language features
spyOn(Array.prototype, 'push'); // ❌ Don't mock prototypes

// Don't create brittle mocks
spy.and.returnValue({ /* huge object */ }); // ❌ Too specific

// Don't forget to verify HTTP calls
// Missing httpMock.verify() in afterEach ❌

Quick Reference

Common Spy Patterns

// Return value
spy.and.returnValue(value);

// Return Observable
spy.and.returnValue(of(value));

// Return Promise
spy.and.resolveTo(value);

// Throw error
spy.and.throwError('error');

// Custom logic
spy.and.callFake((arg) => { /* logic */ });

// Call real method
spy.and.callThrough();

Common Assertions

expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledTimes(n);
expect(spy).toHaveBeenCalledWith(arg1, arg2);
expect(spy).not.toHaveBeenCalled();

HTTP Testing

const req = httpMock.expectOne(url);
expect(req.request.method).toBe('GET');
req.flush(mockData);
httpMock.verify(); // In afterEach

Master mocking for clean, maintainable tests! 🎭