とりあえずtoBeTruthy()で通していませんか
Playwrightでテストを書き始めた頃、私も似たようなコードを量産していました。
expect(await page.getByTestId('status').isVisible()).toBeTruthy();
動きます。テストも一応グリーンになります。ですが、これで本当に「見えている」ことを確認できているのでしょうか。実はこの書き方、Playwrightが持っている一番大事な仕組みを自分で捨ててしまっています。
この記事では、なぜtoBeTruthyだと事故るのか、代わりに何を使えばいいのか、そして物足りなくなってきた人向けにexpect.extendで自作マッチャーを作る一歩目まで、順番に見ていきます。
toBeTruthyだと何がまずいのか
先ほどのコードをもう一度見てみましょう。isVisible()が実行された瞬間の状態を、その場でtrue/falseの値として取り出しています。awaitを先に付けているので、値がexpectに渡る時点でPlaywrightとの接続はもう切れています。ただのbooleanです。
問題は、この後にPlaywrightの「web-first assertion」が持つ自動リトライが一切効かないことです。表示が200ミリ秒後に遅れて出てくる要素なら、判定した瞬間はfalseなのでテストは落ちます。CIだけ落ちる原因の定番でもあります。
正しい書き方はこうです。
await expect(page.getByTestId('status')).toBeVisible();
ロケーターをそのままexpectに渡し、awaitはexpectの前に置きます。こうするとPlaywrightはデフォルトで5秒間、条件が満たされるまで内部で何度も再チェックしてくれます。この「locatorを直接渡す」形が本来のweb-first assertionで、toBeTruthyはこの仕組みの外側にいるただの汎用マッチャーだと考えてください。
意味のあるマッチャーに乗り換える
「trueかどうか」ではなく「何を確認したいか」でマッチャーを選ぶと、テストが失敗した時のエラーメッセージも格段に読みやすくなります。私がよく使うものを挙げます。
// 要素が見えているか
await expect(page.getByRole('button', { name: '送信' })).toBeVisible();
// テキストが完全一致するか
await expect(page.getByTestId('status')).toHaveText('完了');
// テキストが部分一致するか
await expect(page.getByTestId('status')).toContainText('完了');
// 件数を確認する(リストの行数チェックなどに便利)
await expect(page.getByRole('listitem')).toHaveCount(3);
// 有効/無効状態
await expect(page.getByRole('button', { name: '送信' })).toBeEnabled();
// input要素の値
await expect(page.getByLabel('メールアドレス')).toHaveValue('test@example.com');
// URLの確認
await expect(page).toHaveURL(/\/thanks$/);
どれもロケーターをそのまま渡す形になっているのが分かると思います。私はこの中でもtoHaveCountを後から知って一番驚きました。件数チェックのために.count()を自分で呼んでtoBeでの数値比較をしていた時期があったのですが、これも先ほどのtoBeTruthyと同じ罠でした。toHaveCountなら件数が確定するまでの過渡状態ごとリトライしてくれるので、素直にこちらを使うべきでした。
マッチャーの全量はPlaywrightのexpectマッチャー完全リファレンスにまとめてあるので、迷った時はそちらを見てください。
エラーメッセージの読みやすさが段違いになる
マッチャーを乗り換える一番の実利は、失敗した時に何が起きたか一目で分かることです。toBeTruthyのままだと、失敗時のメッセージはだいたいこうなります。
Expected: truthy
Received: false
これだけ見せられても、何が見えなかったのか、どの要素の話なのか一切分かりません。私はこのエラーが出るたびに、テストコードまで遡ってどのロケーターだったか確認する羽目になっていました。
一方、toBeVisibleで書いたテストが失敗すると、メッセージはこう変わります。
Timed out 5000ms waiting for expect(locator).toBeVisible()
Locator: getByTestId('status')
Expected: visible
Received: hidden
どのロケーターを、何を期待して、実際どうだったかまで書いてあります。CIのログだけでその場で原因が分かるので、調査の往復が1回減ります。
expect.extendで自分のマッチャーを作ってみる
組み込みマッチャーに慣れてくると「これ、うちのプロジェクトだと毎回同じ書き方をしている」というチェックが出てきます。ここで使えるのがexpect.extendです。設定ファイルで登録するのが基本の形です。
// playwright.config.ts
import { expect } from '@playwright/test';
expect.extend({
toBeWithinRange(received: number, floor: number, ceiling: number) {
const pass = received >= floor && received <= ceiling;
return {
pass,
message: () =>
pass
? `expected ${received} not to be within range ${floor} - ${ceiling}`
: `expected ${received} to be within range ${floor} - ${ceiling}`,
};
},
});
返す値はpass(真偽)とmessage(失敗時のメッセージ関数)の2つだけです。テスト側ではこう呼び出します。
test('numeric ranges', () => {
expect(100).toBeWithinRange(90, 110);
expect(200).not.toBeWithinRange(90, 110);
});
実務で欲しくなるのはだいたいドメイン固有の判定です。「日付表示が『◯分前』形式になっているか」のような、何度も書いている確認をマッチャーにまとめておくと、テストコード側はtoBeRelativeTimeFormat()のような読みやすい呼び出しだけになります。
(正直に書いておくと、locatorを受け取ってPlaywright本来の自動リトライまで自前で再現するカスタムマッチャーは、書き方によっては少し癖があります。私はまず「リトライ不要な値の検証」からexpect.extendを始めるのをおすすめします)。
どう使い分けるか
私なりの判断基準はこうなります。
- ページ上の要素の状態(見える・消える・テキスト・件数・有効無効)を確認したい → 組み込みのweb-firstマッチャーをまず探す。だいたい用意されています。
- 非同期で結果が来ないapiレスポンスやリトライしたい条件 →
expect.pollやexpect(...).toPass()を検討する。 - プロジェクト固有の判定を何度も書いている → expect.extendでカスタムマッチャーにまとめる。
- リトライが要らない単なる数値・文字列の一致 → 組み込みの汎用マッチャー(toBe、toEqual)で十分。toBeTruthyもここでなら使い所はあります。
toBeTruthy自体が完全に悪者というわけではありません。問題になるのは、ロケーターの状態確認という「本来リトライすべき対象」に対して、awaitで先に評価を済ませてから使ってしまうケースです。ここさえ意識できれば、卒業というより「使い所を選べるようになる」というのが正確な言い方かもしれません。
演習
手元のプロジェクトでtoBeTruthyを使っているテストを1つ探し、toBeVisible()に書き換えてみてください。わざとセレクタを間違えて実行し、エラーメッセージがどう変わるか見比べてみましょう。ヒント:ロケーターをそのままexpectに渡し、awaitはexpectの前に置きます。
まとめ
toBeTruthyでとりあえず通していたテストを見直すと、エラーメッセージの情報量とリトライの有無という2つの実利がついてきます。私自身、昔書いたテストを読み返すたびに「これtoBeVisibleで書き直した方がいいな」という箇所がまだ残っています。
expect.extendはまだ使い始めたばかりで、社内の共通マッチャーとして整備しきれてはいません。もし便利なカスタムマッチャーの実例があれば、コメントで教えてもらえると嬉しいです。
マッチャーの一覧はexpectマッチャー完全リファレンスの記事に、AIにテストを書かせた時の落とし穴はレビューの落とし穴の記事にまとめてあるので、合わせて読んでみてください。


コメント