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

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()andvi.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:
- If the tax rate calculation logic changes, our invoice tests break (brittle tests).
- The
GovernmentExportServicemight 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 returns404, 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
| Action | Jasmine (Karma) | Jest | Vitest (Modern Angular) |
|---|---|---|---|
| Create Mock Function | jasmine.createSpy() | jest.fn() | vi.fn() |
| Mock Return Value | .and.returnValue(val) | .mockReturnValue(val) | .mockReturnValue(val) |
| Spy on Method | spyOn(obj, 'method') | jest.spyOn(obj, 'method') | vi.spyOn(obj, 'method') |
| Verify Called Times | expect(s).toHaveBeenCalledTimes(1) | expect(s).toHaveBeenCalledTimes(1) | expect(s).toHaveBeenCalledOnce() |
Summary Comparison: Mock vs Spy 📊
| Characteristic | Mock 🎭 | Spy 🕵️♂️ |
|---|---|---|
| Primary Goal | Replace dependency and control returned output | Observe and record method invocations |
| Underlying Implementation | Canned / simulated object | Wraps existing method or records calls |
| Verification Focus | State verification (Did the class compute output correctly?) | Behavior verification (Did the class call the collaborator?) |
| Best Scenario | HTTP APIs, complex calculations, database calls | Logging, event dispatching, notifications |
Recap 🛠️
To write clean, non-brittle unit tests:
- Use Mocks (
vi.fn()/jest.fn()) to simulate external service responses and test edge-case outputs. - Use Spies (
vi.spyOn()) to verify that methods are triggered with the right arguments. - 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!
These are my experiences learning and facing my daily challenges working with Angular.
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
Testing Components In Angular: NO_ERRORS_SCHEMA, Stub Components, and ng-mocks
Learn how to test Angular components with complex child dependencies. Compare NO_ERRORS_SCHEMA, manual stub components, and ng-mocks in both legacy and modern Standalone Angular.
How To Test Components In Angular Using TestBed
Learn how to unit test Angular components with TestBed, handle child components and service mocks, and switch from Karma/Jasmine to modern Vitest with Standalone and Signals.
How to Configure Jest in Angular 18
When we write tests in Angular Jasmine and Karma are the default for testing library and runner, but the Angular team was working on bringing Jest to Angular. Today, we're going to experiment with the Jest Builder for Jest (Experimental) in Angular 1...
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.
Join 13,800+ developers and readers.
No spam ever. Unsubscribe at any time.