Slide 1
-----------
Part 1: 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!
Slide 2
-----------
Part 1: 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.
Part 2: 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.
Part 3: 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.
Part 4: 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.
Part 5: 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.
Slide 3
-----------
Part 1: 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.
Part 2: First, we import the function getPostsByTag that we are going to test.
Part 3: Then, we import the prisma client, which we will be spying on to make sure it is used correctly.
Part 4: 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.
Part 5: We arranged our test case, it is time to act. We call getPostsByTag with the tech parameter.
Part 6: We acted, time to assert. This time, we check whether the spy has been called with the expected parameter, filtering for the tag "tech".
Slide 4
-----------
Part 1: 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.
Slide 5
-----------
Part 1: toHaveBeenCalledTimes matcher ensures exact call count, great for retry logic in failed queries or more complex computations.
Slide 6
-----------
Part 1: toHaveBeenCalledWith verifies arguments. It checks all action calls and versifies whether at least one has been called with these arguments
Part 2: Check how we inverted the matcher to negative value, expecting the function not to be called with given parameters
Slide 7
-----------
Part 1: toHaveBeenLastCalledWith checks the last call’s arguments, useful for sequences. In this example, it verifies the final sort, relevant for UI updates.
Slide 8
-----------
Part 1: 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.
Part 2: Spy on `updatePost` to track calls during bulk updates, ensuring we monitor the real database logic.
Part 3: Run bulk update for two posts, simulating an admin action. The spy logs each call.
Part 4: Verify two calls, confirming every post was processed.
Part 5: Check first post’s update, ensuring correct data
Part 6: Confirm last call, validating the sequence.
Slide 9
-----------
Part 1: Spies watch functions to log calls, arguments, and frequency, ideal for verifying interactions like filtering posts.
Part 2: 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.
Part 3: As a common pitfall, avoid over-spying—it makes tests brittle. Don’t ignore type errors and ensure correct module spying syntax.
Slide 10
-----------
Part 1: 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.
Part 2: 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.
Part 3: 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.
Slide 11
-----------
Part 1: 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.
Part 2: 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.
Part 3: We call the getPostsByTag function, testing how our app handles the response.
Part 4: Last, we verify the output, ensuring our UI would display the right posts.
Slide 12
-----------
Part 1: So, what and how can we mock?
Part 2: We can mock local imports, like a post validator, to test logic in isolation.
Part 3: 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.
Part 4: We can mock selected imported libraries like a database client to avoid real queries.
Part 5: 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!
Slide 13
-----------
Part 1: But what can you do with mocks?
Part 2: You can track mock calls like spies and check arguments. You can use the same matchers as with spies, such as toHaveBeenCalledWith.
Part 3: 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.
Part 4: In our last example, you can control outputs or simulate async success/failure.
Slide 14
-----------
Part 1: 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.
Part 2: Let's arrange our test. First, we will spy on the validatePost call, but mocking the return value, specifying that the post is valid.
Part 3: Next, we spy on the savePost methods and again mock the return value with the fake data of the new post.
Part 4: Next, we act and call the subject under the test, which is the createPost method.
Part 5: 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.
Part 6: 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.
Part 7: Last, we'll check whether the return value of the savePost function is the expected mocked value.
Slide 15
-----------
Part 1: 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.
Part 2: 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.
Part 3: 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.
Part 4: 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!
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.
Maggie is a generative AI that can help you understand the course content better. You can ask her questions about the lecture, and she will try to answer them. You can also see the questions asked by other students and her responses.
Join the discussion to ask questions, share your thoughts, and discuss with other learners