🍚

Remixコンポーネントをテストする方法

2024/03/04に公開

変更履歴

変更日 変更内容
2024/04/05 パッケージのアップデートと GitHub Actions を設定
2024/04/06 useOutletContext のテストを追加
2024/05/09 loader で remix-auth の isAuthenticated を行なっている場合のテストを追加

はじめに

みなさん、Remix のテストはどのように書いていますでしょうか?

testing-libraryで画面の表示やボタンのテストを書いたり、vitestjestでテストを書いたりしていると思います。

ただ、私はこれまでは Remix の loaderaction はどのように書いてよいかわかりませんでした。

今回、公式 HP を読んでいたら、@remix-run/testingがあったので、こちらを元にテストを書いていきます!

https://remix.run/docs/en/main/other-api/testing#remix-runtesting

リポジトリはこちら

テストのリポジトリを作ってみました!

よければご参照ください。

https://github.com/yuta-kume/remix-testing-sample

@remix-run/testing をインストールする

以下のコマンドを入力して@remix-run/testingをインストールします。

npm install --save-dev @remix-run/testing

合わせてvitest@testing-library/react@testing-library/jest-dom@testing-library/user-eventjsdomもインストールします。

npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
npm install -D vitest

また、vitest.config.tsも作成します。

vitest.config.ts
import * as path from "path";
import * as VitestConfig from "vitest/config";

export default VitestConfig.defineConfig({
  test: {
    globals: true,
    environment: "jsdom",
  },
  resolve: {
    alias: {
      "~": path.resolve(__dirname, "app"),
    },
  },
});

テストを作成

プロジェクト作成時

Remix のプロジェクトを作成した時点で、/app/routes/_index.tsxのテストを作成してみます。

/tests/index.test.tsx
import { describe, expect, test } from "vitest";
import { createRemixStub } from "@remix-run/testing";
import { render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import Index from "~/routes/_index";

describe("_index.tsxのテスト", () => {
  test("初期表示テスト", async () => {
    const RemixStub = createRemixStub([{ path: "/", Component: Index }]);

    render(<RemixStub />);

    await waitFor(async () => {
      expect(await screen.findByText("Welcome to Remix")).toBeVisible();
      expect(
        await screen.findByText("15m Quickstart Blog Tutorial")
      ).toBeVisible();
      expect(
        await screen.findByText("Deep Dive Jokes App Tutorial")
      ).toBeVisible();
      expect(await screen.findByText("Remix Docs")).toBeVisible();
    });
  });
});

createRemixStub()でスタブを作成し、render(<RemixStub />)でレンダリングしています。レンダリングされた<RemixStub>に対してテストを行なっています。

loader のテスト

/routes/test_loader.tsxを作り、テストとして/tests/test_loader.test.tsxを作成します。

/routes/test_loader.tsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export const loader = async () => {
  return json({ message: "hogehoge" });
};

export default function TestLoader() {
  const { message } = useLoaderData<typeof loader>();

  return (
    <>
      <p>Text is {message}</p>
    </>
  );
}
/tests/test_loader.test.tsx
import { describe, expect, test } from "vitest";
import { createRemixStub } from "@remix-run/testing";
import { render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import TestLoader, { loader } from "~/routes/test_loader";

describe("test_loader.tsxのテスト", () => {
  test("『hogehoge』が表示される", async () => {
    const RemixStub = createRemixStub([
      {
        path: "/",
        Component: TestLoader,
        loader,
      },
    ]);

    render(<RemixStub />);

    await waitFor(async () => {
      const element = await screen.findByText("Text is hogehoge");
      expect(element).toBeVisible();
      expect(element).toBeInTheDocument();
    });
  });
});

createRemixStub()test_loader.tsxloaderを設定しています。
Remix 公式の Example には loader()を書いて json を返していますが、このように書くことtest_loader.tsxに実際に書いたloader()を呼び出すことができます。
test_loader.tsxloader()では『hogehoge』を返しているだけなので、画面に『hogehoge』が表示されているかを確認しています。

action のテスト

/routes/test_action.tsxを作り、テストとして/tests/test_action.test.tsxを作成します。

/routes/test_action.tsx
import { ActionFunctionArgs, json } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";

export default function TestAction() {
  const actionData = useActionData<typeof action>();

  return (
    <Form method={"POST"}>
      <div>
        <label htmlFor="email">email</label>
        <input type="email" name="email" id="email" placeholder="email" />
      </div>
      <div>
        {actionData?.message != null && (
          <p id="message">{actionData?.message}</p>
        )}
      </div>
      <div>
        <button type={"submit"}>Submit</button>
      </div>
    </Form>
  );
}

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const email = String(formData.get("email"));

  if (!email.includes("hoge.co.jp"))
    return json({ message: "メールアドレスが不正です!" });

  return json({ message: "actionに成功しました!" });
};
/tests/test_action.test.tsx
import { describe, expect, test } from "vitest";
import { createRemixStub } from "@remix-run/testing";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import "@testing-library/jest-dom";
import TestAction, { action } from "~/routes/test_action";

describe("test_action.tsxのテスト", () => {
  test("labelが『email』の入力フォームとボタン名が『Submit』のボタンが初期表示される", async () => {
    const RemixStub = createRemixStub([
      {
        path: "/",
        Component: TestAction,
        action,
      },
    ]);

    render(<RemixStub />);

    await waitFor(async () => {
      const emailElement = screen.getByRole("textbox", { name: "email" });
      const buttonElement = screen.getByRole("button", { name: "Submit" });
      expect(emailElement).toBeVisible();
      expect(emailElement).toBeInTheDocument();
      expect(buttonElement).toBeVisible();
      expect(buttonElement).toBeInTheDocument();
    });
  });

  test("フォームの送信に失敗すると『メールアドレスが不正です!』が表示される", async () => {
    const RemixStub = createRemixStub([
      {
        path: "/",
        Component: TestAction,
        action,
      },
    ]);

    render(<RemixStub />);

    // 要素を取得。
    const emailElement = screen.getByRole("textbox", { name: "email" });

    // 適切ではないemailに変更。
    fireEvent.change(emailElement, { target: { value: "hoge@fuga.co.jp" } });

    // Submitボタンをクリック。
    await userEvent.click(screen.getByRole("button", { name: "Submit" }));

    await waitFor(async () => {
      const messageElement = await screen.findByText(
        "メールアドレスが不正です!"
      );
      expect(messageElement).toBeVisible();
      expect(messageElement).toBeInTheDocument();
    });
  });

  test("フォームの送信に成功すると『actionに成功しました!』が表示される", async () => {
    const RemixStub = createRemixStub([
      {
        path: "/",
        Component: TestAction,
        action,
      },
    ]);

    render(<RemixStub />);

    // 要素を取得。
    const emailElement = screen.getByRole("textbox", { name: "email" });

    // 適切なemailに変更。
    fireEvent.change(emailElement, { target: { value: "hoge@hoge.co.jp" } });

    // Submitボタンをクリック。
    await userEvent.click(screen.getByRole("button", { name: "Submit" }));

    await waitFor(async () => {
      const messageElement = await screen.findByText("actionに成功しました!");
      expect(messageElement).toBeVisible();
      expect(messageElement).toBeInTheDocument();
    });
  });
});

上から順に見ていきます。
「label が『email』の入力フォームとボタン名が『Submit』のボタンが初期表示される」では、レンダリング後に email の入力フォームと Submit のボタンが表示されているかを確認しています。

「フォームの送信に失敗すると『メールアドレスが不正です!』が表示される」では、email フォームに『hoge@fuga.co.jp』を入力し、Submit ボタンをクリックします。action 内では『hoge.co.jp』が含まれていなければメールアドレスと判定しないので、画面上に『メールアドレスが不正です!』が表示されているかを確認しています。

「フォームの送信に成功すると『action に成功しました!』が表示される」では、email フォームに『hoge@hoge.co.jp』を入力し、Submit ボタンをクリックします。『hoge.co.jp』に一致するので『action に成功しました!』にが表示されているかを確認しています。

最後に

今回は Remix のテストを書いてみました。
loader や action のテストが書けるのはありがたいです!
テストの書き方や他に良いテスト方法がある場合はコメントや Issue を立ててもらえると嬉しいです!🙇‍♂️

参考

Discussion