📦

next-connectで包んだAPI Routesをnode-mocks-httpでテストしたい場合の注意とその理由

2022/02/03に公開

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;

https://github.com/hoangvvo/next-connect

しかし、この記事を書いている2022/02/03現在、 node-mocks-http でこのハンドラをテストしようとすると処理がスタックしてしまいます。

index.test.js
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>] でインストールできます。

https://docs.npmjs.com/cli/v8/commands/npm-install

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"
  }
}

これで解決するはずです。

2.ハンドラーが next() を呼ぶようにする

スタックする原因は 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() したときに呼ばれるのはこの部分です。

L13-L18#b3fede1
  function nc(req, res) {
    return nc.run(req, res).then(
      () => !isResSent(res) && onNoMatch(req, res),
      (err) => onError(err, req, res)
    );
  }

nc.run() していて、あとはエラーハンドリングです。nc.run() を見てみましょう。

L56-L60#b3fede1
  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 を見に行きましょう。ここからが肝だと思っています。

L61-L79#b3fede1
  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();
  };

流れは以下のような感じです。

  1. _find で処理できるハンドラを探す。
  2. next() が呼ばれて、(1)で見つけたハンドラが残っていれば、 loop() に渡す。なかったら done() を呼んで終了。
    ⚠️ このとき、自分をループに渡してミドルウェアの処理が終わったらまた呼んでもらうようにする。
  3. loop() は、(1)で見つけた関数を実行する。ミドルウェアが next() を呼んだら2に戻る。

expressのハンドラを書いたことがある人ならわかると思いますが、3で渡している next は普段呼ぶことがないと思います。ただ、ここでは「処理が完了したら next を呼び出す」というルールになっているので、いつまでも next が呼ばれず、待ち続けてしまうという状況です。

筆者としては、これでメモリリークが起こらないのかは気になっています。 done() が呼ばれないので、 nc.run() で生成したPromiseが解決されずに残ったままになるのでは...と。

とりあえず next() を呼んで解決するようにしようかなと考えているのですが、意見がある人がいたらコメント欄などで教えてください。

おまけ

jest + Next.js API routes + node-mocks-http のテストについてはこの記事が参考になりました。

https://funnelbit.hatenablog.com/entry/2020/12/17/180709

Discussion