Open29

React Testing LibraryとJestについて学んだことをメモ

RyotaRyota

Jest:
node.jsでテストを実行する

以下に該当するファイルをtestファイルと認識する

  • .spec.js
  • .test.js
  • __test__ディレクトリ配下のjsファイル
RyotaRyota

getByRoleのnameオプションの使い方

https://testing-library.com/docs/queries/byrole/

主に以下の値をマッチさせられる

  • labelのテキスト(input要素)
    ※label自身ではなく、そのlabelに紐づいたinput要素を取得する
  • aria-labelの値
  • text content
    ※getByTextでも対応可能だが、getByRoleの方がベターな方法であるとのこと

aria-labelとtext content双方の指定がある場合、aria-labelでしかマッチできなくなるっぽい

<button aria-label="hoge">fuga</button>
// aria-labelなのでマッチする
screen.getByRole('button', { name: 'hoge' })
// aria-labelの値が設定されているので、マッチしない
screen.getByRole('button', { name: 'fuga' })

nameには正規表現の指定が可能

※ほとんどのメソッドのstring引数部分は正規表現指定できるっぽい
文字列と異なり部分マッチができるのが便利

const button = screen.getByRole("button", { name: /hoge/i });

第一引数にも指定できる

const button = screen.getByRole(/button/i, { name: /hoge/i });

nameには関数も指定可能

// id属性の値がhogeの要素を取得できる
const row = screen.getByRole('row', {
  name: (_, el) => el.id === 'hoge',
})

ループ内で特定行を取得したい場合は予めdata属性とかに行番号を振っておけば楽に取れそう

for (const [i, value] of Object.entries(someArray)) {
  const row = screen.getByRole('row', {
    // data属性はElementオブジェクトには存在しないので、HTMLElementにキャスト
    name: (_, el) => (el as HTMLElement).dataset.index === String(i),
  })
  // 何らかのテスト
}

※ただし上記のようにdata属性等dom要素をがっつり巻き込むとテスト実行時間が長くなるので注意

RyotaRyota

RTLのuser eventはv14から非同期になったので、async/awaitが必要

test("type name and email then submit", async () => {
  render(<UserForm />);

  const button = screen.getByRole("button", { name: /add user/i });

  await userEvent.click(button);
  // ボタンクリックを待って何かしらの処理  
});
RyotaRyota

Jestのmock

関数をモックしたい時はjest.fn()を使える

test("sample", () => {
  const mock = jest.fn();
  render(<UserForm onUserAdd={mock} />);

  // onUserAddを呼び出す処理

  expect(mock).toHaveBeenCalled();
  expect(mock).toHaveBeenCalledWith({ name: "ryota", email: "example@com" });
})

コンポーネントをテストする際に、子コンポーネントをmockしたい場合

const Main = () => {
  return (
    <>
      <div>
        <Sub />
      </div>
    </>
  );
};

該当コンポーネントのパスとmock関数を登録

jest.mock("./Sub", () => {
  return () => {
    return "Sub Component";
  };
});

vitestを使用している場合はjestグローバル変数が使えないので、代わりにviを使う。
ほぼ同じメソッドが用意されている。
https://vitest.dev/guide/mocking.html#functions

RyotaRyota

inputの取得方法2選

<label htmlFor="email">Email</label>
<input
  id="email"
  value={email}
  onChange={(e) => setEmail(e.target.value)}
/>

いずれもlabel要素ではなく、該当するテキストを持つlabelに紐づいているinput要素を取得する

// 方法1
const emailField1 = screen.getByLabelText(/email/i);
// 方法2(公式はこちらを推奨)
const emailField2 = screen.getByRole("textbox", { name: /email/i });
RyotaRyota

デバッグ2パターン

1. screen.debug()

// 表示範囲を絞りたい場合
screen.debug(screen.GetByRole('button'))
// 範囲内全てのelementを表示したい場合(デフォルトでは途中で切れてしまう)
screen.debug(screen.GetByRole('button'), Infinity)
// 第一引数省略で全体を表示可能
screen.debug(undefined, Infinity)

2. screen.logTestingPlaygroundURL()

テスト実行時にコンソールにURLが表示され、ブラウザでレンダリング状態を確認できる。

  render(<UserList users={users} />);

  screen.logTestingPlaygroundURL();
  • eslintエラーが出るが、無視してOK(気になるならeslint-disable-next-line
  • それぞれの要素の取得方法をサジェストしてくれる
  • テキストを持たないタグ(例えばtable内のtr等)は、style属性をブラウザ上のHTMLに直に書き、右側に表示されることでサジェストを確認可能
RyotaRyota

getByRoleで正しく取得できない場合の対処法2選

nameオプションでも区別できないケース

例:以下table コンポーネントのケースでtbody配下のtrだけ取りたい
getAllByRole('row')ではtheadの方も取得してしまう

<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Email</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>ryota</th>
      <th>example@com</th>
    </tr>
  </tbody>
</table>
const rows = screen.getAllByRole("row");
// 実際は2で失敗
expect(rows).toHaveLength(1);

解決策1 testidを使う

<tbody data-testid="body">
  <tr>
    <th>ryota</th>
    <th>example@com</th>
  </tr>
</tbody>

body配下のtrだけ取得できる

const rows = within(screen.getByTestId("body")).getAllByRole("row");
expect(rows).toHaveLength(1);

ちなみにvitestのコンフィグファイルでTestIdを別の属性(例えばid属性)に紐づけられるっぽい

// getByTestIdが呼ばれた場合、data-testidではなくid属性が一致する要素を探しにいく
configure({ testIdAttribute: 'id' })

※title属性もどのHTML要素にも使用可能なので、titleを設定しByTitleで取得することも可能

解決策2 containerを使う

前提として、testing-libraryのrender関数で描画する際、一番外側にdivタグが追加され、componentをラップしている
このdivをテストコード内で活用する

// renderによりdivが追加
<div>
  // renderに渡しているコンポーネントの中身
  <table>
    <thead>
      <tr>
        <th>Name</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th>ryota</th>
      </tr>
    </tbody>
  </table>
</div>

通常のJSと同様にDOMを取得できる

const { container } = render(<UserList users={users} />);
// eslintのtesting-library/no-containerエラーが出るので、あまり推奨されてなさそう
const rows = container.querySelectorAll("tbody tr");
expect(rows).toHaveLength(1);
RyotaRyota

renderの戻り値をそのまま使うこともできる
eslintのtesting-library/prefer-screen-querieswarningが出るので、推奨されてなさそうではある

const app = render(<UserList users={users} />);
const rows = app.getByRole("button");
RyotaRyota

例外が投げられたことをテストしたい時

// 同期処理
expect(()=>screen.getByRole('textbox')).toThrow();

// 同期処理かつ2個以上の要素がある時(0個だと例外ではなくnullになるため)
expect(()=>screen.queryByRole('textbox')).toThrow();
// queryAllByは例外を投げない(0個でも[]になるため)
expect(()=>screen.queryAllByRole('textbox')).toHaveLength(0);

// 非同期処理(もっと上手く書けるはず)
let errorThrown = false;
try {
  await screen.findByRole('textbox');
} catch(err) {
  errorThrown = true;
}
expect(errorThrown).toEqual(true);
RyotaRyota

テストの実行手順
https://qiita.com/noriaki/items/5d800ea1813c465a0a11

通常はファイル同士は並行処理・ファイル内のテストは逐次実行
逐次処理は結構時間がかかるので、ファイル内テストも並行にしたい
it.concurrentを使っていきたい

この場合、beforeEachやbeforeAll等の挙動を理解しておく必要がありそう

RyotaRyota

waitForの仕様

  • 第一引数がエラーを投げなくなるまでリトライする(リトライ上限とタイムアウト上限の制限内)
  • 上記仕様によりfindByだけでなくgetByやqueryByでも非同期要素の取得が可能)
  • 第二引数でオプションを指定できるが、containerで対象範囲を絞るのが便利そう
await waitFor(
  async () => {
    // getByでも要素が取れるまで繰り返し実行してくれる
    expect(app.getByText('更新に成功しました')).not.toBeInTheDocument()
  },
  // dialog要素内に絞る
  { container: app.getByRole('dialog') },
)

複数回実行されるという性質上、以下のようなケースではiがインクリメントしてしまうので注意

let i: number
await waitFor(async () => {
  i++
  console.log(i) // 実行ごとに1増えている
})

user eventとかも同様なので、どこまで1つのwaitForに含めるかは注意が必要

await waitFor(async () => {
  const btn = screen.getByRole('button')
  await userEvent.click(btn) // めっちゃクリックされる

  // 時間がかかる非同期処理
})

queryBy系は要素が見つからない場合タイムアウトまでやるとめちゃ時間かかりそう

await waitFor(async () => {
  console.log('click')
  expect(app.queryAllByRole('hogehoge')).not.toBeInTheDocument()
})
RyotaRyota

非同期処理を待った後、要素が存在しないことをテストしたい場合

  • queryByだと非同期を待たない
  • findByだと要素がないとエラー

waitForElementToBeRemoved という便利なものがあった
こちらも第二引数で色々設定できる

test('movie title no longer present in DOM', async () => {
  // element is removed
  await waitForElementToBeRemoved(() => queryByText('the mummy'))
})
RyotaRyota

カスタムマッチャーの作り方

例として以下のフォームがあり、textboxが正しい個数存在していることをテストしたい

function PostPage() {
  return (
    <form aria-label="form">
      <input type="text" />
      <input type="number" />
      <textarea></textarea>
    </form>
  )
}
render(<PostPage />);

以下のような感じではカスタムマッチャーとして作成したtoContainRoleを呼び出すとする

test('form contains correct number of textbox', () => {
  render(<PostPage />);

  const form = screen.getByRole('form');

  expect(form).toContainRole('textbox', 2);
});

toContainRoleの中身

// 第一引数にはexpectの引数を受け取れる
function toContainRole(container, role, quantity = 1) {
  const elements = within(container).queryAllByRole(role);

  if (elements.length === quantity) {
    // 成功の場合はtrue
    return {
      pass: true
    };
  }

  // 失敗の場合はfalseとメッセージ
  return {
    pass: false,
    message: () => `Expected to find ${quantity} ${role} elements. Found ${elements.length} instead.`
  }
}

// マッチャーを登録しておく
expect.extend({ toContainRole });
RyotaRyota

By〇〇の正規表現の使い所

例えば以下のdivタグ内で、30があるか確認したい時

<div>
    30
    <span></span>
</div>

これだと30個が取れてしまうので意図した通りにならない

screen.getByText('30')

正規表現ならテキストに30が含まれる、という部分マッチを実現可能

screen.getByText(/30/)

// 固定値でない場合
screen.getByText(new RegExp(someObj.amount))
RyotaRyota

renderで呼び出すコンポーネントがLinkを含んでいる場合、react router domのcontextが無いとエラーを吐く

function SomeComponentWithLink() {
  return (
    <div>
        <Link to={`/about`}>
          <span>About</span>
        </Link>
    </div>
  );
}

// エラー
render(<SomeComponentWithLink />);

BrouserRouter/HashRouter/MemoryRouterのいずれかで囲む

import { MemoryRouter } from "react-router-dom";

render(
  <MemoryRouter>
    <SomeComponentWithLink />
  </MemoryRouter>
);
RyotaRyota

actとは

reactのレンダリングやuseEffectが走った際に、actで完了を待てる。

act(() => render(<SomeComponent>)

RTLではfindBy/findAllByやwaitFor、userEvent実行時、裏側でactを呼び出している。
そのため、自分でactを呼び出す必要がない。
actのエラー(... not wrapped in act ...)が出た時は、上記メソッドを正しく使って修正する

参考(actの挙動)
https://github.com/mrdulin/react-act-examples/blob/master/sync.md

RyotaRyota

beforeAll/beforeEach/afterAll/afterEachは、describe内で宣言した場合、他のdescribeには影響を及ぼさない

describe('hoge',()=>{

  // 別のテストスイートでは実行されない
  beforeAll(() => {
    server.listen();
  });
  afterEach(() => {
    server.resetHandlers();
  });
  afterAll(() => {
    server.close();
  });

  test('fuga', ()=>{
    // 何かしらのテスト
  })
})
RyotaRyota

describe.onlyやtest.onlyでそのテストだけ実行可能
describeにonlyがついていなくても、test.onlyは実行される点注意

describe("group1", () => {
  // 実行される
  test.only("test1-1", async () => {});
  // 実行されない
  test("test1-2", async () => {});
});

describe.only("group2", () => {
  // 実行される
  test.only("test2-1", async () => {});
  // 実行されない
  test("test2-2", async () => {});
});
RyotaRyota

ちょっと変わったテストのデバッグ

  1. package.jsonに"test:debug": "react-scripts --inspect-brk test --runInBand --no-cache"と追加
  2. 確認したいコンポーネントもしくはテストにdebuggerを記述しnpm run test:debug
  3. ブラウザURLでabout:inspectと打ち込むとdevtoolが表示されるので、inspectをクリックするとブレークポイントデバッグっぽくできる

この時実行を意味する右向き青矢印ボタンは、押すと次のレンダリングの回にに進む

RyotaRyota

SWR等のデータフェッチライブラリのキャッシュにより、テストが意図した挙動にならないケースがある。
公式ドキュメント

例としてこんな感じ

test('ログイン済み',()=>{
    // ここではコンポーネント内でログインユーザーを取得しているとする
    render(<UserComponent/>)

    // 成功する
    expect(screen.getByText(user.name)).toBeInTheDocument()
})
test('未ログイン',()=>{
    // ここではログインユーザーが取れないはず
    render(<UserComponent/>)

    // 一つ目のテストで取得したユーザーをキャッシュしており、UserComponentではuserが取得できてしまっている。
    // そのためこのテストは失敗する
    expect(screen.getByText(user.name)).not.toBeInTheDocument()
})

この場合は該当テストのrender関数内でUserComponentをwrapする必要がある

<SWRConfig value={{ provider: () => new Map() }}>
  <UserComponent />
</SWRConfig>
RyotaRyota

変数にDOM要素を代入した後にuserEventで値を変更し、debugすると値が変わっていないことがある
この場合は再レンダリングが走ったことにより新しいDOMが生成され、そちらの値が最新になっているはず

// hogeと入力されているinputを代入
const input = await screen.findByRole('textbox')

// valueにはhogeが入っている
screen.debug(input)

// 同一DOMなのでtrue
console.log(input === (await screen.findByRole('textbox')))

// 値を入力し、再レンダリングが走る
await userEvent.type(input, 'fuga')

// 再レンダリング前のDOMなので、valueはhogeのまま
screen.debug(input)

// 異なるDOMなのでfalse
console.log(input === (await screen.findByRole('textbox')))

// 改めて取得するとvalueにはfugaが入っている
screen.debug(await screen.findByRole('textbox'))
RyotaRyota

logrolesが便利

roleごとの要素一覧と、nameの値まで出してくれるので〇〇ByRoleで要素を取得するのに役立つ

import { logRoles } from '@testing-library/dom'

logRoles(screen.GetByRole('dialog'))
RyotaRyota

selectOptions

selectタグ or liタグで候補を選択可能。
https://testing-library.com/docs/user-event/utility/#-selectoptions-deselectoptions

コンボボックスとかで使いやすそう

const accountType = await app.findByRole('combobox', { name: '口座種別' })
expect(accountType).toHaveValue('')

await user.click(accountType)

const list = await app.findByRole('listbox', { name: '口座種別' })
expect(list).toBeInTheDocument()

await user.selectOptions(list, '普通')

expect(accountType).toHaveValue('普通')
RyotaRyota

なるべくByTestIdは使用しない

https://testing-library.com/docs/queries/bytestid/

  • テストが実装を知りすぎる
  • ユーザビリティの観点に沿ったテストにならない
  • 公式でも他に手段がない場合のみ使用すべきとのこと

logRolesでroleを確認しつつ、ByRoleを使おう。