Vue Test Utilsで始めるスナップショットテスト

UI開発者 加藤

みなさんはフロントエンドのテストはどのように行っていますか?

昔は「フロントエンドのテストって何をやればいいの?」ということをよく見聞きし、実際私もそう思っていました。しかし、JavaScriptのモジュール化が一般的になり、ここ数年でフロントエンドのテストについての情報もかなり増えてきたように感じます。
とはいえ、周囲の開発者にテストについて聞いてみると「どこからやればいいのか分からない、難しい」という認識はやはり拭い切れていないのが正直なところです。

そんな時にまずおすすめしたいのが「スナップショットテスト」です。スナップショットテストとは、ざっくりいえば変更前の出力結果と変更後の出力結果を見比べて、差分がないこと(もしくはあること)を確認するテストです。

スナップショットテストはその性質上、テストコードが非常にシンプルで導入も簡単なため、テストのとっつきにくさみたいなものを払拭する効果もあると思っています。

今回はVue.jsコンポーネントのテストを題材に、スナップショットテストの手軽さをお伝えしたいと思います。

Vue Test Utils

Vue.jsコンポーネントのテストをする場合にはVue Test Utilsを使うことが多いです。
Vue Test Utilsを使えばVue.jsアプリケーションをブラウザ上で動作させることなく、コンポーネントをマウントした結果を得ることができます。

今回はVue Test UtilsとJestを使ったスナップショットテストを行います。
Jestのスナップショットに関するガイドページも非常に参考になるため、ぜひご一読ください。

コンポーネント単体のテスト

まずは一番シンプルな、単体で完結するコンポーネントのテストを書いてみます。以下のようなコンポーネントを想定してみます。

<template>
  <p>{{ message }}</p>
</template>

<script>
export default {
  data() {
    return {
      message: 'Vue Test Utilsを使ったコンポーネントのテスト',
    };
  },
}
</script>

このコンポーネントは、どこでインポートされたとしても、最終的には以下のようなHTML文字列が出力されるはずです。

<p>Vue Test Utilsを使ったコンポーネントのテスト</p>

続いてJestが実行するテストコードを書いていきます。

import { shallowMount } from '@vue/test-utils'
import Component from '../src/component.vue';

test('正しくレンダリングされるか', () => {
    const wrapper = shallowMount(Component);

    expect(wrapper.element).toMatchSnapshot();
});

初めてこのテストを行うと、以下のようなテスト結果(スナップショット)がファイルとして出力されます。

exports[`正しくレンダリングされるか 1`] = `
<p>
  Vue Test Utilsを使ったコンポーネントのテスト
</p>
`;

2回目以降のテストでは、このスナップショットを元に差分が出ていないか(出ているか)をテストしていきます。簡単ですね。

このコンポーネントは外部からの入力を受け付けず、外部への出力も行わないため、外から受ける影響、外に与える影響が少ないです。結果としてテストの優先度も下がります。

propsを受け取るコンポーネントのテスト

では、次は外部から入力を受けるコンポーネントのテストを考えてみます。外部からデータを受け取る手段としてまず考えられるのはpropsを使った方法があります。 例えば、以下のようにpropsで受け取った文字列をそのまま出力するコンポーネントを考えてみます。

<template>
  <p v-if="message">{{ message }}</p>
</template>

<script>
export default {
  props: {
    message: {
      type: String,
      required: false,
      default: '',
    },
  },
};
</script>

入力によってコンポーネントが変化する場合は、検証するテストケースも増えます。
ここではpropsに「何もレンダリングされないケース」と「文字列を渡すケース」の2つのテストを実行します。

import { shallowMount } from '@vue/test-utils'
import Component from '../src/component2.vue';

test('何もレンダリングされないケース', () => {
    const wrapper = shallowMount(Component);

    expect(wrapper.element).toMatchSnapshot();
});

test('文字列を渡すケース', () => {
    const wrapper = shallowMount(Component, {
        propsData: {
            message: 'Vue Test Utils'
        },
    });

    expect(wrapper.element).toMatchSnapshot();
});

この他にも、わざと数値やBooleanなどの想定している型とは違う、異常ケースのテストも用意しておくと漏れがなくなります。出力されたスナップショットは以下のようになります。

exports[`何もレンダリングされないケース 1`] = `<!---->`;

exports[`文字列を渡すケース 1`] = `
<p>
  Vue Test Utils
</p>
`;

例えばこのコンポーネントのtemplateを以下のように変更したとしましょう。

<template>
  <div>
    <p v-if="message">{{ upperCaseMessage }}</p>
  </div>
</template>

この状態で再度テストを実行するとpropsに「何も渡さないケース」と「文字列を渡すケース」の両方のテストが失敗します。

スナップショットテストは、保存されているスナップショットと結果が一致しているかどうかによってテストの成功、失敗を判断します。そのためコンポーネントが変更されたことにより出力されるHTMLコードが変わればテストに失敗することが正しい結果です。

出力されたスナップショットを見てみます。

exports[`何もレンダリングされないケース 1`] = `
<div>
  <!---->
</div>
`;

exports[`文字列を渡すケース 1`] = `
<div>
  <p>
    Vue Test Utils
  </p>
</div>
`;

「文字列を渡すケース」については想定通りのため問題なさそうですが「何も渡さないケース」では、余計なdiv要素が残ってしまっていることが分かります。テストに失敗することは期待通りですが、なぜ失敗しているのかは確認が必要です。

slotが含まれるコンポーネントのテスト

最後に<slot>を含むコンポーネントのテストを行います。<slot>は任意の子要素をコンポーネントがラップするための仕組みのようなものですが、その性質上、HTMLの文法エラーが発生してしまうケースも少なくありません。

例えば以下のようなラッパーコンポーネントを想定してみます。

<template>
  <ul class="wrapper">
    <slot />
  </ul>
</template>

これは嫌な予感しかしませんね。では、このラッパーを別のコンポーネントと組み合わせて、以下のように使ったとします。

<template>
  <wrapper>
    <list-item index="1" />
    <list-item index="2" />
    <list-item index="3" />
  </wrapper>
</template>

<script>
import Wrapper from './Wrapper';
import ListItem from './ListItem';

export default {
  components: {Wrapper, ListItem}
}
</script>

順を追って見ていけばListItemコンポーネントは<li>でマークアップされているだろうなということが予想できますが、ここまでくると各コンポーネントがどんな要素で構成されているのかは、パッと見では判断ができません。

プロジェクトが大きくなるにつれ、関わる人数が増えていき、コンポーネントはより複雑化していきます。案の定、以下のような変更が行われてしまいました。

<template>
  <wrapper>
    <div>
        <list-item index="1" />
        <list-item index="2" />
        <list-item index="3" />
    </div>
  </wrapper>
</template>

こういった場合でもスナップショット自体のレビューが継続的に行われていれば、文法エラーにも気付きやすくなります。このコンポーネントのスナップショットは以下のようになります。

exports[`slotを含むコンポーネントのテスト 1`] = `
<ul
  class="wrapper"
>
  <div>
    <li>
      List 1
    </li>

    <li>
      List 2
    </li>

    <li>
      List 3
    </li>
  </div>
</ul>
`;

まとめ

スナップショットテストは実際のロジックをテストするものではないため万能ではありません。個人的には、変更が正しいかどうかではなく、変更が意図しないところに影響が出ていないかどうかを確認するだけのもの、くらいのレベルでとらえています。
最終的にはWebサイトがどんな性質を持っていて、どんなテストが必要なのかを設計した上でいろいろなテストを組み合わせることになるはずです。

とはいえ運用ファーストをスローガンとして掲げている当社としては、運用における影響範囲を継続的に検知することはそれ単体でも非常に重要なポイントです。スナップショットテストがあるだけでも、品質を不用意に下げることなく、安心して改修ができます。

今回はテストの対象にVue.jsコンポーネントを取り上げましたが、スナップショットテストはとても汎用的です。HTMLだけでなく、処理の結果やエラーログ、もちろんVue.js以外のフレームワークでも活用できます。手始めに導入してみてはいかがでしょうか。