フロントエンドテストを雰囲気で書いているとハマりがちなところ


この記事の対象読者

  • Webフロントエンドのテストコードを雰囲気で書いてる人

この記事の前提

  • テストフレームワークは Jest の利用を想定しています
  • Jest自体のセットアップや使い方は一切触れていません

フロントエンドテスト、慣れてないとハマりがち

経験上、フロントエンドのテストコードを書く際には、慣れていないとハマったり混乱してしまうポイントが多くあると思っています。

  • 僕のdivタグ書き換えるコードがテストだと動かない
  • エラーになるテストなのにパスしちゃう

慣れてくると何でもない部分ではありますが、基本的な考え方や躓きやすい箇所を整理してみました。

フロントエンドのテストコードはNode.js上で実行される

フロントエンド開発では、実行環境として主にブラウザを対象とすることが多いでしょう。つまりWindowオブジェクトの利用やDOM操作が可能です。(たとえば location.href, document.querySelector など)

しかし、Jestをはじめとするフロントエンド向けのテストフレームワーク・ライブラリは、puppeteer等を利用しない限りはNode.jsが実行環境となります。そのため、ブラウザを前提とする機能は基本的には利用できません。

たとえば次のようなコードは、そのままではテストできません。

export const sum = () => {
  const input1 = document.querySelector('.input1');
  const input2 = document.querySelector('.input2');
  const text = document.querySelector('.text');

  text.textContent = `sum: ${Number(input1.value) + Number(input2.value)}`;
};

テスト対象のコードがブラウザに依存しているのかどうかをまず意識してみましょう。

じゃあどうやってテストをするの?

実行環境に依存する部分とそうでない部分を切り分ける

まずブラウザに依存しないコアロジック部分をできるだけ切り離せないか検討するところから始めてみましょう。

DOMに依存していると言っても、コードを深く知ると、実際にはコアロジック部分だけ単純なfunctionに切り出せることも多いです。

たとえば先ほどのコードであれば、テキスト生成部だけは別に切り出せます。

export const sum = () => {
  const input1 = document.querySelector('.input1');
  const input2 = document.querySelector('.input2');
  const text = document.querySelector('.text');

  text.textContent = getText(input1.value, input2.value);
};

export const getText = (v1, v2) => `sum: ${Number(v1) + Number(v2)}`;

これで、部分的な検証が可能になります。

test('getText', () => {
  expect(getText('1', '2')).toBe('sum: 3')
});

とはいえ、過度な分割はそれはそれで可読性が落ちることもあるので、本質的に意味のある修正かは意識しておきましょう。

jsdomを使う

切り出してコアロジック部だけ検証できるのは理想的ですが、現実的には難しいこともあります。リファクタリング時などは、手を加える前のコードで動作を担保するテストを追加したくなるでしょう。

じゃあブラウザ依存のDOM操作部分は一切テストできなくて諦めるしかない?というと、全然そんなことはなく、Node.js上で利用可能な代替実装を用いることでカバーできます。

最も利用されているのは jsdom でしょう。

Jestを利用した場合、デフォルトでjsdomが利用可能です。

test('sum', () => {
  document.body.innerHTML = `
    <input type="text" value="1" class="input1" />
    <input type="text" value="2" class="input2" />
    <div class="text"></div>
  `

  sum();

  expect(document.querySelector('.text').textContent).toBe('sum: 3');
});

注意: DOM操作以外はカバーできない

jsdomがサポートするのはあくまでも「DOM操作」のみである点に注意が必要です。次のようなものは、テストで動かなくてハマりがちです。

  • window.confirm でのブラウザ標準のダイアログ表示
  • window.scrollY などを利用したスクロール制御
  • location.href での画面遷移
  • など

これらをテストする際は、自力で global.window にモックを定義しておくなどの工夫が必要となります。

window.alert を呼ぶ簡単なコードを例に考えると

export const showError = (text) => {
  window.alert(text);
};

次のようにモックに置き換えることで検証が可能です。

test('showError', () => {
  const alert = jest.fn();
  global.window.alert = alert;

  showError('message');

  expect(alert.mock.calls).toEqual([['message']]);
});

非同期かどうかに注意する

フロントエンド開発での初心者殺し代表が非同期処理でしょう。

非道鬼、もとい非同期処理そのものに関しては @Yametaro さんの次のエントリをみた方が早いと思います。

参考 : 4歳娘「パパ、Promiseやasync/awaitって何?」〜Promise編〜 - Qiita

テストにおいても、非同期処理を対象とする場合には注意が必要です。テスト対象が次のようなコードの場合は非同期処理が絡んでいる可能性が高いでしょう。

  • Promise を返す
  • async functionである (= Promiseを返す)
  • setTimeout, setInterval, setImmediate, requestAnimationFrame などを使う
  • イベント処理 ( EventTarget.dipatchEvent など) を使う
  • 通信する (XHR, fetch, WebSocket など)

たとえば(普段は書くことは無さそうですが)次のようなコードを例に考えます。

export const asyncFunc = async () => {
  const value = await 100;
  return value;
};

await 100 は、await Promise.resolve(100) と同じ挙動をします。

このコードに対し、次のようなテストコードを用意してもテストはパスしません。

test('asyncFunc', () => {
  const value = asyncFunc();
  expect(value).toBe(100);
});

テスト対象が async function で Promise を返すため、 次のように書く必要があります。

test('asyncFunc', async () => {
  const value = await asyncFunc();
  expect(value).toBe(100);
});

「なんだかうまくテストがパスしないぞ?」という時は非同期が絡んでいることが割と多いです。これは頻繁に出会うので、書いているうちに感覚的に慣れてくると思います。

しかし、個人的にはこちらよりも厄介に感じているのが次のパターンです。

エラーにならないケースがある

非同期処理の場合に「テストが通らない!」とハマることは多いですが、その場合は「通らない」というフィードバックがあるだけまだマシです。非同期処理のテストでもっと厄介なのは「本当は失敗しているのにテストがパスしている」というケースでしょう。

たとえば、さきほどのコードについて次のようなテストコードを書いたとします。

test('asyncFunc', () => {
  const value = asyncFunc();
  expect(value < 10).toBe(false);
});

書いた人の気持ちとしては「値が10未満ではないことを確認したい」といった感じです。

このテストコードは実行するとパスしてしまいます。

理由は簡単で、value の値は Promiseオブジェクトだからです。Promise < 10 を検証しているため、これは false となりテスト的にはOKと判断されます。

このようなコードを残してしまうと、後々気づいた場合に「テストが正しいのかテスト対象が正しいのか区別ができない」といった状況に陥るリスクがあるため、テスト書く時点で注意が必要です。

回避策その1 / 一発でパスしたら疑って RED→GREEN を意識する

私はテストコードを書く際、もし一発でテストがパスしたら「ホントか?」と疑っています。そのときは、意図的にテスト対象コードを書き換えたりして、テストが正しく落ちることを確認します。

さきほどのケースで、テスト対象を次のように書き換えてみます。

export const asyncFunc = async () => {
  const value = await 1;
  return value;
};

この場合、1 < 10true となりテストは失敗してほしいですが、変わらずテストがパスするため、「あれ?何かおかしくない?」と気付くことができます。

一度テストを落ちる状態(RED)にしてからパスさせる(GREEN)のは、@t_wada さんが提唱されているTDDにおける「黄金の回転」を参考にしています。

参考: TDD のこころ

回避策その2 / 「〜ではない」を検証するテストを書かない

基本的に「〜ではない」というパターンのテストを書くこと自体に大きなリスクがあります。テスト結果はホワイトリスト形式で「〜である」という検証をするのを心掛けると、非同期テストで意図しない挙動となるのも回避しやすいでしょう。

これについては @jnchito さんの以下エントリとほぼ同内容のため、そちらに一度目を通すのをお勧めします。

システムスペックやフィーチャスペックで「〜が表示されないこと」だけを検証するのはちょっと危険、という話 - Qiita

まとめ

というわけで、特に頻繁に目にするハマりポイントを書いてみました。
良いテストライフを!

おわり。