Imagine you’re building a blogging app where users filter posts by tags, and admins add new posts. You need to test that your code behaves correctly—like ensuring a function fetches posts when a user clicks “tech” as a tag. But how do you verify a function was called without changing what it does? That’s where spies in Vitest shine.
Spies let you “watch” a function, tracking if it’s called, with what arguments, and how many times. They’re like a security camera for your code—observing without interfering. In our app, we might spy on a getPostsByTag
function to confirm it’s called with “tech” when a user filters posts.
Why It Matters: Testing without spies means you’re often stuck checking outputs, which might depend on databases or APIs. Spies focus on behavior—did the function run as expected? This keeps tests fast and focused.
Common Misconception: Students often think, “If I’m spying, I’m not testing the real function, so my test is fake!” Not true. Spies don’t alter the function—they just record what happens. The misconception comes from confusing spies with mocks (we’ll cover those later). Spies let the real code run, ensuring you’re testing actual behavior, not a fake version.
Let’s dive into our blogging app to see spies in action.
We want to test that it’s called correctly when a user selects a tag. Here’s a test:
// tests/posts.test.ts
import { describe, it, expect, vi } from "vitest";
import { updateLikes } from "../src/utils/posts";
describe("updateLikes", () => {
it("updatesLikes and saves to database with no errors", () => {
const spy = vi.spyOn(console, "error");
updateLikes(1, 3);
expect(spy).not.toHaveBeenCalled();
});
});
What’s Happening:
vi.spyOn
creates a spy on console.error
.updateLikes(postId: number, likes: number)
.console.error
was not called.Vitest provides several matchers to inspect spies. Let’s explore each with small examples, imagining scenarios in our blogging app.
<strong>toHaveBeenCalled</strong>
:it("logs post view", () => {
const logView = vi.spyOn(console, "log");
console.log("Post viewed");
expect(logView).toHaveBeenCalled();
});
<strong>toHaveBeenCalledTimes</strong>
:getPostsByTag
twice on failure.it("retries getPostsByTag twice", () => {
const spy = vi.spyOn({ getPostsByTag }, "getPostsByTag");
getPostsByTag("tech");
getPostsByTag("tech");
expect(spy).toHaveBeenCalledTimes(2);
});
<strong>toHaveBeenCalledWith</strong>
:it("calls formatTag with uppercase", () => {
const formatTag = vi.fn((tag: string) => tag.toUpperCase());
const spy = vi.spyOn({ formatTag }, "formatTag");
formatTag("tech");
expect(spy).toHaveBeenCalledWith("tech");
});
<strong>toHaveBeenLastCalledWith</strong>
:it("sorts by date last", () => {
const sortPosts = vi.fn();
const spy = vi.spyOn({ sortPosts }, "sortPosts");
sortPosts("tag");
sortPosts("date");
expect(spy).toHaveBeenLastCalledWith("date");
});
<strong>toHaveReturned</strong>
:it("parses post data", () => {
const parsePost = vi.fn(() => ({ id: 1 }));
const spy = vi.spyOn({ parsePost }, "parsePost");
parsePost();
expect(spy).toHaveReturned();
});
Why These Matter: Each matcher lets you focus on a specific aspect of behavior. In our app, toHaveBeenCalledWith
ensures correct tag filtering, while toHaveBeenCalledTimes
might verify retry logic for failed queries.
Now let’s test an admin feature in our app. Admins can bulk-update post tags, and we want to ensure the update function is called correctly for each post. Here’s the code:
// src/admin.ts
import { updatePost } from "./utils/posts";
export function bulkUpdateTags(postIds: number[], newTag: string) {
postIds.forEach((id) => updatePost(id, { tag: newTag }));
}
// src/utils/posts.ts
export function updatePost(id: number, data: { tag?: string }) {
// Updates database in real app
return { id, ...data };
}
We’ll spy on updatePost
to verify it’s called for each post ID:
// tests/admin.test.ts
import { describe, it, expect, vi } from "vitest";
import { bulkUpdateTags } from "../src/admin";
import * as posts from "../src/utils/posts";
describe("bulkUpdateTags", () => {
it("updates tags for multiple posts", () => {
const spy = vi.spyOn(posts, "updatePost");
bulkUpdateTags([1, 2, 3], "news");
expect(spy).toHaveBeenCalledTimes(3);
expect(spy).toHaveBeenCalledWith(1, { tag: "news" });
expect(spy).toHaveBeenCalledWith(2, { tag: "news" });
expect(spy).toHaveBeenCalledWith(3, { tag: "news" });
expect(spy).toHaveBeenLastCalledWith(3, { tag: "news" });
});
});
What’s Happening:
updatePost
from the posts
module.bulkUpdateTags
calls updatePost
for each ID.Why It’s Complex: This tests a loop, multiple calls, and dynamic arguments, mimicking real admin workflows. It ensures our bulk update feature works without touching a database.
toHaveBeenCalledWith
and toHaveBeenCalledTimes
to verify specific interactions.getPostsByTag
and updatePost
were called correctly.vi.restoreAllMocks()
after tests to avoid spy interference.toHaveReturned
.vi.spyOn(posts, "updatePost")
).Now imagine you’re testing the blogging app’s post-saving feature. The admin clicks “Save,” and the post goes to a database. But during tests, you don’t want to hit a real database—it’s slow, might fail, or even overwrite real data! Mocks in Vitest let you replace real code with fake versions you control.
Mocks are like stunt doubles in movies—they stand in for the real thing, letting you test safely. In our app, we might mock a database query to return fake posts or simulate errors, ensuring our tests focus on our logic, not external systems.
Why It Matters: Mocks make tests fast, predictable, and isolated. Without them, testing our app’s admin panel or post filters would depend on servers being up, which is a recipe for flaky tests.
Common Misconception: Students often think, “Mocking means I’m not testing my real app—it’s all fake!” Wrong! Mocking isolates your code so you can test it, not the database or API. The misconception comes from over-mocking, where you fake too much and lose confidence in the test. We’ll show how to mock just enough to test effectively.
Let’s test a function that fetches posts by tag, but we don’t want to query a real database:
// src/utils/posts.ts
export async function getPostsByTag(tag: string) {
// Queries database in real app
return [{ id: 1, title: "Tech Post", tag }];
}
Here’s a test mocking the function:
// tests/posts.test.ts
import { describe, it, expect, vi } from "vitest";
import { getPostsByTag } from "../src/utils/posts";
describe("getPostsByTag", () => {
it("returns mocked posts", async () => {
vi.mocked(getPostsByTag).mockResolvedValue([
{ id: 1, title: "Mocked Post", tag: "tech" },
]);
const posts = await getPostsByTag("tech");
expect(posts).toEqual([{ id: 1, title: "Mocked Post", tag: "tech" }]);
});
});
What’s Happening:
vi.mocked
ensures TypeScript safety.mockResolvedValue
makes the async function return our fake posts.Why It’s Real: This mimics a user filtering posts by tag, letting us test the app’s logic (e.g., displaying posts) without relying on a real server.
Vitest offers several ways to mock. Let’s explore them with our blogging app in mind.
validatePost
used in the admin panel.// src/utils/validate.ts
export function validatePost(post: { title: string; tag: string }) {
return post.title.length > 0 && post.tag.length > 0;
}
// tests/validate.test.ts
import { describe, it, expect, vi } from "vitest";
import * as validate from "../src/utils/validate";
vi.spyOn(validate, "validatePost").mockReturnValue(true);
describe("validatePost", () => {
it("mocks local validatePost", () => {
const post = { title: "", tag: "" };
expect(validate.validatePost(post)).toBe(true);
});
});
db-client
package.// src/utils/db.ts
import { query } from "db-client";
export function getPostsByTag(tag: string) {
return query(`SELECT * FROM posts WHERE tag = ?`, [tag]);
}
vi.mock("db-client", () => ({
query: vi.fn().mockReturnValue([{ id: 1, title: "DB Post", tag: "tech" }]),
}));
utils/posts
module.vi.mock("../src/utils/posts", () => ({
getPostsByTag: vi.fn().mockReturnValue([
{ id: 1, title: "Module Post", tag: "tech" },
]),
}));
Why These Matter: In our app, mocking local validators ensures admin tests focus on saving logic. Mocking node_modules
avoids real database calls. Mocking whole modules simplifies testing complex dependencies.
Mocks can simulate various behaviors. Here’s how:
mock.calls
.savePost
.vi.mocked(savePost).mockReturnValue({ id: 42 });
savePost({ title: "Test", tag: "tech" });
expect(savePost.mock.calls[0][0]).toEqual({ title: "Test", tag: "tech" });
vi.mocked(formatTag).mockImplementation((tag: string) => tag.toUpperCase());
expect(formatTag("tech")).toBe("TECH");
vi.mocked(getPostsByTag).mockReturnValue([{ id: 1, title: "Post" }]);
vi.mocked(savePost).mockRejectedValue(new Error("DB error"));
await expect(savePost({ title: "Test", tag: "tech" })).rejects.toThrow(
"DB error"
);
Why These Matter: These options let us test every scenario in our app—successful saves, failed queries, or custom formatting—without real dependencies.
Mocks use the same matchers as spies, but let’s highlight their use with mocks, focusing on control:
<strong>toHaveBeenCalled</strong>
:it("logs with mock", () => {
const log = vi.fn();
log("Post saved");
expect(log).toHaveBeenCalled();
});
<strong>toHaveBeenCalledTimes</strong>
:it("retries mock twice", () => {
const retry = vi.fn();
retry();
retry();
expect(retry).toHaveBeenCalledTimes(2);
});
<strong>toHaveBeenCalledWith</strong>
:it("validates mock post", () => {
const validate = vi.fn();
validate({ title: "Test", tag: "tech" });
expect(validate).toHaveBeenCalledWith({ title: "Test", tag: "tech" });
});
<strong>toHaveReturnedWith</strong>
:it("returns mocked post", () => {
const getPost = vi.fn().mockReturnValue({ id: 1 });
getPost();
expect(getPost).toHaveReturnedWith({ id: 1 });
});
<strong>toHaveRejectedWith</strong>
(for async):it("handles mock error", async () => {
const query = vi.fn().mockRejectedValue(new Error("Failed"));
await expect(query()).rejects.toThrow("Failed");
});
Why These Matter: Matchers let us verify our mocks behave as expected, ensuring our app handles mocked data or errors correctly.
Let’s test an admin feature to save posts, mocking both the database and a validator:
// src/admin.ts
import { savePost } from "./utils/posts";
import { validatePost } from "./utils/validate";
export async function createPost(post: { title: string; tag: string }) {
if (!validatePost(post)) throw new Error("Invalid post");
return savePost(post);
}
// src/utils/posts.ts
export async function savePost(post: { title: string; tag: string }) {
// Saves to database
return { id: 42, ...post };
}
// src/utils/validate.ts
export function validatePost(post: { title: string; tag: string }) {
return post.title.length > 0 && post.tag.length > 0;
}
Here’s the test:
// tests/admin.test.ts
import { describe, it, expect, vi } from "vitest";
import { createPost } from "../src/admin";
import * as posts from "../src/utils/posts";
import * as validate from "../src/utils/validate";
describe("createPost", () => {
it("saves valid post with mocks", async () => {
vi.spyOn(validate, "validatePost").mockReturnValue(true);
vi.spyOn(posts, "savePost").mockResolvedValue({
id: 42,
title: "Test Post",
tag: "tech",
});
const post = { title: "Test Post", tag: "tech" };
const result = await createPost(post);
expect(validate.validatePost).toHaveBeenCalledWith(post);
expect(posts.savePost).toHaveBeenCalledWith(post);
expect(result).toEqual({ id: 42, title: "Test Post", tag: "tech" });
});
it("rejects invalid post", async () => {
vi.spyOn(validate, "validatePost").mockReturnValue(false);
await expect(createPost({ title: "", tag: "" })).rejects.toThrow(
"Invalid post"
);
});
});
What’s Happening:
validatePost
to control validation.savePost
to return a fake saved post.Why It’s Complex: This tests a full admin workflow, combining multiple mocks, async behavior, and error handling, mirroring real-world usage.
getPostsByTag
and createPost
tests ran without databases.vi.mocked
for TypeScript safety.vi.restoreAllMocks()
to prevent test pollution.mockReturnValue
instead of mockResolvedValue
) breaks async tests.Get ready to master spying and mocking with Vitest! In our blogging app, spies will catch every function call, while mocks fake databases for lightning-fast tests. Say goodbye to flaky dependencies and hello to reliable code—let’s dive into testing like pros!
Testing real-world apps like our blogging platform is tricky when code hits databases or APIs. Spies watch functions to check their behavior—how many calls, what arguments—while mocks replace real code with fake versions we control. This keeps tests fast, reliable, and focused on our logic, not external systems.
Some think spying or mocking makes tests “fake.” Not true! Spies observe real code, and mocks isolate your logic, ensuring you test what matters without flaky dependencies.
In Vitest, spies track function calls without changing what the function does. They’re like a logbook, recording how many times a function ran and with what arguments, perfect for verifying user interactions in our app.
Suppose a user filters posts by tag. We need to ensure our getPostsByTag function is called correctly. Spies let us check this without worrying about what the function returns, focusing on behavior.
Students often think spies fake the function. Nope! Spies let the real code run—they just watch it. Confusing spies with mocks leads to this error, but we’ll clarify both today.
Let’s test a function that fetches posts by tag in our blog app. We’ll spy on it to ensure it’s called with the right tag when a user filters posts, a real scenario for our UI. The application uses Prisma ORM.
First, we import the function getPostsByTag that we are going to test.
Then, we import the prisma client, which we will be spying on to make sure it is used correctly.
We need to arrange and prepare objects before we can run the test. In this line, we create the spy, observing calls of findMany on "prisma.posts". Thus, whenever "prisma.posts.findMany" is called, we will know about it.
We arranged our test case, it is time to act. We call getPostsByTag with the tech parameter.
We acted, time to assert. This time, we check whether the spy has been called with the expected parameter, filtering for the tag "tech".
Vitest offers matchers to inspect spies. Each checks a specific aspect of a function’s behavior, letting us verify exactly what happened in our app. toHaveBeenCalled matcher checks if a function was called at least once. Useful for confirming actions like logging a post view.
toHaveBeenCalledTimes matcher ensures exact call count, great for retry logic in failed queries or more complex computations.
toHaveBeenCalledWith verifies arguments. It checks all action calls and versifies whether at least one has been called with these arguments
Check how we inverted the matcher to negative value, expecting the function not to be called with given parameters
toHaveBeenLastCalledWith checks the last call’s arguments, useful for sequences. In this example, it verifies the final sort, relevant for UI updates.
Let's take a look at another example. Admins in our app can update tags for multiple posts. We’ll spy on the updatePost function to verify it’s called correctly for each post, a complex but realistic admin task. It’s interactive, showing how spies handle loops and dynamic data.
Spy on `updatePost` to track calls during bulk updates, ensuring we monitor the real database logic.
Run bulk update for two posts, simulating an admin action. The spy logs each call.
Verify two calls, confirming every post was processed.
Check first post’s update, ensuring correct data
Confirm last call, validating the sequence.
Spies watch functions to log calls, arguments, and frequency, ideal for verifying interactions like filtering posts.
Spy only key functions, and use matchers wisely. You can also remove spies from functions with vi.restoreAllMocks() or "spy.restore()" to avoid test leaks.
As a common pitfall, avoid over-spying—it makes tests brittle. Don’t ignore type errors and ensure correct module spying syntax.
We've covered spies, let's talk about mocks! Mocks swap real functions or modules with fakes we control, letting us simulate databases or errors without real dependencies and complex, long-running or asynchronous functions.
Testing our app’s post-saving feature shouldn’t hit a real database—it’s slow and risky. Mocks fake the database, letting us test save logic safely.
Some think mocking fakes the whole app. Wrong! Mocks isolate your code, testing only your logic, not external systems. Over-mocking is the real issue.
Let's take a look at an example. where we mock a function to return fake posts, testing our blog’s tag filter without a database, a common need for fast tests.
On this line, we use "vi.mock" to mock the findMany function on "prisma.posts". We know, that getPostsByTag" function uses prisma to read posts from the database. Thus, we use the mockResolvedValue function to return fake posts asynchronously, simulating a database query for our filter feature. Since Prisma is type-safe, if we change the underlying data structure, this test will fail when during compilation. Therefore, it is perfectly safe to mock it.
We call the getPostsByTag function, testing how our app handles the response.
Last, we verify the output, ensuring our UI would display the right posts.
So, what and how can we mock?
We can mock local imports, like a post validator, to test logic in isolation.
Using the "vi.mocked" helper, we can mock local target functions, class instances, and objects, creating a type-safe wrapper that allows you to mock portions of the given target.
We can mock selected imported libraries like a database client to avoid real queries.
We can even replace entire modules for complex dependencies, simplifying tests. Vitest has a specific functionality where you cave module mocks in independent files and use them during testing. Check out the documentation!
But what can you do with mocks?
You can track mock calls like spies and check arguments. You can use the same matchers as with spies, such as toHaveBeenCalledWith.
You can even define custom mock logic, such as formatting tags. Please use this with caution, as it is primarily intended for the early stages of Test Driven Development. You should gradually remove these calls from your test cases as your application matures.
In our last example, you can control outputs or simulate async success/failure.
Let's take a look at a more complex example, where we mock a validator and database save to test the admin’s post creation, representing a complex flow with validation and async logic.
Let's arrange our test. First, we will spy on the validatePost call, but mocking the return value, specifying that the post is valid.
Next, we spy on the savePost methods and again mock the return value with the fake data of the new post.
Next, we act and call the subject under the test, which is the createPost method.
It's time to assert whether everything went as expected. First, we check whether the post validation was called with the post data we created.
Then, we check whether the savePost was called with the same data. Imagine that in another test, we would mock the validation's return value to false and then check that the savePost was not called.
Last, we'll check whether the return value of the savePost function is the expected mocked value.
In our last example, we've mocked quite a lot here, so when should we mock and when not? The golden rule is to mock everything that slows down your unit tests or is challenging to set up for unit test execution, such as external API calls and services.
So, mock when your code depends on external systems like databases or APIs. In our blog app, mock savePost to avoid real database calls, ensuring tests are fast and reliable.
Avoid mocking your app’s internal logic, like a post validator. Test real code to catch bugs—mocking too much hides real behavior. You can mock internals if they perform slow computations or heavy operations hindering speed of your unit tests.
Overmocking is like faking every function, makes tests unrealistic. If we mock all post logic, we’re not testing the app, just our mocks. Mock only what’s necessary. And keep it type safe! That's it for today lecture, go mock!