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.spyOn creates a spy on console.error.
  • The spy tracks the call when we run updateLikes(postId: number, likes: number).
  • We check that console.error was 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 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>:
    • 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 updatePost from the posts module.
  • bulkUpdateTags calls updatePost for 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 toHaveBeenCalledWith and toHaveBeenCalledTimes to verify specific interactions.
    • In our app, spies ensured getPostsByTag and updatePost were 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.mocked ensures TypeScript safety.
  • mockResolvedValue makes 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 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);
  });
});
  • (ii) Object from Node Module Import:
    • Mock a third-party library, like a database client.
    • Example: Mock a 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" }]),
}));
  • (iii) Whole Imports:
    • Replace an entire module, useful for complex dependencies.
    • Example: Mock the entire 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.

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.
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 validatePost to control validation.
  • We mock savePost to 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 getPostsByTag and createPost tests ran without databases.
  • Best Practices:
    • Mock only external or unpredictable dependencies.
    • Use vi.mocked for 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 mockReturnValue instead of mockResolvedValue) breaks async tests.
    • Module leaks: Unmocked modules can affect other tests—always scope mocks to specific tests.

No hints available.

Discuss with OthersAsk questions, share your thoughts, and discuss with other learners.

Loading discussion ...