React Testing LibraryとJestについて学んだことをメモ
Jest:
node.jsでテストを実行する
以下に該当するファイルをtestファイルと認識する
- .spec.js
- .test.js
- __test__ディレクトリ配下のjsファイル
getByRoleのnameオプションの使い方
主に以下の値をマッチさせられる
- 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要素をがっつり巻き込むとテスト実行時間が長くなるので注意
matcher
JestとRTL(React Testing Library)それぞれ独自のものがある
重用しそうなtoBeInTheDocument()はRTLから。
Jest:https://jestjs.io/ja/docs/expect#matchers
RTL:https://github.com/testing-library/jest-dom#custom-matchers
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);
// ボタンクリックを待って何かしらの処理
});
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を使う。
ほぼ同じメソッドが用意されている。
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 });
デバッグ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に直に書き、右側に表示されることでサジェストを確認可能
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);
renderの戻り値をそのまま使うこともできる
eslintのtesting-library/prefer-screen-queries
warningが出るので、推奨されてなさそうではある
const app = render(<UserList users={users} />);
const rows = app.getByRole("button");
aria-role
例外が投げられたことをテストしたい時
// 同期処理
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);
テストの実行手順
通常はファイル同士は並行処理・ファイル内のテストは逐次実行
逐次処理は結構時間がかかるので、ファイル内テストも並行にしたい
it.concurrent
を使っていきたい
この場合、beforeEachやbeforeAll等の挙動を理解しておく必要がありそう
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()
})
この動画の作成者が作ってくれたCLIツールで簡単にJest × RTLを試せる
npx rtl-book serve <適当なファイル名>.js
非同期処理を待った後、要素が存在しないことをテストしたい場合
- queryByだと非同期を待たない
- findByだと要素がないとエラー
waitForElementToBeRemoved
という便利なものがあった
こちらも第二引数で色々設定できる
test('movie title no longer present in DOM', async () => {
// element is removed
await waitForElementToBeRemoved(() => queryByText('the mummy'))
})
カスタムマッチャーの作り方
例として以下のフォームがあり、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 });
By〇〇の正規表現の使い所
例えば以下のdivタグ内で、30
があるか確認したい時
<div>
30
<span>個</span>
</div>
これだと30個
が取れてしまうので意図した通りにならない
screen.getByText('30')
正規表現ならテキストに30が含まれる、という部分マッチを実現可能
screen.getByText(/30/)
// 固定値でない場合
screen.getByText(new RegExp(someObj.amount))
testing-library-selectorを使うと少しだけテストをスッキリ書けそう
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>
);
actとは
reactのレンダリングやuseEffectが走った際に、actで完了を待てる。
act(() => render(<SomeComponent>)
RTLではfindBy/findAllByやwaitFor、userEvent実行時、裏側でactを呼び出している。
そのため、自分でactを呼び出す必要がない。
actのエラー(... not wrapped in act ...)が出た時は、上記メソッドを正しく使って修正する
参考(actの挙動)
beforeAll/beforeEach/afterAll/afterEachは、describe内で宣言した場合、他のdescribeには影響を及ぼさない
describe('hoge',()=>{
// 別のテストスイートでは実行されない
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
test('fuga', ()=>{
// 何かしらのテスト
})
})
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 () => {});
});
ちょっと変わったテストのデバッグ
- package.jsonに
"test:debug": "react-scripts --inspect-brk test --runInBand --no-cache"
と追加 - 確認したいコンポーネントもしくはテストに
debugger
を記述しnpm run test:debug
- ブラウザURLで
about:inspect
と打ち込むとdevtoolが表示されるので、inspectをクリックするとブレークポイントデバッグっぽくできる
この時実行を意味する右向き青矢印ボタンは、押すと次のレンダリングの回にに進む
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>
変数に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'))
logrolesが便利
roleごとの要素一覧と、nameの値まで出してくれるので〇〇ByRoleで要素を取得するのに役立つ
import { logRoles } from '@testing-library/dom'
logRoles(screen.GetByRole('dialog'))
カスタムフックのテスト
コンポーネントを描かなくてもテスト可能
データフェッチを伴うようなケースではwaitFor
をうまく組み合わせると良さそう
selectOptions
selectタグ or liタグで候補を選択可能。
コンボボックスとかで使いやすそう
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('普通')
なるべくByTestIdは使用しない
- テストが実装を知りすぎる
- ユーザビリティの観点に沿ったテストにならない
- 公式でも他に手段がない場合のみ使用すべきとのこと
logRolesでroleを確認しつつ、ByRoleを使おう。