Skip to content

Commit 01b7f37

Browse files
authored
test: add convenient API for applying custom schema based mocks (#148)
1 parent e67ecd8 commit 01b7f37

File tree

16 files changed

+952
-757
lines changed

16 files changed

+952
-757
lines changed

docs/mocks.md

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ MSW Auto-Mocker
33
- Handlers: `src/mocks/handlers.ts` combines non-schema mocks and auto-generated mocks.
44
- Non-schema mocks: add hand-written handlers in `src/mocks/customHandlers/index.ts`. These take precedence over schema-based mocks.
55
- Auto-generated: `src/mocks/mocker.ts` reads `swagger.json` and creates fixtures under `src/mocks/fixtures` on first run.
6-
- Validation: Loaded fixtures are validated with Ajv; errors log to console.
76

87
Usage
98
- Vitest: tests initialize MSW in `src/mocks/test.setup.ts`. Run `pnpm test`.
@@ -22,5 +21,139 @@ Failure behavior (always strict)
2221
- Invalid fixtures (including empty `{}` when the schema defines properties) respond 500.
2322

2423
Types
25-
- Fixtures default to strict types. Generated modules import response types from `@api/types.gen` and use a `satisfies` clause to ensure compatibility.
24+
- Fixtures use strict types via the `AutoAPIMock` wrapper. Generated modules import response types from `@api/types.gen` and pass them as generic parameters to `AutoAPIMock<T>` for type safety.
2625
- Make sure `tsconfig.json` includes: `"paths": { "@api/*": ["./src/generated/*"] }`.
26+
27+
## Test-Scoped Overrides with AutoAPIMock
28+
29+
Each fixture is wrapped in `AutoAPIMock<T>`, which provides test-scoped override capabilities.
30+
31+
### Fixture Structure
32+
33+
Generated fixtures use named exports with a consistent naming convention:
34+
35+
```typescript
36+
// src/mocks/fixtures/registry_v0_1_servers/get.ts
37+
import type { GetRegistryV01ServersResponse } from "@api/types.gen";
38+
import { AutoAPIMock } from "@mocks";
39+
40+
export const mockedGetRegistryV01Servers = AutoAPIMock<GetRegistryV01ServersResponse>({
41+
servers: [...],
42+
metadata: { count: 15 },
43+
});
44+
```
45+
46+
### Overriding in Tests
47+
48+
Use `.override()` for type-safe response modifications, or `.overrideHandler()` for full control (errors, network failures):
49+
50+
```typescript
51+
import { HttpResponse } from "msw";
52+
import { mockedGetRegistryV01Servers } from "@mocks/fixtures/registry_v0_1_servers/get";
53+
54+
// Type-safe data override
55+
mockedGetRegistryV01Servers.override(() => ({
56+
servers: [],
57+
metadata: { count: 0 },
58+
}));
59+
60+
// Modify default data
61+
mockedGetRegistryV01Servers.override((data) => ({
62+
...data,
63+
servers: data.servers?.slice(0, 3),
64+
}));
65+
66+
// Error responses (use overrideHandler)
67+
mockedGetRegistryV01Servers.overrideHandler(() =>
68+
HttpResponse.json({ error: "Server error" }, { status: 500 })
69+
);
70+
71+
// Network error
72+
mockedGetRegistryV01Servers.overrideHandler(() => HttpResponse.error());
73+
```
74+
75+
Overrides are automatically reset before each test via `beforeEach()` in `src/mocks/test.setup.ts`.
76+
77+
### Reusable Scenarios
78+
79+
Define named scenarios in your fixture for commonly used test states:
80+
81+
```typescript
82+
// src/mocks/fixtures/registry_v0_1_servers/get.ts
83+
import type { GetRegistryV01ServersResponse } from "@api/types.gen";
84+
import { AutoAPIMock } from "@mocks";
85+
import { HttpResponse } from "msw";
86+
87+
export const mockedGetRegistryV01Servers = AutoAPIMock<GetRegistryV01ServersResponse>({
88+
servers: [...],
89+
metadata: { count: 15 },
90+
})
91+
.scenario("empty-servers", (self) =>
92+
self.override(() => ({
93+
servers: [],
94+
metadata: { count: 0 },
95+
})),
96+
)
97+
.scenario("server-error", (self) =>
98+
self.overrideHandler(() =>
99+
HttpResponse.json({ error: "Internal Server Error" }, { status: 500 }),
100+
),
101+
);
102+
```
103+
104+
Then use them in tests:
105+
106+
```typescript
107+
import { MockScenarios } from "@mocks";
108+
import { mockedGetRegistryV01Servers } from "@mocks/fixtures/registry_v0_1_servers/get";
109+
110+
describe("getServers", () => {
111+
it("returns empty array when API returns no servers", async () => {
112+
mockedGetRegistryV01Servers.activateScenario(MockScenarios.EmptyServers);
113+
114+
const servers = await getServers();
115+
expect(servers).toEqual([]);
116+
});
117+
118+
it("throws on 500 server error", async () => {
119+
mockedGetRegistryV01Servers.activateScenario(MockScenarios.ServerError);
120+
121+
await expect(getServers()).rejects.toBeDefined();
122+
});
123+
});
124+
```
125+
126+
### Global Scenario Activation
127+
128+
Use `activateMockScenario` to activate a scenario across all registered mocks at once. This is useful for setting up a consistent state across multiple endpoints, with the option to further customize individual mocks afterwards.
129+
130+
```typescript
131+
import { activateMockScenario, MockScenarios } from "@mocks";
132+
import { mockedGetRegistryV01Servers } from "@mocks/fixtures/registry_v0_1_servers/get";
133+
134+
describe("error handling", () => {
135+
it("shows error page when all APIs fail", async () => {
136+
// Activate "server-error" on all mocks that define it
137+
// Mocks without this scenario will use their default response
138+
activateMockScenario(MockScenarios.ServerError);
139+
140+
// Test that the app handles the error state correctly
141+
render(<App />);
142+
expect(screen.getByText("Something went wrong")).toBeVisible();
143+
});
144+
145+
it("handles partial failures gracefully", async () => {
146+
// Start with all APIs returning errors
147+
activateMockScenario(MockScenarios.ServerError);
148+
149+
// Then reset specific endpoints to use their default response
150+
mockedGetRegistryV01Servers.reset();
151+
152+
// Now only other endpoints return errors, servers endpoint works
153+
render(<Dashboard />);
154+
expect(screen.getByText("Servers loaded")).toBeVisible();
155+
});
156+
});
157+
```
158+
159+
Scenario names are defined in `src/mocks/scenarioNames.ts` via the `MockScenarios` object, which provides autocomplete and JSDoc documentation. Global scenarios are automatically reset before each test via `resetAllAutoAPIMocks()` in the test setup.

src/app/catalog/actions.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { mockedGetRegistryV01Servers } from "@mocks/fixtures/registry_v0_1_servers/get";
2+
import { HttpResponse } from "msw";
3+
import { describe, expect, it } from "vitest";
4+
import { getServers } from "./actions";
5+
6+
// Authentication is mocked globally in vitest.setup.ts:
7+
// - auth.api.getSession returns a mock session
8+
// - getValidOidcToken returns "mock-test-token"
9+
10+
describe("getServers", () => {
11+
it("returns servers from default fixture", async () => {
12+
const servers = await getServers();
13+
14+
expect(servers.length).toBeGreaterThan(0);
15+
expect(servers[0].name).toBe("awslabs/aws-nova-canvas");
16+
});
17+
18+
// Demo: using .activateScenario() for reusable test scenarios
19+
it("returns empty array when using empty-servers scenario", async () => {
20+
mockedGetRegistryV01Servers.activateScenario("empty-servers");
21+
22+
const servers = await getServers();
23+
24+
expect(servers).toEqual([]);
25+
});
26+
27+
// Demo: using .activateScenario() for error scenarios
28+
it("throws on server error scenario", async () => {
29+
mockedGetRegistryV01Servers.activateScenario("server-error");
30+
31+
await expect(getServers()).rejects.toBeDefined();
32+
});
33+
34+
// Demo: using .override() for type-safe response modifications
35+
it("can override response data with type safety", async () => {
36+
mockedGetRegistryV01Servers.override(() => ({
37+
servers: [
38+
{
39+
server: {
40+
name: "test/server",
41+
title: "Test Server",
42+
},
43+
},
44+
],
45+
metadata: { count: 1 },
46+
}));
47+
48+
const servers = await getServers();
49+
50+
expect(servers).toHaveLength(1);
51+
expect(servers[0].name).toBe("test/server");
52+
});
53+
54+
// Demo: using .overrideHandler() for error status codes
55+
it("can use overrideHandler for custom error responses", async () => {
56+
mockedGetRegistryV01Servers.overrideHandler(() =>
57+
HttpResponse.json({ error: "Unauthorized" }, { status: 401 }),
58+
);
59+
60+
await expect(getServers()).rejects.toBeDefined();
61+
});
62+
});

src/lib/auth/__tests__/token.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { HttpResponse, http } from "msw";
22
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
import { server } from "@/mocks/node";
4-
import { getValidOidcToken } from "../token";
54
import type { OidcTokenData } from "../types";
65
import { encrypt } from "../utils";
76

7+
// Unmock @/lib/auth/token to test the real implementation
8+
// (overrides the global mock from vitest.setup.ts)
9+
vi.unmock("@/lib/auth/token");
10+
// Import after unmocking to get the real function
11+
const { getValidOidcToken } = await import("../token");
12+
813
const REFRESH_API_URL = "http://localhost:3000/api/auth/refresh-token";
914

1015
// Mock jose library

src/mocks/autoAPIMock.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import type { HttpResponseResolver, JsonBodyType } from "msw";
2+
import { HttpResponse } from "msw";
3+
import type { MockScenarioName } from "./scenarioNames";
4+
5+
const SCENARIO_HEADER = "x-mock-scenario";
6+
7+
type ResponseResolverInfo = Parameters<HttpResponseResolver>[0];
8+
9+
type OverrideHandlerFn<T> = (data: T, info: ResponseResolverInfo) => Response;
10+
type OverrideFn<T> = (data: T, info: ResponseResolverInfo) => T;
11+
type ScenarioFn<T> = (
12+
instance: AutoAPIMockInstance<T>,
13+
) => AutoAPIMockInstance<T>;
14+
15+
export interface ActivateScenarioOptions {
16+
/** If true, silently falls back to default when scenario doesn't exist. Default: false (throws) */
17+
fallbackToDefault?: boolean;
18+
}
19+
20+
export interface AutoAPIMockInstance<T> {
21+
/** MSW handler to use in handler registration. Respects overrides and scenarios. */
22+
generatedHandler: HttpResponseResolver;
23+
24+
/** Override response data with type safety. Preferred for simple data changes. */
25+
override: (fn: OverrideFn<T>) => AutoAPIMockInstance<T>;
26+
27+
/** Override the full handler. Use for errors, network failures, or invalid data. */
28+
overrideHandler: (fn: OverrideHandlerFn<T>) => AutoAPIMockInstance<T>;
29+
30+
/** Define a reusable named scenario for this mock. */
31+
scenario: (
32+
name: MockScenarioName,
33+
fn: ScenarioFn<T>,
34+
) => AutoAPIMockInstance<T>;
35+
36+
/** Activate a named scenario for the current test. */
37+
activateScenario: (
38+
name: MockScenarioName,
39+
options?: ActivateScenarioOptions,
40+
) => AutoAPIMockInstance<T>;
41+
42+
/** Reset to default behavior. Called automatically before each test. */
43+
reset: () => AutoAPIMockInstance<T>;
44+
45+
/** The default fixture data. */
46+
defaultValue: T;
47+
}
48+
49+
// Registry to track all instances for bulk reset
50+
const registry: Set<AutoAPIMockInstance<unknown>> = new Set();
51+
52+
export function AutoAPIMock<T>(defaultValue: T): AutoAPIMockInstance<T> {
53+
let overrideHandlerFn: OverrideHandlerFn<T> | null = null;
54+
const scenarios = new Map<MockScenarioName, ScenarioFn<T>>();
55+
56+
const instance: AutoAPIMockInstance<T> = {
57+
defaultValue,
58+
59+
generatedHandler(info: ResponseResolverInfo) {
60+
// Check for header-based scenario activation (for browser/dev testing)
61+
const headerScenario = info.request.headers.get(SCENARIO_HEADER);
62+
if (headerScenario) {
63+
const scenarioFn = scenarios.get(headerScenario as MockScenarioName);
64+
if (scenarioFn) {
65+
// Temporarily apply scenario and get the handler
66+
const previousHandler = overrideHandlerFn;
67+
scenarioFn(instance);
68+
const result = overrideHandlerFn
69+
? overrideHandlerFn(defaultValue, info)
70+
: HttpResponse.json(defaultValue as JsonBodyType);
71+
// Restore previous state
72+
overrideHandlerFn = previousHandler;
73+
return result;
74+
}
75+
}
76+
77+
if (overrideHandlerFn) {
78+
return overrideHandlerFn(defaultValue, info);
79+
}
80+
return HttpResponse.json(defaultValue as JsonBodyType);
81+
},
82+
83+
override(fn: OverrideFn<T>) {
84+
return instance.overrideHandler((data, info) =>
85+
HttpResponse.json(fn(data, info) as JsonBodyType),
86+
);
87+
},
88+
89+
overrideHandler(fn: OverrideHandlerFn<T>) {
90+
overrideHandlerFn = fn;
91+
return instance;
92+
},
93+
94+
scenario(name: MockScenarioName, fn: ScenarioFn<T>) {
95+
scenarios.set(name, fn);
96+
return instance;
97+
},
98+
99+
activateScenario(
100+
name: MockScenarioName,
101+
options?: ActivateScenarioOptions,
102+
) {
103+
const scenarioFn = scenarios.get(name);
104+
if (!scenarioFn) {
105+
if (options?.fallbackToDefault) {
106+
return instance;
107+
}
108+
throw new Error(
109+
`Scenario "${name}" not found. Available scenarios: ${[...scenarios.keys()].join(", ") || "(none)"}`,
110+
);
111+
}
112+
return scenarioFn(instance);
113+
},
114+
115+
reset() {
116+
overrideHandlerFn = null;
117+
return instance;
118+
},
119+
};
120+
121+
registry.add(instance as AutoAPIMockInstance<unknown>);
122+
123+
return instance;
124+
}
125+
126+
export function resetAllAutoAPIMocks(): void {
127+
for (const instance of registry) {
128+
instance.reset();
129+
}
130+
}
131+
132+
/**
133+
* Activate a scenario globally across all registered mocks.
134+
* Mocks that don't have the scenario defined will silently use their default.
135+
*/
136+
export function activateMockScenario(name: MockScenarioName): void {
137+
for (const instance of registry) {
138+
instance.activateScenario(name, { fallbackToDefault: true });
139+
}
140+
}

0 commit comments

Comments
 (0)