Closed4

Next.js の Experimental test mode for Playwright でのナビゲーション時に Server Components での API リクエストエラー調査

KotaroKotaro

以下が本事象を再現する Example は以下の通り。

アプリケーション:

app/page.tsx
import Link from "next/link";

export default function Page() {
  return <Link href="/hello">リンク</Link>;
}
app/hello/page.tsx
import { Suspense } from "react";

export default async function Page() {
  const res = await fetch("http://localhost:3000/api/foo");
  if (!res.ok) {
    throw new Error("Failed to fetch");
  }
  const data = await res.json();
  return (
    <div>
      {data.text}
      <Suspense fallback="loading...">
        <Example />
      </Suspense>
    </div>
  );
}

async function Example() {
  const res = await fetch("http://localhost:3000/api/bar");
  if (!res.ok) {
    throw new Error("Failed to fetch");
  }
  const data = await res.json();
  return <div>{data.text}</div>;
}

テスト:

import { test } from "next/experimental/testmode/playwright";
import { expect } from "@playwright/test";
import { setTimeout } from "timers/promises";

test("/hello へのページ遷移テスト", async ({ page, next }) => {
  await page.goto("/");

  next.onFetch(async (request) => {
    if (request.url === "http://localhost:3000/api/foo") {
      return new Response(
        JSON.stringify({
          text: "foo",
        }),
        {
          headers: {
            "Content-Type": "application/json",
          },
        },
      );
    }
    if (request.url === "http://localhost:3000/api/bar") {
      await setTimeout(1000);
      return new Response(
        JSON.stringify({
          text: "bar",
        }),
        {
          headers: {
            "Content-Type": "application/json",
          },
        },
      );
    }
    return "abort";
  });

  const link = await page.getByRole("link", { name: "リンク" });
  expect(link).toBeVisible();
  await link.click();
});

この状態で VSCode でテストを実行すると、テスト自体はパスするが Next.js サーバー側でエラーが発生してしまう。Playwright のテスト中 Next.js サーバー側の stdout を出力するように設定する(webServer.stdoutpipe にする)ことで、以下のような Server Components での API リクエストエラーを確認できる。

 ⨯ TypeError: fetch failed
    at async Page (./src/app/hello/page.tsx:12:17)
digest: "255610910"
Cause: AggregateError
    at internalConnectMultiple (node:net:1114:18)
    at afterConnectMultiple (node:net:1667:5)
    at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
  code: 'ECONNREFUSED',
  [errors]: [
    Error: connect ECONNREFUSED ::1:55924
        at createConnectionError (node:net:1634:14)
        at afterConnectMultiple (node:net:1664:40)
        at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
      errno: -61,
      code: 'ECONNREFUSED',
      syscall: 'connect',
      address: '::1',
      port: 55924
    },
    Error: connect ECONNREFUSED 127.0.0.1:55924
        at createConnectionError (node:net:1634:14)
        at afterConnectMultiple (node:net:1664:40)
        at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
      errno: -61,
      code: 'ECONNREFUSED',
      syscall: 'connect',
      address: '127.0.0.1',
      port: 55924
    }
  ]
}

内容としては 127.0.0.1:55924 に接続しようとしたけどできなかったというエラー。
※ ポート番号はその人の環境によって異なる

KotaroKotaro

「アプリケーションから 127.0.0.1:55924 にリクエストを送った覚えはないけど、これは一体何のリクエスト?」と疑問に思う方もいるかもしれません。これに関して、Next.js Test Mode の仕組みを簡単に説明する。
Next.js Test Mode を有効にすると、Next.js で使用しているグローバル fetch が上書きされ、Playwright Worker で起動したプロキシサーバー(127.0.0.1:55924)にリクエストが送られるようになる。このプロキシサーバーがモックレスポンスを返す仕組み。ここら辺の実装一通り追ったが中々面白い。

詳しくは Quramy さんの記事で紹介されている。

https://gist.github.com/Quramy/75943784df2e325db20772ee0c50431d

したがって、このエラーは、モックレスポンスを返すプロキシサーバーが起動していない状態で fetch リクエストが実行されたために発生してしまう。テストが終了するとプロキシサーバーも停止するため、テスト終了後にリクエストが送られていることが原因。

KotaroKotaro

このエラーを回避するには API のレスポンスを待ってからテストを終了するように修正するだけ。

ただし、foo のレスポンスを待機しても、bar のレスポンスでエラーが発生するため注意。

test("/hello へのページ遷移テスト", async ({ page, next }) => {
  // ...
  await link.click();
+ await expect(page.getByText("foo")).toBeVisible();
});

ちゃんと、ページ上での全ての fetch のレスポンスを待機する必要がある。また、SSR Streaming を使用しているケースにおいては Streaming 完了を待つ前にテストが終了してしまうと UND_ERR_SOCKET エラーが発生してしまう。そのため Streaming についても必ず待つようなテストコードにしよう。

test("/hello へのページ遷移テスト", async ({ page, next }) => {
  // ...
  await link.click();
+ await expect(page.getByText("foo")).toBeVisible();
+ await expect(page.getByText("bar")).toBeVisible();
});
このスクラップは2ヶ月前にクローズされました