0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Next.js】テストフレームワーク「Jest」と「Testing library」を組み込む

Posted at

テストフレームワークをインストール

下記のコマンドを使ってインストールします。

npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom ts-node @types/jest
参考となる公式サイトはこちら⇩

How to set up Jest with Next.js

その他、必要なソフトウェアをインストールします。

@testing-library/user-event
npm i @testing-library/user-event

@testing-library/jest-dom
npm i @testing-library/jest-dom

ts-jest
npm i ts-jest

1.tsconfig.jsonの更新

"types": ["jest", "@testing-library/jest-dom"]を追加します。

tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext", //修正
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "ESNext", //修正
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"] //追加
    },
    "types": ["jest", "@testing-library/jest-dom"] //👈追加
  },
  //(略)

全体のtsconfig.jsonのコード

tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext", //修正
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "ESNext", //修正
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"] //追加
    },
    "types": ["jest", "@testing-library/jest-dom"] //追加
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts",
    "**/*.mts"
  ],
  "exclude": ["node_modules"]
}

2.__tests__フォルダをプロジェクト直下に新規作成

下記のように__tests__フォルダを作成します。
image.png

image.png

__tests__ フォルダ内に、各機能ごとのテストファイルを配置します。
例えば、AdminLogin のテストファイルは AdminLogin.test.tsx となります。
他のページやコンポーネントがあれば、それぞれに対応するテストファイルを作成します。

【イメージ】
my-next-app/
└── src/
    └── __tests__/
        ├── AdminLogin.test.tsx     # AdminLoginのテスト
        ├── Header.test.tsx         # Headerコンポーネントのテスト
        ├── Footer.test.tsx         # Footerコンポーネントのテスト
        ├── ...                     # その他のテストファイル

このフォルダにtest.tsxファイルを作成します。
今回のテストファイルはこちら⇩

AdminLogin.test.tsx
// src/__tests__/AdminLogin.test.tsx
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"; //追加
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider } from "react-redux";
import { store } from "../app/redux-function/admin/login/store/store";
import AdminLogin from "../app/admin/login/page"; // AdminLoginのパス

// Reduxのstateをモックするために使う
jest.mock("react-redux", () => ({
  ...jest.requireActual("react-redux"),
  useDispatch: jest.fn(),
  useSelector: jest.fn(),
}));

const setup = () =>
  render(
    <ChakraProvider value={defaultSystem}>
      <Provider store={store}>
        <AdminLogin />
      </Provider>
    </ChakraProvider>,
  );
describe("AdminLogin", () => {
  const mockDispatch = jest.fn();

  beforeEach(() => {
    jest.clearAllMocks();

    const redux = require("react-redux");
    redux.useDispatch.mockReturnValue(mockDispatch);
    redux.useSelector.mockImplementation((selector: any) =>
      selector({
        user: {
          email: "xxxxx@example.com",
          password: "password123",
        },
      }),
    );
  });

  test("フォームが正しく表示されること", () => {
    setup();

    expect(screen.getByLabelText(/メールアドレス/)).toBeInTheDocument();
    expect(screen.getByLabelText(/パスワード/)).toBeInTheDocument();
    //expect(screen.getByText(/ログイン/)).toBeInTheDocument();
    expect(screen.getByText(/リセット/)).toBeInTheDocument();
    const loginButton = screen.getByRole("button", { name: /ログイン/ });
    userEvent.click(loginButton);
  });

  test("送信ボタンをクリックするとalertが表示されること", async () => {
    const alertMock = jest.spyOn(window, "alert").mockImplementation(() => {});
    setup(); // ChakraProvider + Redux Provider でラップした render

    // 入力
    await userEvent.type(
      screen.getByLabelText(/メールアドレス/),
      "test@example.com",
    );
    await userEvent.type(screen.getByLabelText(/パスワード/), "password123");

    // 送信ボタンをクリック
    const loginButton = screen.getByRole("button", { name: /ログイン/ });
    await userEvent.click(loginButton);

    // alert が呼ばれたか確認
    await waitFor(() => {
      expect(alertMock).toHaveBeenCalledWith("送信しました。");
    });

    alertMock.mockRestore(); // モックを元に戻す
  });

  /*
  test("送信ボタンをクリックするとalertが表示されること", async () => {
    setup();
    window.alert = jest.fn();

    await userEvent.type(screen.getByLabelText(/メールアドレス/), "test@example.com");
    await userEvent.type(screen.getByLabelText(/パスワード/), "password123");
    const loginButton = screen.getByRole('button', { name: /ログイン/ });
    userEvent.click(loginButton);
    //await userEvent.click(screen.getByText(/ログイン/));

    expect(window.alert).toHaveBeenCalledWith("送信しました。");
    expect(mockDispatch).toHaveBeenCalledTimes(2); // setEmail と setPassword
  });
  */

  test("リセットボタンをクリックするとフォームがリセットされること", async () => {
    setup();

    await userEvent.type(
      screen.getByLabelText(/メールアドレス/),
      "test@example.com",
    );
    await userEvent.type(screen.getByLabelText(/パスワード/), "password123");

    await userEvent.click(screen.getByText(/リセット/));

    expect(
      (screen.getByLabelText(/メールアドレス/) as HTMLInputElement).value,
    ).toBe("");
    expect(
      (screen.getByLabelText(/パスワード/) as HTMLInputElement).value,
    ).toBe("");
  });
});

3.srcフォルダを新規作成

image.png

image.png

setupTests.tsファイルに下記の内容を記載します。

setupTests.ts
import "@testing-library/jest-dom";

// structuredClone Polyfill
if (typeof (global as any).structuredClone !== "function") {
  (global as any).structuredClone = (obj: any) =>
    JSON.parse(JSON.stringify(obj));
}

4.jest.config.jsの新規作成

トラブルシューティング

1.「Parameter 'selector' implicitly has an 'any' type.ts(7006) (parameter) selector: any」

(回答)
TS7006: Parameter 'selector' implicitly has an 'any' type
これはTypeScriptnoImplicitAnyが有効なため、テスト内でselectorの型が推論できずエラーになるものです。
RootState を import して使う方法をがおすすめです。

sample.test.tsx
import type { RootState } from "../../redux-function/admin/login/store/store";

require("react-redux").useSelector.mockImplementation(
  (selector: (state: RootState) => any) => {
    return selector({
      user: {
        email: "test@example.com",
        password: "password123",
      },
    } as RootState);
  }
);

2.「Property 'toBeInTheDocument' does not exist on type 'JestMatchers'.ts(2339)」

(回答)
そのエラーはtoBeInTheDocument()Jest に認識されていない ために起きています。
toBeInTheDocument() は React Testing Library(@testing-library/jest-dom) の拡張マッチャーなので、
セットアップファイルでimport "@testing-library/jest-dom"を読み込む必要があります。

✅ 解決方法(必須)

✔ 1. setupTests.ts を作って jest-dom を読み込む
🔧 プロジェクトの構成例(Next.js)
project/
├── jest.config.js
└── src/
    └── setupTests.ts
src/setupTests.ts
src/setupTests.ts
import "@testing-library/jest-dom";

✔ 2. jest.config.js に setupFilesAfterEnv を登録

jest.config.js がない場合は作る。
ある場合は以下を追加してください。

jest.config.js
module.exports = {
  testEnvironment: "jsdom",
  setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
};

🔧 TypeScript で型が効かない時(TS の追加設定)

TypeScript が jest-dom の型を認識しない場合は tsconfig.json に追加:

tsconfig.json
{
  "compilerOptions": {
    "types": ["jest", "@testing-library/jest-dom"],
    ...
  }
}

3.「Property 'value' does not exist on type 'HTMLElement'.ts」

そのエラーは TypeScript が getByLabelText() の戻り値を HTMLElement と推論し、value プロパティがないと判断している ために発生します。

value を持っているのは HTMLInputElement です。

✅ 解決方法(最もシンプルで実用的)

👉 1. 明示的に型アサーションを付ける

sample.test.tsx
expect((screen.getByLabelText(/メールアドレス/) as HTMLInputElement).value).toBe("");

4.プロジェクトルートで「npm run test」を実行すると「Missing script: "test"」と表示された。

Missing script: "test" というエラーは、
package.json "test"スクリプトが定義されていない ことが原因です。

いずれにしても package.json に test スクリプトがない と動きません。

package.json
//略
{
  "scripts": {
    "test": "jest --passWithNoTests"
  }
}
//略

5.SyntaxError: C:\Users\xxx\Desktop\nextjs-chakra-app_tests_\AdminLogin.test.tsx: Support for the experimental syntax 'jsx' isn't currently enabled (35:7):

(回答)
これは Jest JSX(TSX)を変換できていない ために発生する典型的エラーです。

原因は次のいずれか:
ts-jest の設定不足
babel-jest の設定不足
jest.config.js transform が設定されていない

Next.js + TypeScript + Jest + React Testing Library の環境では
Babel ではなく ts-jest を使うのが最も簡単です。

🎯【最短で確実に直す方法】Jest を ts-jest で設定する

✅ ① ts-jest をインストール

npm install -D ts-jest @types/jest

✅ ② jest.config.js を正しく設定する

jest.config.js
module.exports = {
  preset: "ts-jest",
  testEnvironment: "jsdom",
  setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
  moduleNameMapper: {
    "^@/(.*)$": "<rootDir>/src/$1",
  },
  transform: {
    "^.+\\.(ts|tsx)$": "ts-jest",
  },
};

✅ ③ setupTests.ts を用意(なければ作る)

setupTests.ts
import "@testing-library/jest-dom";

// structuredClone Polyfill
if (typeof (global as any).structuredClone !== "function") {
  (global as any).structuredClone = (obj: any) =>
    JSON.parse(JSON.stringify(obj));
}

Jest実行後に下記のエラーが表示された
FAIL __tests__/AdminLogin.test.tsx AdminLogin × フォームが正しく表示されること (68 ms) × フォームにデータを入力して送信ボタンをクリックすると、dispatchが呼ばれること (5 ms) × リセットボタンをクリックするとフォームがリセットされること (6 ms) × 送信ボタンをクリックするとalertが表示されること (5 ms) ● AdminLogin › フォームが正しく表示されること ReferenceError: structuredClone is not defined

(回答)
ReferenceError: structuredClone is not defined
➡ Jest(= JSDOM)には structuredClone が標準実装されていないため発生するエラーです。

Next.js + React Hook Form + Redux などで内部的に structuredClone() を使うことがあり、
Jest の実行環境で polyfill を入れないと動きません。
✅【最短で確実に直す方法】
👉 Jest のセットアップファイル (setupTests.ts) に polyfill を追加する

setupTests.ts
// jest-dom
import "@testing-library/jest-dom";

// structuredClone Polyfill
if (typeof (global as any).structuredClone !== "function") {
  (global as any).structuredClone = (obj: any) => JSON.parse(JSON.stringify(obj));
}

6.Jestを実行すると下記のエラーが表示

● AdminLogin › 送信ボタンをクリックするとalertが表示されること ContextError: useContext returned undefined. Seems you forgot to wrap component within <ChakraProvider />

(回答)
テストコードでは setup() 内で ChakraProvider でラップしているにも関わらず、各テスト内でさらに render(...) を実行している ため、ChakraProvider が正しく適用されていない状態になっています。

つまり 二重に render している のが原因です。

✅ 修正版のポイント

1.setup() で ChakraProvider + Redux Provider をまとめてラップ
2.各テスト内で再度 render しない
3.テスト内では setup() を呼ぶだけで良い

sample.test.tsx
const setup = () =>
  render(
    <ChakraProvider>
      <Provider store={store}>
        <AdminLogin />
      </Provider>
    </ChakraProvider>
  );

describe("AdminLogin", () => {
  const mockDispatch = jest.fn();

  beforeEach(() => {
    jest.clearAllMocks();

    const redux = require("react-redux");
    redux.useDispatch.mockReturnValue(mockDispatch);
    redux.useSelector.mockImplementation((selector: any) =>
      selector({
        user: {
          email: "xxxxx@example.com",
          password: "password123",
        },
      })
    );
  });

  test("フォームが正しく表示されること", () => {
    setup();

    expect(screen.getByLabelText(/メールアドレス/)).toBeInTheDocument();
    expect(screen.getByLabelText(/パスワード/)).toBeInTheDocument();
    expect(screen.getByText(/ログイン/)).toBeInTheDocument();
    expect(screen.getByText(/リセット/)).toBeInTheDocument();
  });

  test("送信ボタンをクリックするとalertが表示されること", async () => {
    setup();
    window.alert = jest.fn();

    await userEvent.type(screen.getByLabelText(/メールアドレス/), "test@example.com");
    await userEvent.type(screen.getByLabelText(/パスワード/), "password123");
    await userEvent.click(screen.getByText(/ログイン/));

    expect(window.alert).toHaveBeenCalledWith("送信しました。");
    expect(mockDispatch).toHaveBeenCalledTimes(2); // setEmail と setPassword
  });

  test("リセットボタンをクリックするとフォームがリセットされること", async () => {
    setup();

    await userEvent.type(screen.getByLabelText(/メールアドレス/), "test@example.com");
    await userEvent.type(screen.getByLabelText(/パスワード/), "password123");

    await userEvent.click(screen.getByText(/リセット/));

    expect((screen.getByLabelText(/メールアドレス/) as HTMLInputElement).value).toBe("");
    expect((screen.getByLabelText(/パスワード/) as HTMLInputElement).value).toBe("");
  });
});

7.Jest実行後に下記のエラーが出た。

● AdminLogin › フォームが正しく表示されること TestingLibraryElementError: Found a label with the text of: /メールアドレス/, however no form control was found associated to that label. Make sure you're using the "for" attribute or "aria-labelledby" attribute correctly.

(回答)
これは Testing Library がラベルと入力要素の紐付けを見つけられない ことによるエラーです。

原因は Next.js + Chakra UI の と の組み合わせで htmlFor と id が正しく一致していない ことにあります。

page.tsx はこうなっています:

page.tsx
<FormLabel htmlFor="email" textAlign="start">
  メールアドレス
</FormLabel>
<Input {...register('email')} borderColor="blue.200" />

■ と の id 属性 が一致していない
■Chakra UI の は register('email') だけだと id が自動で付かない
■そのため Testing Library が getByLabelText(/メールアドレス/) で紐付けを見つけられずエラーになる

✔ 解決方法
1️⃣ Input に id を追加してラベルと一致させる

page.tsx
<FormControl>
  <FormLabel htmlFor="email" textAlign="start">
    メールアドレス
  </FormLabel>
  <Input
    id="email"            // ← ここを追加
    {...register('email')}
    borderColor="blue.200"
  />
</FormControl>

<FormControl>
  <FormLabel htmlFor="password">パスワード</FormLabel>
  <Input
    id="password"         // ← ここを追加
    {...register('password')}
    borderColor="blue.200"
  />
</FormControl>

2️⃣ 補足

■React Hook Form の register は id を自動でセットしない
■ に対して、対象の が id="X" を持っている必要があります
■これで Testing Library の getByLabelText() がラベルと入力を正しく紐付けられます

Jest実行後に下記のエラー

AdminLogin › 送信ボタンをクリックするとalertが表示されること TestingLibraryElementError: Found multiple elements with the text: /ログイン/ Here are the matching elements: Ignored nodes: comments, script, style

(回答)
今回のエラーは 「ログイン」というテキストが複数見つかった」 ためです。

page.tsx を見ると、 が複数あり、うち 1 つが「ログイン」というテキストを持っていますが、テストでは getByText(/ログイン/) が複数マッチしてしまっている のが原因です。

✅ 解決方法
1️⃣ 役割(role)と name を指定してボタンを特定する

Testing Library では getByRole('button', { name: /ログイン/ }) を使うのがベストです。

page.tsx
// 修正版
const loginButton = screen.getByRole('button', { name: /ログイン/ });
userEvent.click(loginButton);

9.Jest実行後に下記のエラー

AdminLogin › 送信ボタンをクリックするとalertが表示されること expect(jest.fn()).toHaveBeenCalledWith(...expected) Expected: "送信しました。" Number of calls: 0

(回答)
今回のエラーは alert が呼ばれていない ことが原因です。
前回の修正で window.alert = jest.fn() を設定しているのに、呼ばれないのは テスト内での render / userEvent の順番や非同期処理が正しく扱われていない 可能性が高いです。

✅ 原因

非同期で state が更新される React Hook Form の handleSubmit を呼ぶので、userEvent も非同期で扱う必要があります。

window.alert をテスト内で設定する前にコンポーネントが render されると、古い alert を参照してしまう。

sample.test.tsx
test("送信ボタンをクリックするとalertが表示されること", async () => {
  const alertMock = jest.spyOn(window, "alert").mockImplementation(() => {});
  setup(); // ChakraProvider + Redux Provider でラップした render

  // 入力
  await userEvent.type(screen.getByLabelText(/メールアドレス/), "test@example.com");
  await userEvent.type(screen.getByLabelText(/パスワード/), "password123");

  // 送信ボタンをクリック
  const loginButton = screen.getByRole("button", { name: /ログイン/ });
  await userEvent.click(loginButton);

  // alert が呼ばれたか確認
  await waitFor(() => {
    expect(alertMock).toHaveBeenCalledWith("送信しました。");
  });

  alertMock.mockRestore(); // モックを元に戻す
});

🔑 ポイント

await をつけて非同期処理を待つ
→ React Hook Form の submit 内で state が更新されるため

jest.spyOn(window, "alert") を使う
→ 安全に alert をモックして呼び出し確認できる

waitFor() で確認
→ submit 内の非同期処理が完了してから assertion を実行

サイト

How to set up Jest with Next.js

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?