next-connectで包んだAPI Routesをnode-mocks-httpでテストしたい場合の注意とその理由
next-connectを使うと、Next.js のAPI Routesをいい感じに書くことができるし、Expressのミドルウェアも扱えるようになって便利です。
// pages/api/hello.js
import nc from "next-connect";
const handler = nc({
onError: (err, req, res, next) => {
console.error(err.stack);
res.status(500).end("Something broke!");
},
onNoMatch: (req, res) => {
res.status(404).end("Page is not found");
},
})
.use(someMiddleware())
.get((req, res) => {
res.send("Hello world");
})
.post((req, res) => {
res.json({ hello: "world" });
})
.put(async (req, res) => {
res.end("async/await is also supported!");
})
.patch(async (req, res) => {
throw new Error("Throws me around! Error can be caught and handled.");
});
export default handler;
しかし、この記事を書いている2022/02/03現在、 node-mocks-http
でこのハンドラをテストしようとすると処理がスタックしてしまいます。
import { createMocks } from 'node-mocks-http';
import handler from './index';
test('200 OK', async () => {
const { req, res } = createMocks({ method: 'GET', url: '/' });
await handler(req, res); // stuck here
expect(res.status).toBe(200);
})
これを解決する変更が提案されていますが、今のところマージに至っていません。この問題を解決するには2通りの解決策があります。
解決策
1.提案されている変更のbranchを使う
PRが出ているリポジトリのパッケージを使うことで解決できます。 npm install <githubname>/<githubrepo>[#<commit-ish>]
でインストールできます。
npm i --save jakeorr/next-connect#c837eee
しかし、そのままでは読み込めなかったので、node_modules
の中に入ってビルドしてやります。package.jsonに以下のスクリプトを追加して、 npm run postinstall
を叩きます。(これで次誰かがインストールした場合に自動でパッケージがビルドされます)
{
"scripts": {
"postinstall": "cd node_modules/next-connect; npm i; npm run build"
}
}
これで解決するはずです。
next()
を呼ぶようにする
2.ハンドラーが スタックする原因は next-connect
がハンドラーに渡した3つ目の引数 next()
が呼ばれることを待ち続けてしまうことだと思うので(後述します)、3つ目の引数を受け取って明示的に next()
を呼んであげればOKです。
import nc from "next-connect";
const handler = nc()
.get(async (req, res, next) => {
res.status(200).send('hello!')
next()
});
export default handler;
なぜスタックするのか
筆者もPRを読んでみたので、参考までに紹介します。
next-connectは100行もない短いコードです。
nc()
したときに呼ばれるのはこの部分です。
function nc(req, res) {
return nc.run(req, res).then(
() => !isResSent(res) && onNoMatch(req, res),
(err) => onError(err, req, res)
);
}
nc.run()
していて、あとはエラーハンドリングです。nc.run()
を見てみましょう。
nc.run = function run(req, res) {
return new Promise((resolve, reject) => {
this.handle(req, res, (err) => (err ? reject(err) : resolve()));
});
};
Promiseを返してます。 handle()
に req
res
を渡していて、3つ目はエラーがあればPromiseのエラー、なければPromiseを解決してます。 this.handle
を見に行きましょう。ここからが肝だと思っています。
nc.handle = function handle(req, res, done) {
const idx = req.url.indexOf("?");
const { handlers, params } = _find( // 1: ハンドラーを集める
req.method,
idx !== -1 ? req.url.substring(0, idx) : req.url
);
if (attachParams) req.params = params;
let i = 0;
const len = handlers.length;
const loop = async (next) => handlers[i++](req, res, next); // 3. ハンドラーを呼び出してindexを次へ。ハンドラーが終わったらnextを呼ぶ → 2へ
const next = (err) => { 2. 分岐処理
i < len
? err
? onError(err, req, res, next) // 2-x. エラーの場合はこちら
: loop(next).catch(next) // 2-a. まだハンドラーがあるならloopを呼ぶ。自分も渡す
: done && done(err); // 2-b. ハンドラーがなければ終了
};
next();
};
流れは以下のような感じです。
-
_find
で処理できるハンドラを探す。 -
next()
が呼ばれて、(1)で見つけたハンドラが残っていれば、loop()
に渡す。なかったらdone()
を呼んで終了。
⚠️ このとき、自分をループに渡してミドルウェアの処理が終わったらまた呼んでもらうようにする。 -
loop()
は、(1)で見つけた関数を実行する。ミドルウェアが next() を呼んだら2に戻る。
expressのハンドラを書いたことがある人ならわかると思いますが、3で渡している next
は普段呼ぶことがないと思います。ただ、ここでは「処理が完了したら next
を呼び出す」というルールになっているので、いつまでも next
が呼ばれず、待ち続けてしまうという状況です。
筆者としては、これでメモリリークが起こらないのかは気になっています。 done()
が呼ばれないので、 nc.run()
で生成したPromiseが解決されずに残ったままになるのでは...と。
とりあえず next()
を呼んで解決するようにしようかなと考えているのですが、意見がある人がいたらコメント欄などで教えてください。
おまけ
jest + Next.js API routes + node-mocks-http のテストについてはこの記事が参考になりました。
Discussion
突然のコメント失礼します。next-connectを使ったAPI Routesのテストにおけるnode-mocks-httpの課題について、具体的な解決策が示されていてとても参考になりました。提案されたPRのリンクも助かります。私もECHOAPIで類似の構成を試してみる際に、この記事のアドバイスを活用させていただきます。ありがとうございました!