For many years, Angular developers relied on the Karma/Jasmine tool stack, which came bundled with the Angular CLI.
While alternatives such as Jest were sometimes used, Karma and Jasmine remained the default and most widely adopted solution.
Karma acted as the test runner, executing tests in a real browser environment. Jasmine served as the testing framework, providing the APIs needed to define test suites, specifications, and assertions.
The Angular CLI no longer uses Karma/Jasmine for new projects.
They can still be used if needed, but the new default testing solution is now Vitest — a modern, fast, all-in-one, batteries-included testing framework that provides an integrated solution for almost every aspect of testing.
This article introduces the fundamental concepts of Vitest, helping you understand the new capabilities available when writing Angular tests.
We will explore key concepts such as spying, mocking, and pure mocks. You will also learn how to ensure proper test isolation by understanding the differences between clearing, resetting, and restoring mocks.
This post focuses strictly on Vitest fundamentals.
It does not cover the setup and configuration of Angular tests, component testing strategies, or other Angular-specific testing patterns — those topics will be addressed in future articles.
If you are already familiar with Karma and Jasmine, we will begin by comparing the Karma/Jasmine tool stack with Vitest to help you quickly understand the key differences.
Table of Contents
In this post, we will cover the following topics:
- Introduction to Vitest: comparison with Karma / Jasmine.
- How Spying works in Vitest: understanding Vitest spies
- How Mocking works in Vitest
- Pure mocks in Vitest
- Mock clearing - understanding mockClear()
- Mock resetting - understanding mockReset()
- Mock restoring - understanding mockRestore()
- Configuring Vitest to clean up between tests
- Conclusions
So without further ado, let's get started with this deep dive into the fundamentals of Vitest!
Introduction to Vitest: comparison with Karma/Jasmine
With Vitest, the test runner is not a separate tool like Karma — it is built directly into Vitest itself. This eliminates the need to coordinate multiple tools and provides a more integrated testing experience.
Vitest is significantly faster than Karma in most scenarios.
One key reason for the extra speed is that Vitest does not require launching and managing a full browser instance by default.
Instead, tests typically run in a simulated browser environment powered by a lightweight, non-visual DOM implementation such as jsdom.
Although jsdom is the default DOM environment, Vitest gives you full control over the execution environment. For example, you can replace jsdom with other lightweight DOM implementations such as happy-dom.
If needed, Vitest can also run tests in a real browser using its browser mode (powered by tools like Playwright), allowing execution in environments such as Chromium. This provides flexibility when true browser APIs or rendering behavior must be tested.
In most cases, however, the default jsdom-based configuration is more than sufficient and offers excellent performance.
And so that covers how the Vitest test runner differs from Karma.
But what about writing tests themselves? How does the Vitest API compare to Jasmine?
The Vitest testing API
At first glance, the Vitest testing API looks very similar to Jasmine. It follows the familiar describe / it / expect structure for organizing test suites and writing assertions.
There are, however, a few important differences:
- More powerful assertions – Vitest’s assertion system is inspired and comparable in expressiveness to Jest, which is a popular alternative to Jasmine in the Angular ecosystem.
- Stronger mocking capabilities – Vitest provides a much more powerful mocking system, including the ability to mock entire ES modules.
- Explicit imports – Unlike Jasmine in the Angular CLI setup, Vitest does not rely on globals by default. Testing utilities such as
describe,it,expect, andviare typically imported explicitly (unless you opt into global mode).
This gives you a high-level overview of how Vitest compares to the previous Angular testing stack.
Next, let’s explore the most fundamental concepts of Vitest, starting with the notion of spying.
How Spying works in Vitest: Understanding Vitest Spies
In Vitest, a spy is a tool that lets you observe how a function is being used during a test without changing its actual behavior.
This is especially useful when you want to verify not just the result of a function, but also how it was called.
For example, imagine a simple calculator module with an add method that adds two numbers and logs a message to the console:
function add(a: number, b: number) {
console.log('REAL add() called');
return a + b;
}
export const calculator = {
add
}
A normal test might simply check that add(2, 3) returns 5. However, sometimes that’s not enough.
You may also want to confirm that the function was called exactly once, and that it was called with the correct arguments.
That’s where spies come in.
Here is an example of how spying works in Vitest:
import {describe, it, expect, vi} from 'vitest';
import {calculator} from "./calculator";
describe("Vitest Fundamentals", () => {
it("should add two numbers", () => {
const result = calculator.add(2, 3);
expect(result).toBe(5);
})
it("shows how Vitest spies work", () => {
const spy = vi.spyOn(calculator, "add");
const result = calculator.add(2, 3);
expect(result).toBe(5);
expect(spy).toHaveBeenCalledOnce();
expect(spy).toHaveBeenCalledWith(2, 3);
})
})
In the first test of this test suite, we are calling the actual calculator functionality, without any spies attached to it.
It's just a pure unit test.
In the second test though, we are attaching a Vitest spy to the calculator module, specifically to the add function.
The spy will by default observe the original add() function, without modifying its behavior.
And by that I mean that from the point of view of a user of the function, the calculator can still be called as usual, and the actual underlying add method will still be called.
But each time that add() is called, the spy is making note of it, and tracking the calls.
That is why we can assert in that second test that the add() function was only called once, and with the exact arguments 2 and 3.
And that is how spying works in Vitest: it's all about observing a behavior, without altering it. Next up, let's talk about mocking.
How Mocking works in Vitest: Understanding Vitest Mocks
Vitest mocking allows you to replace the real implementation of a function with a fake one.
This is useful when you want full control over what a dependency returns, without executing its real logic.
Notice that mocking can be used in combination with spying, and in fact the two techniques are often used together.
To understand how mocking works, consider the following test:
it("shows how Vitest mocking works", () => {
const spy = vi.spyOn(calculator, "add")
.mockReturnValue(5);
const result = calculator.add(2, 3);
expect(result).toBe(5);
expect(spy).toHaveBeenCalledOnce();
expect(spy).toHaveBeenCalledWith(2, 3);
// the result is always 5, due to mocking
const result2 = calculator.add(5, 5);
expect(result2).toBe(5);
})
Here, we start by creating a spy on the calculator add() function.
But unlike a simple spy, we chain .mockReturnValue(5).
This changes the behavior of the function: instead of running the original add implementation, it will now always return 5, no matter what arguments are passed.
More than that, the actual add() implementation is completely bypassed!
When we call calculator.add(2, 3), the real addition logic is no longer executed.
The mocked version runs instead and immediately returns 5.
The same happens when we call calculator.add(5, 5) — even though the real result would be 10, the mocked function still returns 5.
At the same time, the spy continues tracking how the function is used.
We can still assert that it was called once, and that it was called with specific arguments.
So mocking with mockReturnValue gives us full control over the return value of the mocked function.
In short:
- spying observes calls but keeps the real implementation.
- mocking replaces the implementation.
You can still verify call count and arguments while controlling the return value.
When to use mocking?
Mocking is especially useful in real applications when you want to test a component in isolation from some of its dependencies.
These could be services with API calls, or complex dependencies where you don’t really want the actual logic to execute during the test, but instead you would want to replace that dependency with a mock.
In this example we have mocked an actual external module, and replaced the default functionality.
Let's now see another type of mocks in Vitest - pure mocks.
Pure mocks in Vitest
When working with Vitest, most developers first encounter mocks in the context of replacing a specific method on an imported dependency.
In our previous example, we were spying on a real calculator add() method, and we were overriding just that particular function while leaving the rest of the module intact.
A pure mock, however, takes a different approach.
Instead of partially mocking a real dependency, you create a completely standalone mock function that has no underlying implementation at all.
A pure mock doesn’t wrap real code — instead, it just provides a completely fake alternative, with a similar API.
In Vitest, pure mocks are created using vi.fn().
This utility returns a new mock function that can track how it was called and can be configured to return specific values.
For example:
it("shows how a Vitest pure mock works", () => {
const addMock = vi.fn().mockReturnValue(10);
const result = addMock(5, 5);
expect(result).toBe(10);
expect(addMock).toHaveBeenCalledOnce();
expect(addMock).toHaveBeenCalledWith(5, 5);
});
Here, vi.fn() creates a brand-new mock function.
By chaining mockReturnValue(10), we define its behavior: regardless of the arguments passed, it will always return 10.
There is no real “add” implementation underneath — no logic that sums numbers.
The function is entirely synthetic, built purely for testing purposes.
But even though the mock has no real logic, Vitest still records how it is used.
That’s why we can assert that it was called exactly once and verify the exact arguments with which it was called.
When to use pure mocks?
This dual capability — controlling return values while inspecting invocation details — makes pure mocks extremely powerful.
Pure mocks are especially useful when creating or instantiating a real dependency would be expensive, complex, or unnecessary for the test.
Instead of importing and partially mocking a module, you can replace the entire dependency with a lightweight fake implementation.
This approach keeps tests isolated, fast, and focused strictly on the behavior of the unit under test.
And that covers mocking and spying. If you also like to learn by watching videos, here is a free video introduction on how Vitest spies work (and more).
Check out the free sample video lessons available as part of the Angular Testing In Depth (Signals Edition) course:
Now that we know about mocking and spying, let's talk about how to keep your tests well isolated from each other.
In Vitest, this involves 3 separate but highly inter-related concepts that we will cover one at a time: mock clearing, resetting and restoring.
Let's start with mock clearing.
Mock clearing - understanding mockClear()
When you create a spy in Vitest, that does a couple of things:
- The spy watches a function
- The spy keeps track of how many times that function was called (and with what arguments).
There could be situations in your tests, where you might want to clear that tracking data.
Mock clearing does exactly that - it resets the tracking data, but keeps the spy active.
That means the function is still being watched, but its “memory” of past calls is erased.
Here is an example:
it("shows how mock clearing works", () => {
const spy = vi.spyOn(calculator, "add");
const result = calculator.add(2, 3);
expect(result).toBe(5);
expect(spy).toHaveBeenCalledOnce();
spy.mockClear();
const result2 = calculator.add(5, 5);
expect(result2).toBe(10);
expect(spy).toHaveBeenCalledOnce();
});
Let's break down what is going on here:
- A spy was created on the real add method.
- The first call
add(2, 3)runs the real function and the spy records 1 call. - Then
mockClear()wipes the spy’s call history. - The second call
add(5, 5)runs the real function again.
So now the spy sees the second call as #1, because its history was cleared.
Even though add was called twice in total, the spy now only remembers calls made after mockClear().
You can think of mockClear() like this:
Keep watching the function — but forget everything that happened before.
Calling mockClear() cleans up call counts, but it does not:
- Remove the spy
- Restore the original function
- Change the implementation
It just clears the call history.
So that’s mock clearing in a nutshell. Next up, let's talk about mock resetting.
Mock resetting - understanding mockReset()
Vitest has a couple of “cleanup” operations for mocks, and it’s easy to mix them up until you see them side by side.
Mock resetting does everything a clear does plus removes any custom mock behavior.
In other words, mockReset() wipes the mock’s recorded state (calls, arguments, instances, etc.), and it removes any configured behavior such as
mockReturnValue or mockImplementation.
What “default behavior” means depends on what you’re resetting.
With a spy, there’s a real function underneath.
When you call mockReset() on a spy, it keeps the spy in place but removes the mocked return value or implementation you provided.
After that, calls go through to the original function again. You can see that in this test:
it("shows how mockReset() works for Vitest spies", () => {
const spy = vi.spyOn(calculator, "add");
spy.mockReturnValue(10);
const result = calculator.add(2, 3);
// result is 10, independently of
//the calling arguments
expect(result).toBe(10);
expect(spy).toHaveBeenCalledOnce();
spy.mockReset();
const result2 = calculator.add(2, 3);
// the actual add function is now called again
expect(result2).toBe(5);
expect(spy).toHaveBeenCalledOnce();
});
Before the reset, the original calculator.add() implementation is completely bypassed and always returns 10.
After mockReset(), the custom return value is gone, so add(2, 3) behaves normally again and returns 5.
Notice that the call history is also cleared—so the post-reset invocation is treated as the first call.
How does mockReset work for pure mocks?
With a pure mock (created using vi.fn()), and unlike spies, there is no underlying real implementation.
A pure mock starts with no behavior, which means calling it returns undefined unless you configure it.
When you call mockReset() on a pure mock, it clears the call history and removes the configured behavior, bringing it back to having no implementation:
it("shows how mockReset() works for pure mocks", () => {
const addMock = vi.fn().mockReturnValue(10);
const result = addMock(5, 5);
expect(result).toBe(10);
expect(addMock).toHaveBeenCalledOnce();
expect(addMock).toHaveBeenCalledWith(5, 5);
addMock.mockReset();
const result2 = addMock(5, 5);
expect(result2).toBe(undefined);
expect(addMock).toHaveBeenCalledOnce();
});
The key mental model is this:
clearing is about resetting state (what happened), while resetting is state plus behavior (what it does).
For spies, that means removing the mocked behavior so calls flow through to the original function.
For pure mocks, it means removing the mocked behavior so the function goes back to returning undefined.
So this covers resetting, leaving only one final concept to cover: mock restoring.
Mock restoring - understanding mockRestore()
When you use vi.spyOn() in Vitest, you’re temporarily replacing a real method with a spy wrapper.
That wrapper both tracks how the function is used and lets you override its behavior.
But once a test is done, you usually want everything to go back to normal, by disconnecting the spy from the actual implementation.
And that’s where mockRestore() comes in.
The simplest way to understand mockRestore() is to think of it as doing three things at once: clear, reset, and restore.
- First, it clears the call history — any recorded calls, arguments, and return values are wiped.
- Second, it resets the mocked behavior — meaning any mockReturnValue or mockImplementation you defined is removed.
- Third, it restores and disconnects — the original real function is put back onto the object, and the spy wrapper is completely removed. Future calls go directly to the real implementation and are no longer tracked.
Here’s a small test that demonstrates the full lifecycle:
it("shows how mockRestore() works", () => {
const spy = vi.spyOn(calculator, "add");
spy.mockReturnValue(10);
const result = calculator.add(2, 3);
expect(result).toBe(10);
expect(spy).toHaveBeenCalledOnce();
spy.mockRestore();
const result2 = calculator.add(2, 3);
expect(result2).toBe(5);
expect(spy).toHaveBeenCalledTimes(0);
});
Let’s break it down.
We start by spying on calculator.add, which replaces the real method with a spy wrapper. Then we override its behavior so that add(2, 3) returns 10 instead of 5. The spy tracks that it was called once.
When spy.mockRestore() runs, Vitest clears the call history, removes the mocked return value, and re-installs the original add implementation. The spy is now fully disconnected.
So when we call calculator.add(2, 3) again, it returns 5 — the real logic is back.
And because the spy was cleared and detached, it reports zero calls.
Why use mockRestore?
In practice, you usually want to disconnect all spies between tests to avoid test pollution.
If you leave spies attached, mocked behavior can accidentally leak into later tests and cause confusing failures.
That’s why many test setups include an afterEach hook like this:
afterEach(() => {
vi.restoreAllMocks();
});
This ensures every spy is restored and disconnected after each test, keeping your test suite isolated and predictable.
In the next section, I'm going to show you a better way of doing this.
Configuring Vitest to clean up between tests
As you imagine, it's best to clean up all spies between each test, and never reuse any spies or mocks across tests.
Ideally each test creates all the mocks and spies that it needs to run, and then it cleans up everything in the end.
If we don't do so, we might accidentally introduce erratic behavior in our tests.
For example, the tests might start failing if we run them in a different order, etc.
To avoid all this, it's best to automatically cleanup everything after each test.
Instead of relying on local beforeEach functions, we can also do that globally via configuration:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals:false,
environment:'jsdom',
include: ['src/**/*.spec.ts'],
restoreMocks: true
},
});
This has the effect of ensuring that every test cleans up after itself automatically.
Notice that there are other flags for resetting and clearing as well:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals:false,
environment:'jsdom',
include: ['src/**/*.spec.ts'],
restoreMocks: true,
clearMocks:true,
mockReset:true
},
});
In practice, you should't need all 3. But if by some reason you need them, now you know that they are there.
And with this, we have covered the fundamental concepts of Vitest.
Let's now summarize everything, and wrap things up.
Conclusions
Vitest represents more than just a replacement for Karma and Jasmine — it introduces a simpler mental model, a faster execution environment, and a significantly more powerful mocking system.
By integrating the test runner directly into the framework, Vitest eliminates the complexity of coordinating multiple tools. Combined with its lightweight DOM environments and optional real browser mode, it offers both speed and flexibility depending on your testing needs.
At the API level, the familiar describe / it / expect structure makes the transition smooth for Angular developers.
But under the surface, Vitest provides:
- stronger assertions
- first-class module mocking
- explicit control over test utilities
All of which encourage clearer and more intentional test design.
Understanding the differences between spies, mocks, and pure mocks is key:
-
Use spies when you want to observe real behavior without changing it.
-
Use mocks when you need to override behavior while still tracking usage.
-
Use pure mocks when you want a fully synthetic dependency with no real implementation underneath.
Equally important is mastering test isolation. Knowing when to use mockClear(), mockReset(), and mockRestore() ensures your tests remain predictable and independent.
Proper cleanup — ideally automated via configuration — prevents test pollution and avoids subtle, order-dependent failures.
Spying, Mocking .. when to use each and why?
In practice, a good rule of thumb is this:
-
Prefer spying on real functionality when you want confidence that real logic executes correctly while still verifying interactions.
-
Prefer pure mocks when the real implementation is irrelevant, slow, complex, or tied to external systems such as APIs.
Vitest gives you precise control over both approaches, allowing you to write tests that are fast, expressive, and isolated — without sacrificing clarity.
With these fundamentals in place, you now have a solid foundation for writing robust Angular tests with Vitest.
In upcoming articles, we’ll build on this knowledge and explore Angular-specific testing patterns.
Subscribe to my newsletter if you want me to send you the next articles to your mailbox: