Getting Started with Vitest: A Side-by-Side with Karma and Jasmine

For a long time, the Angular CLI shipped with Karma and Jasmine as the default testing setup, and this combination became the standard for most Angular projects. While some teams experimented with alternatives like Jest, the Karma/Jasmine stack remained the go-to choice.

Karma operated as the test runner, launching tests in an actual browser. Jasmine served as the framework, giving developers the tools to structure test cases and write expectations.

Fresh Angular projects no longer come with Karma and Jasmine out of the box.

You can still opt to use them if you prefer, but the modern default has shifted to Vitest. This framework aims to be a comprehensive, high-performance, all-in-one solution that covers most testing needs without requiring a lot of extra tooling.

Our focus here is on the core principles of Vitest, giving you a clear picture of the new tools at your disposal for writing Angular tests. We'll look at how to observe function calls with spies, how to replace real logic with mocks, and how to build isolated tests from scratch using pure mocks. We will also cover the critical differences between clearing, resetting, and restoring your mocks to ensure your tests don't interfere with one another.

This is a deep dive into the essentials of Vitest itself. We won't be covering how to configure Angular testing, write component tests, or other Angular-specific patterns — those will be the subject of later pieces.

If you're coming from a Karma and Jasmine background, we'll start by comparing these two stacks so you can see exactly what changes with Vitest.

Here is the roadmap for what we will explore:

  • Vitest at a glance: contrasting it with the Karma / Jasmine combo.
  • The mechanics of Spying in Vitest
  • How Mocking works in Vitest
  • Understanding pure mocks
  • Keeping tests isolated with mockClear()
  • Resetting state with mockReset()
  • Reversing mocks with mockRestore()
  • Setting up Vitest for automatic cleanup
  • Final thoughts

Let's dive into these core Vitest concepts.

Vitest vs. the Traditional Karma/Jasmine Stack

With Vitest, you no longer need a separate test runner like Karma. The runner is a built-in feature, which simplifies your setup and gives you a more connected testing experience without the hassle of coordinating multiple tools.

In many situations, Vitest is substantially quicker than Karma. A major reason for this performance boost is that Vitest, in its default configuration, doesn't need to start a full browser to run your tests.

Instead, it usually runs in a simulated browser environment provided by a lightweight, headless DOM library, such as jsdom.

While jsdom is the usual choice, Vitest gives you control over the execution environment. You can easily swap it out for other lightweight options like happy-dom.

For scenarios where you need genuine browser features or rendering, Vitest has a browser mode that leverages tools like Playwright to run tests in a real browser such as Chromium. This offers flexibility when you need to test against true browser APIs.

For the vast majority of tests, however, the standard jsdom setup is fast and more than adequate.

That covers the runner. But what about how you write the tests? How does the Vitest API stack up against Jasmine?

The Structure of the Vitest API

On the surface, Vitest's API feels quite similar to Jasmine. It uses the well-known describe / it / expect pattern for defining test suites and assertions.

However, there are some key distinctions to be aware of:

  • Richer assertions – The assertion library in Vitest is as expressive and powerful as Jest, another popular choice in the Angular world.
  • Advanced mocking features – Vitest boasts a more robust mocking system, allowing you to easily mock entire ES modules, which is a significant upgrade.
  • Explicit imports are key – In the standard Angular CLI setup, Jasmine relies on global variables. Vitest, by default, does not. You'll need to explicitly import tools like describe, it, expect, and vi in your test files (though you can enable global mode if you prefer).

This should give you a sense of how Vitest differs from the older Angular testing setup.

Now, let's move on to the most basic concept in Vitest: spying.

Understanding Spies in Vitest

Think of a spy in Vitest as a surveillance tool. It allows you to watch a function and track how it's being invoked during a test without changing what the function actually does.

This is very handy when you need to verify not just the output of a function, but the details of the calls themselves—how many times it was called, and with what arguments.

Let's say we have a simple calculator with an add function that does the math and logs a message:

function add(a: number, b: number) {
    console.log('REAL add() called');
    return a + b;
}

export const calculator = {
    add
}

A basic test might just verify that add(2, 3) equals 5. But sometimes, you need to know more than the final result.

You might want to ensure it was invoked exactly one time, and that it was invoked with the specific numbers 2 and 3.

This is precisely the job of a spy.

Here's a practical example:

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 within this suite, we're simply calling the real calculator function. There's no spying involved; it's a straightforward unit test.

In the second test, we create a spy on the add function of the calculator module. By default, this spy wraps the original add() method. It observes its behavior without altering it in any way.

What this means is that from the perspective of anything using the calculator, everything works as before. The real underlying add logic is still executed when the function is called.

But now, every time add() is invoked, the spy takes notes. It records each call in the background.

This is why, in that second test, we can confidently assert that add() was called exactly once, and that the arguments were precisely 2 and 3.

So, spying in Vitest is about observation without interference. With that clear, let's shift our focus to mocking.

Mocking is a different technique. Instead of just observing, you are now replacing the real behavior of a function with a stand-in, or fake, implementation.

This is the go-to strategy when you want to dictate exactly what a dependency returns, bypassing its real code entirely. Spies and mocks are not mutually exclusive; in fact, they are often used together, with the spy aspect providing call tracking for the mocked function.

To see how it works, look at this 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 begin by creating a spy on the calculator's add() function, just like before. But now, we add a crucial chain: .mockReturnValue(5).

This single line changes everything. The function no longer runs its original logic. Instead, no matter what arguments are provided, it will immediately return 5.

The most important detail here is that the real add() implementation is completely skipped. When we execute calculator.add(2, 3), the actual addition logic is never run. The mock version takes over and returns 5 right away.

The same thing happens when we call calculator.add(5, 5). Though the true sum would be 10, our mocked function still returns 5.

Throughout this, the spy is still active. It continues to record how the function is being used. This lets us make assertions on the call count (e.g., called once) and the specific arguments (e.g., with 2 and 3).

So, by using mockReturnValue, we gain total control over what a function returns while still retaining the ability to inspect how it was called.

To summarize:

  • Spying is for observation; it keeps the real implementation working.
  • Mocking is for substitution; it replaces the implementation with a fake one.

You get the call-tracking benefits of a spy along with the power to control the result of the function.

Determining When to Mock

Mocking becomes incredibly important in real-world applications when you need to test a component in isolation from its dependencies. These dependencies might be services that make API calls or other complex modules whose logic you don't want to execute in a unit test. Instead of letting them run, you replace them with a mock.

In our example, we mocked a method from an external module, overriding its default behavior. Next, we'll look at a different kind of mock in Vitest: the pure mock.

What are Pure Mocks?

Often, mocking involves taking an existing function within a module and altering just that part while keeping the rest of the module intact. In our last example, we spied on a real calculator add() method and overrode only that single function.

A pure mock is a different concept. Instead of partially mocking a real thing, you are building a completely new fake. This mock function has no connection to any real implementation whatsoever.

A pure mock doesn’t wrap anything. It's a completely fabricated alternative that mimics the API of the thing it replaces.

In Vitest, we create these using vi.fn(). This is a utility that returns a new, standalone mock function ready to be tracked and configured to return value you want.

Consider this 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() gives us a fresh mock. When we chain mockReturnValue(10), we are defining its behavior: no matter what arguments are used, it will always return 10.

Underneath this mock, there is no real “add” logic. There's no code summing the numbers. This function is entirely synthetic, invented purely for the test.

Even without any real logic, Vitest is monitoring its usage. We can easily assert that it was called exactly once and that it received the correct arguments.

Ideal Use Cases for Pure Mocks

Having the dual ability to set the return value and inspect the calls makes pure mocks a powerful tool. They are particularly useful when creating a real dependency would be costly, complicated, or just irrelevant to your test.

Rather than importing a whole module to partially mock a piece of it, you can swap out the entire dependency for a lightweight fake. This strategy helps keep your tests fast, isolated, and focused entirely on what you're trying to verify.

This covers the basics of mocking and spying. For those who prefer a video format, there is a free introduction to Vitest spies available.

You can find free sample lessons from the Angular Testing In Depth (Signals Edition) course here:

Modern Angular Testing with Vitest: The Fundamentals — figure 1

With mocking and spying under our belt, the next topic is a critical one: ensuring your tests are fully isolated from one another. Vitest provides three distinct but related methods for this, which we'll take one at a time: clearing, resetting, and restoring mocks.

We begin with clearing.

Understanding Clear Operations - A Look at mockClear()

When Vitest creates a spy, it performs a few tasks simultaneously:

  • The spy monitors the target function
  • The spy records how often that function gets invoked (along with its arguments).

There are scenarios during testing where you'd want to wipe that recorded information.

Clearing a mock accomplishes exactly that - it discards the tracking data while leaving the spy intact and operational.

This means the function remains under observation, but its "recollection" of previous invocations is wiped clean.

Consider this illustrative snippet:

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 examine what's happening step by step:

  • A spy is attached to the genuine add method.
  • The initial invocation add(2, 3) executes the actual function, and the spy logs one call.
  • Subsequently, mockClear() erases the spy's recorded history.
  • The second invocation add(5, 5) once again invokes the real function.

Consequently, the spy now regards this second invocation as the first, given that its history was wiped.

Even though add has been called twice in total, the spy now only accounts for calls that took place after mockClear() was invoked.

One way to conceptualize mockClear() is:

Continue observing the function — but erase any recollection of what transpired previously.

Invoking mockClear() purges call metrics, yet it does not:

  • Detach the spy
  • Reinstate the original function
  • Alter the implementation logic

Its sole purpose is to remove the invocation history.

That encapsulates mock clearing. Now, let's delve into the concept of mock resetting.

Comprehending Reset Operations - An Examination of mockReset()

Vitest offers several "cleanup" mechanisms for mocks, and distinguishing between them can be tricky until you observe them side-by-side.

Resetting a mock encompasses everything clearing does, plus it strips away any bespoke mock configurations.

To put it another way, mockReset() purges the mock's logged state (calls, arguments, instances, etc.), and it discards any established behavior like
mockReturnValue or mockImplementation.

What constitutes "default behavior" varies based on the object being reset.

For a spy, there's a genuine function lying beneath the surface.

When you apply mockReset() to a spy, it maintains the spy's presence but eliminates the mocked return value or implementation you supplied.

Following that, invocations are routed through to the original function once more. This behavior is illustrated in the following 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();
});

Prior to the reset, the original calculator.add() logic is entirely bypassed, and the function consistently yields 10.

Once mockReset() is applied, the custom return value vanishes, so add(2, 3) reverts to standard operation, returning 5.

It's worth noting that the invocation history is also expunged—so the post-reset call is counted as the first one.

What happens when mockReset is applied to pure mocks?

For a pure mock (generated via vi.fn()), unlike spies, there's no real implementation lurking underneath.

A pure mock initiates without any behavior, implying that calling it results in undefined unless you configure it otherwise.

Applying mockReset() to a pure mock cleans the invocation history and eliminates any configured behavior, reverting it to a state with 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 fundamental mental framework to adopt is:

clearing focuses on resetting state (what occurred), while resetting addresses both state and behavior (what it executes).

For spies, this entails removing the mocked behavior so that invocations are directed to the original function.

For pure mocks, it involves eliminating the mocked behavior so that the function reverts to yielding undefined.

That wraps up resetting, leaving just one final idea to explore: mock restoring.

Comprehending Restore Operations - A Look at mockRestore()

When you employ vi.spyOn() within Vitest, you're momentarily substituting a real method with a spy wrapper.

This wrapper serves dual purposes: it monitors how the function is employed and permits you to modify its behavior.

However, once a test concludes, it's typical to want things returned to their original state by detaching the spy from the actual implementation.

This is precisely where mockRestore() becomes relevant.

The easiest approach to grasping mockRestore() is to view it as a threefold operation: clear, reset, and restore.

  • First, it clears the invocation history — all noted calls, arguments, and return values are obliterated.
  • Second, it resets the mocked behavior — any mockReturnValue or mockImplementation you've specified is discarded.
  • Third, it restores and detaches — the original real function is reattached to the object, and the spy wrapper is thoroughly eliminated. Future invocations proceed directly to the real implementation and remain untracked.

The following compact test demonstrates this entire 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 dissect this.

We begin by spying on calculator.add, substituting the real method with a spy wrapper. Then we alter its behavior so that add(2, 3) produces 10 instead of 5. The spy logs that it was invoked once.

When spy.mockRestore() executes, Vitest purges the invocation history, removes the mocked return value, and reattaches the original add implementation. The spy is now completely detached.

Thus, when we invoke calculator.add(2, 3) once more, it yields 5 — the genuine logic has been reinstated.

Furthermore, since the spy was cleared and detached, it registers zero invocations.

The rationale behind employing mockRestore

In real-world scenarios, you typically want to detach all spies between tests to prevent cross-test contamination.

Leaving spies attached can result in mocked behavior inadvertently leaking into subsequent tests, leading to puzzling failures.

This is why many test configurations incorporate an afterEach hook similar to this:

afterEach(() => {
    vi.restoreAllMocks();
});

This guarantees that every spy is restored and detached after each test, maintaining isolation and predictability in your test suite.

In the upcoming section, I'll present a more streamlined approach to handling this.

Setting Up Vitest for Automatic Cleanup Between Tests

As you might expect, it's optimal to restore all spies between individual tests, avoiding the reuse of spies or mocks across different tests.

Ideally, each test generates all the mocks and spies it requires, then cleans everything up once it's done.

Neglecting this can result in unintended erratic behavior in your tests.

For instance, tests might fail if they're executed in a different sequence, among other issues.

To mitigate all this, it's best to automate the cleanup process after every test.

Rather than depending on local beforeEach functions, you have the option to handle this globally through configuration:

    import { defineConfig } from 'vitest/config';
    
    export default defineConfig({
    test: {
    globals:false,
    environment:'jsdom',
    include: ['src/**/*.spec.ts'],
    restoreMocks: true
    },
    });

This ensures that each test automatically cleans up after itself.

You'll observe that there are additional 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 typical use, you shouldn't require all three. However, should the need arise, it's good to know they're available.

With this, we've addressed the essential concepts of Vitest.

Let's now consolidate everything and bring this to a close.

Final Thoughts

Vitest transcends being merely a substitute for Karma and Jasmine — it presents a more straightforward conceptual framework, a quicker execution environment, and a notably more robust mocking system.

By embedding the test runner directly into the framework, Vitest removes the hassle of orchestrating various tools. Combined with its lightweight DOM environments and an optional real browser mode, it delivers both speed and adaptability tailored to your testing requirements.

At the API level, the recognizable describe / it / expect structure eases the transition for Angular developers.

Yet beneath the surface, Vitest provides:

  • enhanced assertions
  • first-class module mocking
  • explicit governance over test utilities

All these features promote clearer and more purposeful test design.

Grasping the distinctions between spies, mocks, and pure mocks is essential:

  • Opt for spies when the goal is to monitor genuine behavior without modifying it.

  • Employ mocks when you must supplant behavior while still logging its usage.

  • Choose pure mocks for a completely synthetic dependency that lacks any real implementation underneath.

Equally critical is mastering test isolation. Recognizing when to leverage mockClear(), mockReset(), and mockRestore() ensures your tests stay predictable and self-contained.

Consistent cleanup — ideally automated through configuration — prevents test pollution and sidesteps subtle, ordering-dependent setbacks.

Spying, Mocking .. when to apply each and why?

In practice, a valuable guiding principle is this:

  • Favor spying on actual functionality when you seek assurance that real logic runs correctly while still confirming interactions.

  • Favor pure mocks when the real implementation is unnecessary, sluggish, complicated, or connected to external services such as APIs.

Vitest grants you precise authority over both strategies, enabling the creation of tests that are rapid, expressive, and isolated — without sacrificing clarity.

With these foundational concepts in place, you now possess a sturdy base for constructing robust Angular tests with Vitest.

In forthcoming articles, we'll expand on this knowledge and delve into Angular-specific testing patterns.

Subscribe to my newsletter if you'd like the subsequent articles delivered to your inbox: