Skip to content

Add docs for testing #1258

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 27, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 142 additions & 1 deletion docs/src/content/docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Advanced
description: Advanced usage as well as tips, tricks, and best practices
---

Various anselary topics and advanced usage.
Advanced usage and various topics.

## Data fetching

Expand All @@ -16,6 +16,147 @@ Fetching data can be done simply and safely using an **automatically-typed fetch
>
> A good fetch wrapper should **never use generics.** Generics require more typing and can hide errors!

## Testing

One of the most common causes of false positive tests is when mocks are out-of-date with the actual API responses.

`openapi-typescript` offers a fantastic way to guard against this with minimal effort. Here’s one example how you could write your own helper function to typecheck all mocks to match your OpenAPI schema (we’ll use [vitest](https://vitest.dev/)/[vitest-fetch-mock](https://www.npmjs.com/package/vitest-fetch-mock) but the same principle could work for any setup):

Let’s say we want to write our mocks in the following object structure, so we can mock multiple endpoints at once:

```
{
[pathname]: {
[HTTP method]: {
[HTTP status code]: { [some mock data] }
}
}
}
```

Using our generated types we can then infer **the correct data shape** for any given path + HTTP method + status code. An example test would look like this:

```ts
// my-test.test.ts
import { mockResponses } from "../test/utils";

describe("My API test", () => {
it("mocks correctly", async () => {
mockResponses({
"/users/{user_id}": {
get: {
200: { id: "user-id", name: "User Name" }, // ✅ Correct 200 response
404: { code: "404", message: "User not found" }, // ✅ Correct 404 response
},
},
"/users": {
put: {
201: { status: "success" }, // ✅ Correct 201 response
},
},
});

// test 1: GET /users/{user_id}: 200 returned by default
await fetch("/users/user-123");

// test 2: GET /users/{user_id}: 404 returned if `x-test-status` header sent
await fetch("/users/user-123", { headers: { "x-test-status": 404 } });

// test 3: PUT /users: 200
await fetch("/users", {
method: "PUT",
body: JSON.stringify({ id: "new-user", name: "New User" }),
});

// test cleanup
fetchMock.resetMocks();
});
});
```

_Note: this example uses a vanilla `fetch()` function, but any fetch wrapper—including [openapi-fetch](/openapi-fetch)—could be dropped in instead without any changes._

And the magic that produces this would live in a `test/utils.ts` file that can be copy + pasted where desired (hidden for simplicity):

<details>
<summary>📄 <strong>test/utils.ts</strong></summary>

```ts
// test/utils.ts
import { paths } from "./api/v1/my-schema"; // generated by openapi-typescript

// Settings
// ⚠️ Important: change this! This prefixes all URLs
const BASE_URL = "https://myapi.com/v1";
// End Settings

// type helpers — ignore these; these just make TS lookups better
type FilterKeys<Obj, Matchers> = { [K in keyof Obj]: K extends Matchers ? Obj[K] : never }[keyof Obj];
type PathResponses<T> = T extends { responses: any } ? T["responses"] : unknown;
type OperationContent<T> = T extends { content: any } ? T["content"] : unknown;
type MediaType = `${string}/${string}`;

/**
* Mock fetch() calls and type against OpenAPI schema
*/
export function mockResponses(responses: {
[Path in keyof Partial<paths>]: {
[Method in keyof Partial<paths[Path]>]: {
[Status in keyof Partial<PathResponses<paths[Path][Method]>>]: FilterKeys<OperationContent<PathResponses<paths[Path][Method]>[Status]>, MediaType>;
};
};
}) {
fetchMock.mockResponse((req) => {
const mockedPath = findPath(req.url.replace(BASE_URL, ""), Object.keys(responses))!;
// note: we get lazy with the types here, because the inference is bad anyway and this has a `void` return signature. The important bit is the parameter signature.
if (!mockedPath || (!responses as any)[mockedPath]) throw new Error(`No mocked response for ${req.url}`); // throw error if response not mocked (remove or modify if you’d like different behavior)
const method = req.method.toLowerCase();
if (!(responses as any)[mockedPath][method]) throw new Error(`${req.method} called but not mocked on ${mockedPath}`); // likewise throw error if other parts of response aren’t mocked
const desiredStatus = req.headers.get("x-status-code");
const body = (responses as any)[mockedPath][method];
return {
status: desiredStatus ? parseInt(desiredStatus, 10) : 200,
body: JSON.stringify((desiredStatus && body[desiredStatus]) ?? body[200]),
};
});
}

// helper function that matches a realistic URL (/users/123) to an OpenAPI path (/users/{user_id}
export function findPath(actual: string, testPaths: string[]): string | undefined {
const url = new URL(actual, actual.startsWith("http") ? undefined : "http://testapi.com");
const actualParts = url.pathname.split("/");
for (const p of testPaths) {
let matched = true;
const testParts = p.split("/");
if (actualParts.length !== testParts.length) continue; // automatically not a match if lengths differ
for (let i = 0; i < testParts.length; i++) {
if (testParts[i]!.startsWith("{")) continue; // path params ({user_id}) always count as a match
if (actualParts[i] !== testParts[i]) {
matched = false;
break;
}
}
if (matched) return p;
}
}
```

> **Additional Explanation** That code is quite above is quite a doozy! For the most part, it’s a lot of implementation detail you can ignore. The `mockResponses(…)` function signature is where all the important magic happens—you’ll notice a direct link between this structure and our design. From there, the rest of the code is just making the runtime work as expected.

```ts
export function mockResponses(responses: {
[Path in keyof Partial<paths>]: {
[Method in keyof Partial<paths[Path]>]: {
[Status in keyof Partial<PathResponses<paths[Path][Method]>>]: FilterKeys<OperationContent<PathResponses<paths[Path][Method]>[Status]>, MediaType>;
};
};
});
```

</details>

Now, whenever your schema updates, **all your mock data will be typechecked correctly** 🎉. This is a huge step in ensuring resilient, accurate tests.

## Tips

In no particular order, here are a few best practices to make life easier when working with OpenAPI-derived types.
Expand Down