Typed mocks, satisfies, and unknown instead of loose test data
Test code often gets a free pass on type discipline because deadlines are tight, a mock gets copy-pasted, or it is supposedly just a test. Yet tests are exactly where type regressions in the code under test need to be caught reliably. This article covers practical typed mocking patterns using vi.mocked, satisfies, and mock factories, plus unknown and zod as replacements for any in loosely shaped test data.
Table of Contents
- 1. Why test code gets a free pass on type discipline
- 2. Why any in tests is just as risky as any in production code
- 3. Typed mocks with vi.mocked() and Mocked<T>
- 4. satisfies: catching typos without widening the type
- 5. Typed mock factories instead of repeated boilerplate
- 6. unknown and type guards instead of any for loose test data
- 7. Runtime validation with zod for API response fixtures
- 8. When a narrowly scoped any in a test is still pragmatic
- 9. any vs. typed patterns side by side
- 10. Summary
- 11. FAQ
1. Why test code gets a free pass on type discipline
Few areas of a codebase get a free pass on type discipline as often as test code. Under deadline pressure, the fastest route to a green test wins: declare a mock object as any, slap a few methods together with vi.fn(), done. The argument "it's just a test" persists stubbornly even though it contradicts reality: test code gets read, refactored, and extended just as often as production code, often more, since it has to keep pace with every change to the module under test.
A second driver is copy-paste: an existing any-typed mock gets copied for the next test, tweaked slightly, and the type gap multiplies across the entire test suite. After a few months, practically every test file has at least one spot where any is in play, usually unnoticed, because the test still passes. That automatism is the real problem: it doesn't prevent tests from failing, it prevents them from failing for the right reasons.
2. Why any in tests is just as risky as any in production code
The core purpose of a test is to catch regressions in the code under test, including type regressions. If a method on a service gets renamed or its signature changes, the compiler, and by extension the test, should flag it immediately. An any-typed mock defeats exactly that mechanism: the method getTotal gets renamed to calculateTotal, the mock still calls it getTotal, TypeScript stays silent because any skips every check, and the test keeps passing even though it no longer says anything about the real interface.
The result is a false sense of security: the test suite is green, but it no longer tests what it claims to test. In production code, a reviewer would usually question an any declaration. In test code, it often slips through uncommented, because the problem only surfaces once a bug reaches production that the test should have caught. That's exactly why test code deserves the same type discipline as the code it protects, not less.
3. Typed mocks with vi.mocked() and Mocked<T>
The most pragmatic entry point into typed mocks is vi.mocked() from Vitest, with jest.mocked() as its Jest counterpart. The function takes an already typed import and returns it with full autocompletion for the mock methods, no manual type annotation required. For full object mocks, Vitest additionally provides the utility type Mocked<T>, which turns any interface into an object whose methods are all typed as mock functions.
The difference from an any-mock shows up most clearly on a rename: with Mocked<CartService>, the compiler immediately flags an error if the mock object contains a method that no longer exists on the real interface, before every test run, right in the editor. The example below shows the difference between an any-mock that lets a typo slip through unnoticed and the typed version that catches the same mistake at compile time.
// BEFORE: any hides every typo and shape mismatch in the mock
const cartServiceMock: any = {
addItem: vi.fn(),
removeItem: vi.fn(),
getTotall: vi.fn(), // typo: "getTotall" is never flagged
};
test("checkout applies discount", () => {
const result = calculateCheckout(cartServiceMock, discountCode);
expect(result.total).toBe(89.99);
});
// AFTER: typed via vi.mocked() and the Mocked<T> utility type
import { vi, test, expect } from "vitest";
import type { Mocked } from "vitest";
import type { CartService } from "../src/cart-service";
const cartServiceMock: Mocked<CartService> = {
addItem: vi.fn(),
removeItem: vi.fn(),
getTotal: vi.fn().mockReturnValue(99.99),
};
test("checkout applies discount", () => {
const result = calculateCheckout(cartServiceMock, discountCode);
expect(cartServiceMock.getTotal).toHaveBeenCalledOnce();
expect(result.total).toBe(89.99);
});
4. satisfies: catching typos without widening the type
The satisfies operator, available since TypeScript 4.9, solves a problem that neither an explicit type annotation nor as Type handle cleanly: an annotation like const mock: UserRepository = {...} widens the object's type to UserRepository, which loses specific mock details such as concrete mockResolvedValue return values needed for later assertions. as UserRepository, on the other hand, suppresses the check entirely and lets any deviation through unnoticed. satisfies checks the object against the target type while keeping the narrower, actual type of the literal.
In practice that means a typo in a method name gets reported as an excess property at compile time, because TypeScript runs the same excess-property check for satisfies that it runs for a direct assignment. At the same time, IDE autocompletion stays anchored to the concrete mock object, which a plain interface annotation would lose. For test code, that's the ideal combination: structural checking against the real interface without sacrificing the mock's precision.
import { vi } from "vitest";
import type { UserRepository } from "../src/user-repository";
// satisfies checks the literal against UserRepository's shape,
// then keeps the narrower mock type for autocompletion and assertions
const userRepositoryMock = {
findById: vi.fn().mockResolvedValue({ id: "u1", email: "a@example.com" }),
findByEmial: vi.fn(), // TS2353: object literal may only specify known properties
save: vi.fn(),
} satisfies UserRepository;
// userRepositoryMock.findById is still known as a specific Mock instance here,
// not widened to the generic method signature from UserRepository
userRepositoryMock.findById.mockResolvedValueOnce(null);
5. Typed mock factories instead of repeated boilerplate
Writing out typed mocks by hand for every test case quickly turns into repetition, especially when a service has many methods and a given test only needs one of them. A typed mock factory fixes that: a function produces a fully typed default mock object with sensible default implementations and allows targeted overrides via Partial<Mocked<T>>. The caller only writes out the methods relevant to that particular test; the rest stays type safe and behaves as a no-op mock.
The decisive advantage over copy-pasted mocks: if the interface changes, only the factory function needs updating, not every individual test. The compiler immediately flags an override that no longer matches the interface. This structure noticeably reduces boilerplate without giving up type safety, an argument the "but any is faster" objection often overlooks.
import { vi } from "vitest";
import type { Mocked } from "vitest";
interface PricingEngine {
calculate(sku: string, qty: number): number;
applyTax(amount: number): number;
}
// Typed mock factory: sensible defaults, targeted overrides, no repetition
function createPricingEngineMock(
overrides: Partial<Mocked<PricingEngine>> = {},
): Mocked<PricingEngine> {
return {
calculate: vi.fn().mockReturnValue(0),
applyTax: vi.fn().mockReturnValue(0),
...overrides,
};
}
test("applies tax after calculating the base price", () => {
const pricingEngine = createPricingEngineMock({
calculate: vi.fn().mockReturnValue(100),
});
const total = checkoutTotal(pricingEngine, "SKU-1", 2);
expect(pricingEngine.calculate).toHaveBeenCalledWith("SKU-1", 2);
expect(total).toBeGreaterThan(100);
});
6. unknown and type guards instead of any for loose test data
For loosely structured test data, such as fixtures loaded via JSON.parse(), mocked HTTP responses, or data from an untyped external library, unknown is the correct starting type, not any. The crucial difference: unknown allows no access to properties or methods until the type has been explicitly narrowed. That enforced restriction isn't an obstacle, it's documentation: it makes visible exactly which assumption the test is making about the data's shape, and forces that assumption into a checkable form.
A type guard function like isOrderPayload(value): value is OrderPayload takes on that check explicitly and narrows unknown to a concrete type once the structure actually matches. If the check fails, the test throws a meaningful error instead of failing later on an undefined property in a completely different spot. The extra code for the type guard pays off especially when the same fixture shape gets reused across multiple tests.
import { readFileSync } from "node:fs";
// Fixture loaded from disk: the shape is not statically known at this point
function loadFixture(name: string): unknown {
return JSON.parse(readFileSync(`./fixtures/${name}.json`, "utf-8"));
}
interface OrderPayload {
orderId: string;
items: Array<{ sku: string; qty: number }>;
}
function isOrderPayload(value: unknown): value is OrderPayload {
return (
typeof value === "object" &&
value !== null &&
"orderId" in value &&
typeof (value as { orderId: unknown }).orderId === "string" &&
Array.isArray((value as { items: unknown }).items)
);
}
test("processes a valid order fixture", () => {
const raw = loadFixture("order-valid");
if (!isOrderPayload(raw)) {
throw new Error("Fixture order-valid.json does not match OrderPayload");
}
// raw is narrowed to OrderPayload from here on, no cast needed
const result = processOrder(raw);
expect(result.itemCount).toBe(raw.items.length);
});
7. Runtime validation with zod for API response fixtures
Type guards are fine for simple shapes but quickly turn unwieldy once nested objects, arrays, and optional fields enter the picture, exactly the case for realistic API response fixtures. That's where zod pays off: a schema describes the expected structure declaratively, schema.parse() validates an unknown value at runtime, and throws a precise error with a path to the offending property on any mismatch. Via z.infer<typeof schema>, the TypeScript type can be derived directly from the schema without maintaining it twice.
The practical advantage over a plain type guard: zod checks not just structure but also constraints such as enum values, positive numbers, or minimum lengths, things a hand-written guard often misses. Used in tests, a zod validation surfaces when a mocked API response drifts from the real API's actual contract, instead of silently waving that discrepancy through as any and only discovering it in production.
import { z } from "zod";
import { test, expect } from "vitest";
const ApiOrderResponseSchema = z.object({
orderId: z.string(),
status: z.enum(["pending", "shipped", "delivered"]),
items: z.array(
z.object({ sku: z.string(), qty: z.number().int().positive() }),
),
});
type ApiOrderResponse = z.infer<typeof ApiOrderResponseSchema>;
test("parses a mocked API response for order status", async () => {
const response: unknown = await fetchOrderStatusMock("order-42");
// parse() throws a readable error if the mocked payload drifts
// from the schema, catching contract mismatches before production does
const order: ApiOrderResponse = ApiOrderResponseSchema.parse(response);
expect(order.status).toBe("shipped");
});
8. When a narrowly scoped any in a test is still pragmatic
Typed mocks are the default, but not every situation justifies the full effort. A narrowly scoped, single-line any with an explanatory comment remains pragmatically defensible when, for example, a deliberately malformed object gets passed to a function to test its error handling, or when an untyped third-party library marks a boundary where TypeScript can't offer guarantees anyway. In cases like that, the comment documents the deliberate decision and keeps the blast radius to a single line.
any turns into a smell once it becomes the default answer to "I don't feel like figuring out the type right now", scattered across many files, uncommented, often preceded by a // eslint-disable-next-line with no justification. The ESLint rule @typescript-eslint/no-explicit-any combined with targeted, commented exceptions makes that difference measurable: a team that consistently bans any and only permits it with justification has a fundamentally different error culture than one that silently tolerates it.
9. any vs. typed patterns side by side
The table below sets five common testing situations side by side, once solved with any, once with the matching typed pattern.
| Scenario | With any | Typed | Benefit |
|---|---|---|---|
| Mock for a service interface | const mock: any = {...} |
const mock: Mocked<Service> |
Missing/wrong methods surface at compile time |
| Partial mock with a typo | { getTotall: vi.fn() } as any |
{...} satisfies Partial<Service> |
Typo produces a compile error instead of a silent bug |
| Parsed JSON fixture | const data: any = JSON.parse(...) |
unknown + type guard |
Access forces an explicit structural check |
| Mocked API response | return {...} as any |
Schema.parse(response) with zod |
Runtime check surfaces drift from the real API |
| Error object in a catch block | catch (e: any) |
catch (e: unknown) + instanceof |
Prevents access to properties that don't exist |
In all five cases, the extra effort for the typed variant is small, usually one extra line of code or a utility type from Vitest or Jest. The payoff, though, is structural: the compiler and, where needed, a zod runtime check take over control that with any rests entirely on a human.
Mironsoft
Type-safe TypeScript test suites for Magento frontends and headless integrations
Looking for a test suite without any-shaped holes?
We analyze your existing TypeScript tests, identify any-usage that carries real risk, and replace it with typed mocks, satisfies patterns, and zod validation, without making your test suite slower or more cumbersome.
Type safety audit
Systematically catalog any-usage in tests and prioritize by risk
Mock refactoring
Introduce vi.mocked, satisfies, and typed mock factories
CI integration
Anchor tsc --noEmit and no-explicit-any as a gate in your pipeline
10. Summary
Avoiding any in tests isn't a style fix, it's a structural one: tests whose mocks aren't subject to type checking can no longer reliably catch regressions in the code under test, even while staying green. vi.mocked() and the Mocked<T> utility type deliver typed mocks with almost no extra effort compared to an any-mock. satisfies closes the gap between an overly strict type annotation and an overly loose as-cast, checking structure while preserving the precision of the concrete object.
For loosely structured data such as parsed JSON fixtures or mocked API responses, unknown combined with type guards or zod schemas replaces blanket any with an explicit, documented assumption about the data's shape. A narrowly scoped any with a comment remains legitimate in exceptional cases, it only becomes a problem once it turns into the unreflective default answer for every uncertainty in test code.
Avoiding any in Tests - The Essentials at a Glance
Typed mocks
vi.mocked() and Mocked<T> replace any-mocks with almost no extra effort and catch interface changes at compile time.
satisfies over as
Checks mock objects against the real interface while keeping concrete types for assertions and autocompletion.
unknown + guards
Forces an explicit, documented check for parsed JSON fixtures instead of silently assuming any.
zod for API fixtures
Validates mocked responses at runtime against a declarative schema and surfaces drift from the real API.