examples/tutorials/stubbing.md
Stubbing is a powerful technique for isolating the code you're testing by replacing functions with controlled implementations. While spies monitor function calls without changing behavior, stubs go a step further by completely replacing the original implementation, allowing you to simulate specific conditions or behaviors during testing.
Stubs are fake implementations that replace real functions during testing. They let you:
Deno provides robust stubbing capabilities through the Standard Library's testing tools.
Here's a simple example demonstrating how to stub a function:
import { assertEquals } from "jsr:@std/assert";
import { stub } from "jsr:@std/testing/mock";
// Wrap dependencies so they can be stubbed safely from tests.
const deps = {
getUserName(_id: number): string {
// In a real app, this might call a database
return "Original User";
},
};
// Function under test
function greetUser(id: number): string {
const name = deps.getUserName(id);
return `Hello, ${name}!`;
}
Deno.test("greetUser with stubbed getUserName", () => {
// Create a stub that returns a controlled value
const getUserNameStub = stub(deps, "getUserName", () => "Test User");
try {
// Test with the stubbed function
const greeting = greetUser(123);
assertEquals(greeting, "Hello, Test User!");
} finally {
// Always restore the original function
getUserNameStub.restore();
}
});
In this example, we:
getUserName function that returns "Test User" instead
of calling the real implementationLet's look at a more practical example with a UserRepository class that
interacts with a database:
import { assertSpyCalls, returnsNext, stub } from "jsr:@std/testing/mock";
import { assertThrows } from "jsr:@std/assert";
type User = {
id: number;
name: string;
};
// This represents our database access layer
const database = {
getUserById(id: number): User | undefined {
// In a real app, this would query a database
return { id, name: "Ada Lovelace" };
},
};
// The class we want to test
class UserRepository {
static findOrThrow(id: number): User {
const user = database.getUserById(id);
if (!user) {
throw new Error("User not found");
}
return user;
}
}
Deno.test("findOrThrow method throws when the user was not found", () => {
// Stub the database.getUserById function to return undefined
using dbStub = stub(database, "getUserById", returnsNext([undefined]));
// We expect this function call to throw an error
assertThrows(() => UserRepository.findOrThrow(1), Error, "User not found");
// Verify the stubbed function was called once
assertSpyCalls(dbStub, 1);
});
In this example:
findOrThrow method, which should throw an error when a
user is not founddatabase.getUserById to return undefined, simulating a missing
userfindOrThrow throws the expected errorNote that we're using the using keyword with stub, which is a convenient way
to ensure the stub is automatically restored when it goes out of scope.
Sometimes you want a stub to return different values each time it's called:
import { returnsNext, stub } from "jsr:@std/testing/mock";
import { assertEquals } from "jsr:@std/assert";
Deno.test("stub with multiple return values", () => {
const dataService = {
fetchData: () => "original data",
};
const fetchDataStub = stub(
dataService,
"fetchData",
// Return these values in sequence
returnsNext(["first result", "second result", "third result"]),
);
try {
assertEquals(dataService.fetchData(), "first result");
assertEquals(dataService.fetchData(), "second result");
assertEquals(dataService.fetchData(), "third result");
} finally {
fetchDataStub.restore();
}
});
You can also provide custom logic in your stub implementations:
import { stub } from "jsr:@std/testing/mock";
import { assertEquals } from "jsr:@std/assert";
Deno.test("stub with custom implementation", () => {
// Create a counter to track how many times the stub is called
let callCount = 0;
const mathService = {
calculate: (a: number, b: number) => a + b,
};
const calculateStub = stub(
mathService,
"calculate",
(a: number, b: number) => {
callCount++;
return a + b * 2; // Custom implementation
},
);
try {
const result = mathService.calculate(5, 10);
assertEquals(result, 25); // 5 + (10 * 2)
assertEquals(callCount, 1);
} finally {
calculateStub.restore();
}
});
One of the most common uses of stubs is to replace API calls during testing:
import { assertEquals } from "jsr:@std/assert";
import { stub } from "jsr:@std/testing/mock";
const apiClient = {
fetch: globalThis.fetch,
};
async function fetchUserData(id: string) {
const response = await apiClient.fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`);
}
return await response.json();
}
Deno.test("fetchUserData with stubbed fetch", async () => {
const mockResponse = new Response(
JSON.stringify({ id: "123", name: "Jane Doe" }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
// Replace apiClient.fetch with a stubbed version
const fetchStub = stub(
apiClient,
"fetch",
() => Promise.resolve(mockResponse),
);
try {
const user = await fetchUserData("123");
assertEquals(user, { id: "123", name: "Jane Doe" });
} finally {
fetchStub.restore();
}
});
Always restore stubs: Use try/finally blocks or the using keyword to
ensure stubs are restored, even if tests fail.
Use stubs for external dependencies: Stub out database calls, API requests, or file system operations to make tests faster and more reliable.
Keep stubs simple: Stubs should return predictable values that let you test specific scenarios.
Combine with spies when needed: Sometimes you need to both replace functionality (stub) and track calls (spy).
Stub at the right level: Stub at the interface boundary rather than deep within implementation details.
š¦ Stubs are a powerful tool for isolating your code during testing, allowing you to create deterministic test environments and easily test edge cases. By replacing real implementations with controlled behavior, you can write more focused, reliable tests that run quickly and consistently.
For more testing resources, check out: