Open6

react-testing-libraryについて

mu0363mu0363

Prefix

getBy, getAllBy

要素がそこにあることを証明する場合

test("favor getBy when proving an element exist", () => {
    render(<Component />);
    const element = screen.getByRole("list");
    expect(element).toBeInTheDocument();
  }
)

 

queryBy, queryAllBy

要素がそこにないことを証明する場合

test("favor getBy when proving an element does not exist", () => {
    render(<Component />);
    const element = screen.queryByRole("textbox");
    expect(element).not.toBeInTheDocument();
  }
)

無いことを確認するときにqueryByを使う理由は、DOMにない要素をgetByの引数に入れた時点でエラーを投げてしまうから
 

findBy, findAllBy

非同期を扱う場合

test("favor findBy or findAllBy when data fetching", async () => {
    render(<Component />);
    const elements = await screen.findAllByRole("listitem");
    expect(elements).toHaveLength(3);
  }
)
mu0363mu0363

Suffix

suffix 検索基準 どれ使う?
ByRole 明示的・暗黙的なARIA roleを検索 基本的にはこれ
ByLabel ラベルテキストを検索
ByPlaceholderText プレースホルダーを検索する
ByText テキストを検索する 次候補はこれ
ByDisplayValue 現在の値から検索
ByAltText alt属性を検索
ByTitle title属性を検索
ByTestId data-testid属性を検索 これはお勧めできない最後の手段
mu0363mu0363

Custom Matcher

基本のMatcherは以下を参照。
https://github.com/testing-library/jest-dom

 

以下のコンポーネントをテストしたい。

export const FormData = () => {
  return (
    <div>
      <button>Do something</button>
      <form aria-label="form">
        <button>Save</button>
        <button>Cancel</button>
      </form>
    </div>
  );
}

withinでラップすればいいが、毎回これを書くのは大変なので...

test("the form display two buttons", () => {
  render(<FormData />);
  const form = screen.getByRole("form");
  const buttons = within(form).getAllByRole("button");

  expect(buttons).toHaveLength(2)
})

カスタムマッチャーを使ってテストを書く!
テスト全体で使えるようにjest.setup.tsにカスタムマッチャーを追加する

jest.setup.ts
import "@testing-library/jest-dom/extend-expect";
import { expect } from "@jest/globals";
import { within } from "@testing-library/react";
import { server } from "./mocks/server.js";
import type { ByRoleMatcher } from "@testing-library/react";
import type { MatcherFunction } from "expect";

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

const toContainRole: MatcherFunction<[role: ByRoleMatcher, quantity: number]> = (
  actual,
  role,
  quantity
) => {
  const element = within(actual as HTMLElement).queryAllByRole(role);

  if (element.length === quantity) {
    return { message: () => "true", pass: true };
  }

  return {
    message: () =>
      `Expected to find ${quantity} ${role} elements. Found ${element.length} instead.`,
    pass: false,
  };
};
expect.extend({ toContainRole });
jest.config.ts
const customJestConfig = {
  ...
  setupFilesAfterEnv: ["./jest.setup.ts"],
  ...
};

型を追加

src/types/jest.d.ts
/* eslint-disable @typescript-eslint/no-empty-interface */
import { ByRoleMatcher } from "@testing-library/react";

interface CustomMatchers<R = unknown> {
  toContainRole(role: ByRoleMatcher, quantity: number): R;
}

declare global {
  namespace jest {
    interface Expect extends CustomMatchers {}
    interface Matchers<R> extends CustomMatchers<R> {}
    interface InverseAsymmetricMatchers extends CustomMatchers {}
  }
}

export {};

1ラインでかける!

test.js
test("the form display two buttons", () => {
  render(<FormData />);
  const form = screen.getByRole("form");

  expect(form).toContainRole("button", 2); 
})

https://jestjs.io/docs/expect#custom-matchers-api
https://qiita.com/shunexe/items/7dcf8b5f7c75bc11c55f
https://github.com/shunexe/jest-custom-matcher-sample/blob/main/jest.d.ts

【エラー遭遇】form要素が見つからない

Unable to find an accessible element with the role "form"のエラーが出たら、form要素にaria-label="form"を追加するとエラーが消える。
https://github.com/testing-library/dom-testing-library/issues/474

mu0363mu0363

非同期のテストについて

useEffectなどでデータを取得する場合にact(() => { ...のような警告が出る場合がある。
これは内部で非同期処理が行われているため。解決法はいくつかあるが1がBestで3がWorst

1 findBy findAllBy

ほどんどのケースはfindBy findAllByで解決させる。これらを用いることで、コンポーネントで行われているデータ取得が終了したことを検出することができる。

test.js
const renderComponent = () => {
  const repository = {
    full_name: "facebook/react",
    language: "Javascript",
    description:
      "A declarative, efficient, and flexible JavaScript library for building user interfaces.",
    owner: {
      login: "facebook",
    },
    name: "react",
    html_url: "https://github.com/facebook/react",
  };
  render(
    <MemoryRouter>
      <RepositoriesListItem repository={repository} />
    </MemoryRouter>
  );
  return { repository };
};

test("shows a link to the github homepage for this repository", async () => {
  const { repository } = renderComponent();
  // actエラー回避
  await screen.findByRole("img", { name: /javascript/i });
  const link = screen.getByRole("link", { name: /github repository/i });
  expect(link).toHaveAttribute("href", repository.html_url);
});

test("shows a fileIcon with the appropriate icon", async () => {
  renderComponent();
  const icon = await screen.findByRole("img", { name: /javascript/i });
  expect(icon).toHaveClass("js-icon");
});

test("shows a link to the code editor page", async () => {
  const { repository } = renderComponent();
  // actエラー回避
  await screen.findByRole("img", { name: /javascript/i });
  const link = await screen.findByRole("link", {
    name: new RegExp(repository.owner.login),
  });
  expect(link).toHaveAttribute("href", `/repositories/${repository.full_name}`);
});
component.js
    <a href={repository.html_url} aria-label="github repository">
       <MarkGithubIcon />
     </a>

2 Module Mocks

テストしたい箇所に直接関係ないところでエラーが発生している場合、その箇所をモックを用いて解決する。jest.mockは第一引数で指定したコンポーネントを実際にはインポートせず、return内の要素を返すようにしてくれる。

...
jest.mock("../tree/FileIcon.js", () => {
  // FileIcon.jsのコンテンツ 
  return () => {
    return "File Icon Component";
  };
});

3 act

まじでこの方法は使いたくない。**最後の切り札。**このテクニックは使わないことをおすすめする。
詳細はUdemyで。

mu0363mu0363

fetchが関連するテスト(msw)

mswを用いてAPIをインターセプトする。実際のプロジェクトではたくさんのmockルートを定義しなければいけないので以下のようなcreateServer関数でセットアップを効率的にする。

__test__/server.js
import { rest } from "msw";
import { setupServer } from "msw/node";

export const createServer = (handlerConfig) => {
  const handlers = handlerConfig.map((config) => {
    return rest[config.method || "get"](config.path, (req, res, ctx) => {
      return res(ctx.json(config.res(req, res, ctx)));
    });
  });

  const server = setupServer(...handlers);

  beforeAll(() => server.listen());
  afterEach(() => server.resetHandlers());
  afterAll(() => server.close());
};

テストファイルで読み込むとクリーンな記述になる!

HomeComponent.test.js
import { createServer } from "../test/server";

createServer([
  {
    path: "/api/repositories",
    method: "get",
    res: (req, res, ctx) => {
      const language = req.url.searchParams.get("q").split("language:")[1];
      return {
        items: [
          { id: 1, full_name: `${language}_one` },
          { id: 2, full_name: `${language}_two` },
        ],
      };
    },
  },
]);

test("something to expect", () => {
   ...
})

createServerはdescribeのスコープでラップするとより良い

createServer関数にテストの初期化をまとめたのは、単純にキレイになるだけじゃない。testファイル内で使用するときにdescribeのスコープでラップするとdescribeごとにサーバーを初期化してくれる。
そのため同じファイル内でdescripeスコープごとに異なるレスポンスを返すことができる。

test.js
const renderComponent = () => {
  return (
    <MemoryRouter>
      <AuthButtons />
    </MemoryRouter>
  );
};

describe("when user is not signed in", () => {
  createServer([
    {
      path: "/api/user",
      method: "get",
      res: (req, res, ctx) => {
        return { user: null };
      },
    },
  ]);

  test("when user is not signed in, signin and signup are visible", async () => {
    renderComponent();
  });

  test("when user is not signed in, signout is not visible", async () => {
    renderComponent();
  });
});

describe("when user is signed in", () => {
  createServer([
    {
      path: "/api/user",
      method: "get",
      res: (req, res, ctx) => {
        return { user: { id: 3, email: "test@gmail.com" } };
      },
    },
  ]);

  test("when user is not signed in, signin and signup are not visible", async () => {
    renderComponent();
  });
  test("when user is not signed in, signout is visible", async () => {
    renderComponent();
  });
});
mu0363mu0363

外部ライブラリー使用時のテスト

外部ライブラリ(例: SWRなど)が関連するテストは間違いなく大半の時間を費やすことになる 😫
もしswrでのuser取得をモックしてテストが失敗した場合、ライブラリーがjest用にうまく動いてはくれないことを認識する必要がある。 以下、解決方法の戦略を順に記載する。

1. describe.only test.only

スコープを絞ることで、失敗箇所を限定させる。

2. debuggerの設定

以下をpackage.jsonscriptsへ追加
このコマンドは、テストを特殊なモードで実行し、テストランナーに「すべてのテストをすぐに実行する必要はない」と伝えます。その代わり、最初に見つけたdebugger;で一時停止するようにします。

package.json
  "scripts": {
    ...
    "test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
  },

--inspect-brk

指定ファイルの1行目にブレークポイントを設定した状態にできます。

--runInBand

複数のテストファイルが1つずつ順番に処理されます。

--runInBand

複数のテストファイルが1つずつ順番に処理されます。

--no-cache

キャッシュの利用を無効にする。

調べたいコードの直前にdebugger;を追加

AuthButton.js
function AuthButtons() {
  const { user, isLoading } = useUser();
  
  // これ!!!!!
  debugger;

  if (isLoading) {
    return null;
  } else if (user) {
    return (
   ...

調べたいテストにtest.onlyをつけ、直後にdebugger;を追加

test.js
test.only("signin and signup are visible", async () => {
    debugger;
    await renderComponent();
    const signInButton = screen.getByRole("link", { name: /sign in/i });

3. ブラウザの開発者ツールで調査

ターミナルで*about:inspectを入力
以下の画面に遷移するのでinspectをクリック

開発者ツールが新たに開き、テストの一番最初の行でポーズしている。先程追加したdebugger;まで進みたいので、右上の青い再生ボタンをクリック。

すると追加したdegugger;を順番に推移する。その時の状態のuserを確認。
初期状態userの中身はundefined -> ok。
コンポーネントレンダリング直後null -> まだfetchしてないからok。
useSWRでフェッチ後null -> !!!!!??? これが問題だ!!
コンポーネントもmswもtestも問題ない... 問題は、、、キャッシュや!!

useSWRのキャッシュをリセットする方法をググると出てくる。
https://swr.vercel.app/docs/advanced/cache#reset-cache-between-test-cases

const renderComponent = async () => {
  render(
    <SWRConfig value={{ provider: () => new Map() }}>
      <MemoryRouter>
        <AuthButtons />
      </MemoryRouter>
    </SWRConfig>
  );
  await screen.findAllByRole("link");
};

解決!!!