PresentationInteractive, narrated presentation of the section content.
ContentDetailed description of the section content.
Spying and Mocking with Vitest
Part 1: Spies
1.1 Motivation and Misconception
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.
1.2 Simple Example: Spying in the Blog App
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.spyOncreates a spy onconsole.error.- The spy tracks the call when we run
updateLikes(postId: number, likes: number). - We check that
console.errorwas not called.
1.3 Vitest Spy Matchers
Vitest provides several matchers to inspect spies. Let’s explore each with small examples, imagining scenarios in our blogging app.
<strong>toHaveBeenCalled</strong>:- Use: Verify a function was called at least once.
- Example: Ensure a logging function runs when a user views a post.
it("logs post view", () => {
const logView = vi.spyOn(console, "log");
console.log("Post viewed");
expect(logView).toHaveBeenCalled();
});
<strong>toHaveBeenCalledTimes</strong>:- Use: Check the exact number of calls, useful for repeated actions like retry logic.
- Example: Test that a retry function calls
getPostsByTagtwice on failure.
it("retries getPostsByTag twice", () => {
const spy = vi.spyOn({ getPostsByTag }, "getPostsByTag");
getPostsByTag("tech");
getPostsByTag("tech");
expect(spy).toHaveBeenCalledTimes(2);
});
<strong>toHaveBeenCalledWith</strong>:- Use: Confirm arguments match expectations, critical for functions with dynamic inputs.
- Example: Verify an admin function formats tags correctly.
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>:- Use: Check the arguments of the last call, great for sequences of actions.
- Example: Test that a sorting function ends with the right criterion.
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>:- Use: Ensure the function completed without throwing, useful for error-prone code.
- Example: Confirm a risky parsing function runs successfully.
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.
1.4 Complex Example: Spying on Admin Actions
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:
- We spy on
updatePostfrom thepostsmodule. bulkUpdateTagscallsupdatePostfor each ID.- We use multiple matchers to verify the number of calls, arguments, and the last call.
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.
1.5 Takeaways, Best Practices, Pitfalls
- Takeaways:
- Spies observe functions without changing them, perfect for checking behavior.
- Use matchers like
toHaveBeenCalledWithandtoHaveBeenCalledTimesto verify specific interactions. - In our app, spies ensured
getPostsByTagandupdatePostwere called correctly.
- Best Practices:
- Spy only on functions you need to verify to keep tests focused.
- Use
vi.restoreAllMocks()after tests to avoid spy interference. - Combine matchers for thorough checks, as in the bulk update example.
- Pitfalls:
- Over-spying: Spying on too many functions makes tests brittle. Focus on key interactions.
- Ignoring errors: If a spied function throws, your test might pass unexpectedly—use
toHaveReturned. - Module issues: Spying on imported functions requires correct module syntax (e.g.,
vi.spyOn(posts, "updatePost")).
Part 2: Mocks
2.1 Motivation and Misconception
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.
2.2 Simple Example: Mocking in the Blog App
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.mockedensures TypeScript safety.mockResolvedValuemakes the async function return our fake posts.- The test checks the output without hitting a database.
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.
2.3 How to Mock
Vitest offers several ways to mock. Let’s explore them with our blogging app in mind.
- (i) Object from Local Import:
- Mock a function from a local module, like a validator in our app.
- Example: Mock
validatePostused 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);
});
});
- (ii) Object from Node Module Import:
- Mock a third-party library, like a database client.
- Example: Mock a
db-clientpackage.
// 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" }]),
}));
- (iii) Whole Imports:
- Replace an entire module, useful for complex dependencies.
- Example: Mock the entire
utils/postsmodule.
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.
2.4 Mocking Options
Mocks can simulate various behaviors. Here’s how:
- Calls:
- Track calls like spies, using
mock.calls. - Example: Check arguments for a mocked
savePost.
- Track calls like spies, using
vi.mocked(savePost).mockReturnValue({ id: 42 });
savePost({ title: "Test", tag: "tech" });
expect(savePost.mock.calls[0][0]).toEqual({ title: "Test", tag: "tech" });
- Functions:
- Define custom logic in mocks.
- Example: Mock a tag formatter.
vi.mocked(formatTag).mockImplementation((tag: string) => tag.toUpperCase());
expect(formatTag("tech")).toBe("TECH");
- Return Values:
- Set specific outputs.
- Example: Mock post data.
vi.mocked(getPostsByTag).mockReturnValue([{ id: 1, title: "Post" }]);
- Promises (Resolved/Rejected):
- Simulate async success or failure.
- Example: Mock a failed save.
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.
2.5 Vitest Mock Matchers
Mocks use the same matchers as spies, but let’s highlight their use with mocks, focusing on control:
<strong>toHaveBeenCalled</strong>:- Use: Verify the mock was triggered.
- Example: Ensure a mocked logger runs.
it("logs with mock", () => {
const log = vi.fn();
log("Post saved");
expect(log).toHaveBeenCalled();
});
<strong>toHaveBeenCalledTimes</strong>:- Use: Check call frequency for mocked retries.
- Example: Test a mocked retry function.
it("retries mock twice", () => {
const retry = vi.fn();
retry();
retry();
expect(retry).toHaveBeenCalledTimes(2);
});
<strong>toHaveBeenCalledWith</strong>:- Use: Verify mock input, critical for data validation.
- Example: Check mocked validator input.
it("validates mock post", () => {
const validate = vi.fn();
validate({ title: "Test", tag: "tech" });
expect(validate).toHaveBeenCalledWith({ title: "Test", tag: "tech" });
});
<strong>toHaveReturnedWith</strong>:- Use: Check mock output, great for testing return values.
- Example: Verify mocked post data.
it("returns mocked post", () => {
const getPost = vi.fn().mockReturnValue({ id: 1 });
getPost();
expect(getPost).toHaveReturnedWith({ id: 1 });
});
<strong>toHaveRejectedWith</strong>(for async):- Use: Test error handling in mocks.
- Example: Mock a failed query.
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.
2.6 Complex Example: Mocking Admin Save
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:
- We mock
validatePostto control validation. - We mock
savePostto return a fake saved post. - We test both success (valid post) and failure (invalid post) cases.
- Matchers verify calls and outputs.
Why It’s Complex: This tests a full admin workflow, combining multiple mocks, async behavior, and error handling, mirroring real-world usage.
2.7 Takeaways, Best Practices, Pitfalls
- Takeaways:
- Mocks replace real code with controlled versions, ideal for isolating tests.
- Mock functions, modules, or properties to simulate any scenario.
- In our app, mocks ensured
getPostsByTagandcreatePosttests ran without databases.
- Best Practices:
- Mock only external or unpredictable dependencies.
- Use
vi.mockedfor TypeScript safety. - Clear mocks with
vi.restoreAllMocks()to prevent test pollution. - Test both success and failure cases, as in the admin example.
- Pitfalls:
- Over-mocking: Mocking too much can make tests unrealistic—mock only what’s necessary.
- Forgetting async: Mocking promises incorrectly (e.g., using
mockReturnValueinstead ofmockResolvedValue) breaks async tests. - Module leaks: Unmocked modules can affect other tests—always scope mocks to specific tests.
No hints available.