Angular
·6 min read·↗

How and When to Use Mocks vs Spies in Unit Testing (Vitest, Jest, and Jasmine)

Main cover illustration for article: How and When to Use Mocks vs Spies in Unit Testing (Vitest, Jest, and Jasmine)
Summarize with AI:

When writing unit tests for Angular services or TypeScript domain classes, you rarely test functions in total isolation. Your classes have dependencies: HTTP clients, authentication providers, tax calculators, or third-party SDKs.

To prevent tests from hitting real servers or breaking when secondary logic changes, we use Test Doubles. Two of the most common are Mocks and Spies.

⚡ Using Modern Angular with Vitest or Jest? Jump directly to Modern Test Runners: Mocking & Spying in Vitest and Jest to see how to use vi.fn() and vi.spyOn().

In this guide, we will clarify the difference between Mocks and Spies, review practical scenarios, and explore examples across Vitest, Jest, and Jasmine.

Let's begin by defining our test scenario.


1. The Scenario: Invoice Processing Service 🧾

Imagine an InvoiceService that calculates an invoice total based on tax rules and submits the completed invoice to an external government API:

// invoice.service.ts
import { Injectable, inject } from '@angular/core';
 
export class TaxCalculationService {
  getTaxRate(): number {
    // In real life, this might make an HTTP call to a tax service
    return 0.18;
  }
}
 
export class GovernmentExportService {
  sendToGovernment(invoiceId: number): boolean {
    console.log(`Sending invoice ${invoiceId} to government gateway...`);
    return true;
  }
}
 
export class InvoiceService {
  id: number = Math.floor(Math.random() * 1000000);
  isProcessed: boolean = false;
 
  constructor(
    private taxService: TaxCalculationService,
    private exportService: GovernmentExportService
  ) {}
 
  calculateTotal(subtotal: number): number {
    const rate = this.taxService.getTaxRate();
    return subtotal + (subtotal * rate);
  }
 
  processInvoice(): boolean {
    const success = this.exportService.sendToGovernment(this.id);
    if (success) {
      this.isProcessed = true;
    }
    return this.isProcessed;
  }
}

The Problem with Testing Using Real Instances

If we test InvoiceService by instantiating the real TaxCalculationService and GovernmentExportService:

  1. If the tax rate calculation logic changes, our invoice tests break (brittle tests).
  2. The GovernmentExportService might attempt real network operations or side effects during test runs.

Let's look at how Mocks solve the first problem.


2. Using Mocks: Controlling Return Values 🎭

A Mock replaces a dependency with a controlled fake object. You dictate what values it returns so you can verify how your unit handles specific outputs.

When to use a Mock:

  • When you want to isolate your class from external calculations or HTTP responses.
  • When you want to test edge cases (e.g. tax rate is 0, API returns 404, or service throws an error).

Example with Jasmine (jasmine.createSpyObj)

import { InvoiceService, TaxCalculationService, GovernmentExportService } from './invoice.service';
 
describe('InvoiceService with Mocks (Jasmine)', () => {
  let invoiceService: InvoiceService;
  let mockTaxService: jasmine.SpyObj<TaxCalculationService>;
  let mockExportService: jasmine.SpyObj<GovernmentExportService>;
 
  beforeEach(() => {
    // 1. Create mock objects with designated methods
    mockTaxService = jasmine.createSpyObj<TaxCalculationService>(['getTaxRate']);
    mockExportService = jasmine.createSpyObj<GovernmentExportService>(['sendToGovernment']);
 
    // 2. Control return values
    mockTaxService.getTaxRate.and.returnValue(0.20); // 20% tax
 
    // 3. Inject mocks into the service
    invoiceService = new InvoiceService(mockTaxService, mockExportService);
  });
 
  it('should calculate total with mocked tax rate', () => {
    const total = invoiceService.calculateTotal(100);
    expect(total).toBe(120); // 100 + (100 * 0.20)
  });
});

Now let's see how Spies differ when you only need to observe function invocations.


3. Using Spies: Verifying Execution and Arguments 🕵️‍♂️

A Spy monitors function executions. It observes:

  • Was the method called?
  • How many times was it called?
  • What exact arguments were passed to it?

When to use a Spy:

  • When you don't necessarily need to replace the returned data, but want to verify that a secondary action (e.g. logging, analytics dispatch, saving to storage) occurred with the correct parameters.

Example with Jasmine (spyOn)

it('should verify sendToGovernment was called with invoice ID', () => {
  mockExportService.sendToGovernment.and.returnValue(true);
 
  const result = invoiceService.processInvoice();
 
  // Verify execution and exact arguments
  expect(mockExportService.sendToGovernment).toHaveBeenCalledTimes(1);
  expect(mockExportService.sendToGovernment).toHaveBeenCalledWith(invoiceService.id);
  expect(result).toBe(true);
});

Now let's examine modern testing in Angular using Vitest.


4. Modern Test Runners: Mocking and Spying in Vitest and Jest ⚡

Modern Angular (v18 and v19+) provides first-class support for Vitest (via @angular/build and Vite tooling) and Jest, replacing the deprecated Karma/Protractor setup.

In Vitest and Jest, the API is concise, unified, and standard across TypeScript.

Mocking and Spying with Vitest (vi.fn and vi.spyOn)

// invoice.service.spec.ts (Vitest)
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { InvoiceService, TaxCalculationService, GovernmentExportService } from './invoice.service';
 
describe('InvoiceService (Vitest)', () => {
  let invoiceService: InvoiceService;
  let mockTaxService: Partial<TaxCalculationService>;
  let mockExportService: Partial<GovernmentExportService>;
 
  beforeEach(() => {
    // 1. Create mock with vi.fn()
    mockTaxService = {
      getTaxRate: vi.fn().mockReturnValue(0.10), // 10% tax
    };
 
    mockExportService = {
      sendToGovernment: vi.fn().mockReturnValue(true),
    };
 
    invoiceService = new InvoiceService(
      mockTaxService as TaxCalculationService,
      mockExportService as GovernmentExportService
    );
  });
 
  it('should calculate total with Vitest mock', () => {
    const total = invoiceService.calculateTotal(200);
    expect(total).toBe(220);
    expect(mockTaxService.getTaxRate).toHaveBeenCalledOnce();
  });
 
  it('should spy on government export execution', () => {
    invoiceService.processInvoice();
 
    expect(mockExportService.sendToGovernment).toHaveBeenCalledWith(invoiceService.id);
    expect(invoiceService.isProcessed).toBe(true);
  });
});

Quick Syntax Conversion Guide

ActionJasmine (Karma)JestVitest (Modern Angular)
Create Mock Functionjasmine.createSpy()jest.fn()vi.fn()
Mock Return Value.and.returnValue(val).mockReturnValue(val).mockReturnValue(val)
Spy on MethodspyOn(obj, 'method')jest.spyOn(obj, 'method')vi.spyOn(obj, 'method')
Verify Called Timesexpect(s).toHaveBeenCalledTimes(1)expect(s).toHaveBeenCalledTimes(1)expect(s).toHaveBeenCalledOnce()

Summary Comparison: Mock vs Spy 📊

CharacteristicMock 🎭Spy 🕵️‍♂️
Primary GoalReplace dependency and control returned outputObserve and record method invocations
Underlying ImplementationCanned / simulated objectWraps existing method or records calls
Verification FocusState verification (Did the class compute output correctly?)Behavior verification (Did the class call the collaborator?)
Best ScenarioHTTP APIs, complex calculations, database callsLogging, event dispatching, notifications

Recap 🛠️

To write clean, non-brittle unit tests:

  1. Use Mocks (vi.fn() / jest.fn()) to simulate external service responses and test edge-case outputs.
  2. Use Spies (vi.spyOn()) to verify that methods are triggered with the right arguments.
  3. In modern Angular projects, embrace Vitest for fast, native ESM test execution.

For more Angular testing guides, check out my articles on Testing Components in Angular with ng-mocks and How to Test Components Using TestBed!

Part of the Angular Series

These are my experiences learning and facing my daily challenges working with Angular.

View Entire Series

Frequently Asked Questions

What is the key difference between a Mock and a Spy in unit testing?

A Mock replaces a real dependency with a completely simulated object that returns canned responses, isolating the unit under test. A Spy wraps an existing object or method to observe, record arguments, and verify execution without necessarily replacing the underlying logic.

How do I create mocks and spies in modern Angular with Vitest?

In Vitest (the modern test runner in Angular), create mock functions using 'vi.fn().mockReturnValue(val)' and create method spies using 'vi.spyOn(object, 'methodName')'.

When should I use a Mock instead of a Spy?

Use a Mock when the real dependency makes external HTTP calls, accesses a database, or performs expensive calculations whose output you want to control. Use a Spy when you want to verify that a method was invoked with specific arguments.

How does Angular test runner tooling compare between Jasmine, Jest, and Vitest?

Jasmine/Karma is the legacy test runner in older Angular projects. Modern Angular (v18 and v19+) defaults to Vite-based testing with Vitest or Jest, offering instant execution, native ESM support, and simplified mocking via vi.fn() and vi.spyOn().

Related Articles

Share this article

If you found this guide helpful, consider sharing it with your team or fellow developers.


Real Software. Real Lessons.

I share the lessons I learned the hard way, so you can either avoid them or be ready when they happen.

User avatar
User avatar
User avatar
User avatar
+13K

Join 13,800+ developers and readers.

No spam ever. Unsubscribe at any time.