Skip to content

Commit

Permalink
feat(types): expanded typescript support; testApiHandler weakly typ…
Browse files Browse the repository at this point in the history
…ed by default

Previously, `testApiHandler` accepted a `NextApiHandlerType` generic parameter that
_defaulted to `unknown`_. This parameter is passed to the `NextApiHandler` generic
type directly.

With this change:
1) `NextApiHandlerType` has been renamed to the more descriptive `NextResponseJsonType`
2) `NextApiHandlerType` defaults to `any` instead of `unknown`
3) The `.json()` method on the `Response` object (returned by `await fetch()`) now
returns a `NextResponseJsonType` object instead of an `any` object.

tl;dr: NTARH can be as strongly typed as needed, but isn't as pushy about it :)
  • Loading branch information
Xunnamius committed Aug 22, 2021
1 parent 3f1d53b commit 419d5fe
Show file tree
Hide file tree
Showing 5 changed files with 70 additions and 16 deletions.
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,24 @@ await testApiHandler({
console.log(await res.json()); // ◄ outputs: "{hello: 'world'}"
}
});

// NTARH also supports typed response data via TypeScript generics:
await testApiHandler<{ hello: string }>({
// The next line would cause TypeScript to complain:
// handler: (_, res) => res.status(200).send({ hello: false }),
handler: (_, res) => res.status(200).send({ hello: 'world' }),
requestPatcher: (req) => (req.headers = { key: process.env.SPECIAL_TOKEN }),
test: async ({ fetch }) => {
const res = await fetch({ method: 'POST', body: 'data' });
// The next line would cause TypeScript to complain:
// const { goodbye } = await res.json();
const { hello } = await res.json();
console.log(hello); // ◄ outputs: "world"
}
});
```

The interface for `testApiHandler` looks like this:
The interface for `testApiHandler` without generics looks like this:

```TypeScript
async function testApiHandler({
Expand Down
35 changes: 28 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@
"source-map-support": "^0.5.19",
"spellchecker": "^3.7.1",
"text-extensions": "^2.4.0",
"type-fest": "^2.0.0",
"typedoc": "^0.21.6",
"typedoc-plugin-markdown": "^3.10.4",
"typescript": "^4.3.5",
Expand Down
26 changes: 20 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,26 @@ import fetch from 'isomorphic-unfetch';
import { createServer } from 'http';
import { parse as parseUrl } from 'url';

import type { PromiseValue } from 'type-fest';
import type { NextApiHandler } from 'next';
import type { IncomingMessage, ServerResponse } from 'http';
import type { apiResolver as NextApiResolver } from 'next/dist/server/api-utils';

let apiResolver: typeof NextApiResolver | null = null;

type FetchReturnValue = PromiseValue<ReturnType<typeof fetch>>;
type FetchReturnType<NextResponseJsonType> = Promise<
Omit<FetchReturnValue, 'json'> & {
json: (
...args: Parameters<FetchReturnValue['json']>
) => Promise<NextResponseJsonType>;
}
>;

/**
* The parameters expected by `testApiHandler`.
*/
export type Parameters<NextApiHandlerType = unknown> = {
export type TestParameters<NextResponseJsonType = unknown> = {
/**
* A function that receives an `IncomingMessage` object. Use this function to
* edit the request before it's injected into the handler.
Expand Down Expand Up @@ -51,31 +61,32 @@ export type Parameters<NextApiHandlerType = unknown> = {
* `NextApiRequest` and `NextApiResult` objects (in that order) as its two
* parameters.
*/
handler: NextApiHandler<NextApiHandlerType>;
handler: NextApiHandler<NextResponseJsonType>;
/**
* `test` must be a function that runs your test assertions, returning a
* promise (or async). This function receives one parameter: `fetch`, which is
* the unfetch package's `fetch(...)` function but with the first parameter
* omitted.
*/
test: (obj: {
fetch: (init?: RequestInit) => ReturnType<typeof fetch>;
fetch: (init?: RequestInit) => FetchReturnType<NextResponseJsonType>;
}) => Promise<void>;
};

/**
* Uses Next's internal `apiResolver` to execute api route handlers in a
* Next-like testing environment.
*/
export async function testApiHandler<NextApiHandlerType = unknown>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function testApiHandler<NextResponseJsonType = any>({
requestPatcher,
responsePatcher,
paramsPatcher,
params,
url,
handler,
test
}: Parameters<NextApiHandlerType>) {
}: TestParameters<NextResponseJsonType>) {
let server = null;

try {
Expand Down Expand Up @@ -137,7 +148,10 @@ export async function testApiHandler<NextApiHandlerType = unknown>({
}))
);

await test({ fetch: (init?: RequestInit) => fetch(localUrl, init) });
await test({
fetch: (init?: RequestInit) =>
fetch(localUrl, init) as FetchReturnType<NextResponseJsonType>
});
} finally {
server?.close();
}
Expand Down
7 changes: 5 additions & 2 deletions test/unit-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,12 @@ describe('::testApiHandler', () => {
handler: async (_, res) => {
// @ts-expect-error: b does not exist (this test "fails" if no TS error)
res.send({ b: 1 });
expect(true).toBeTrue();
},
test: async ({ fetch }) => void (await fetch())
test: async ({ fetch }) => {
// @ts-expect-error: b does not exist (this test "fails" if no TS error)
(await (await fetch()).json()).b;
expect(true).toBeTrue();
}
});
});
});

0 comments on commit 419d5fe

Please sign in to comment.