🎉

Cypress で Vue 3 コンポーネントを楽々テストしちゃおう

2022/09/12に公開

概要

本記事では、オープンソースのE2Eテストフレームワークである CypressComponent Testing を用いて、 Vue 3 のコンポーネントのテストを書く手法及びいくつかのレシピを紹介します。

本記事は Vue を用いていますが、 React でも基本的には同様ですので適宜読み替えてください。

想定読者は主に以下の方々です。

  • モダンなコンポーネントテストの手法例を知りたい
  • Cypress を E2E テストで使用しているが、コンポーネントテストでの利用にも興味がある
  • コンポーネントの最適なテスト手法を探している

基本的には公式ドキュメントの内容をベースにしていますが、要所を抜き出したり、サンプルコードをよりイメージしやすいものにしたり、実際のプロダクトレベルで使用するための各種調整などを追加しています。

技術構成

  • Cypress: 10.7.0
  • Vue.js: 3.2.37
  • TypeScript: 4.6.4
  • Vite: 3.1.0
  • yarn: 1.22.19

注意事項

  • Cypress 自体の説明は最低限に留めます
  • CypressComponent Testing は記事公開時点でβ機能です
  • Vue コンポーネントは Vue 2 ユーザーの方にもわかりやすいよう、 <script setup> 含む Composition API よりも従来の Option API を主に使用します

Cypress について

Cypress は一言でいうと、モダンWebアプリケーションのための次世代フロントエンドツールです。

テストのセットアップ、作成、実行、デバッグを1パッケージですぐに利用可能で、ブラウザと一体化した Cypress アプリケーション内に iframe でテスト対象アプリケーションを埋め込むという独自の形式のテストツールになっています。

これだけだとどんなツールなのかイメージが湧きづらいと思いますので、初めての方には以下の動画がオススメです。

https://vimeo.com/237527670

また、公式ドキュメントに目を通すのが一番確実ですが、筆者が Cypress に初めて触れた際にざっくりとメモをとったスクラップがありますのでご参考までどうぞ。

https://zenn.dev/sa2knight/scraps/b5946489946802

Component Testing について

Component Testing (以降、コンポーネントテスト) は、E2Eテストを主目的としてきた Cypress に新たに追加されたテストモードです。

ReactVue といったフレームワークにおける、コンポーネントレベルの単体テストを、E2Eテストと同じように作成することができます。

Cypress の E2E テストでは、 iframe 内にテスト対象のWebサイトを実際に読み込んで操作するという仕組みでしたが、コンポーネントテストでは任意のビルドツールを使って、コンポーネント単体をビルドし、同じく iframe 上にレンダリングすることで単体でのテストを行うことが出来ます。

類似のツールとして StorybookInteraction tests がありますが、 Storybook がコンポーネントのカタログからドキュメンテーションまで包括的に扱うのに対して、 Cypress はテストに特化しているので使い分けの判断はプロジェクトの方針によるのかなと個人的には思います。

E2Eテストとコンポーネントテストの比較

E2Eテストとコンポーネントテストについて、公式ドキュメント内での比較を整理すると以下のようになります。

メリット デメリット
E2E アプリケーション全体を包括的にテストでき、UXレベルでのテストシナリオを書けることから、QA観点でのテストができる セットアップやメンテナンスのコストが高く、アプリケーションが依存する外部のシステム、APIの考慮も必要
コンポーネント コンポーネント単位の独立したテストができることから、実行が高速で信頼性が高く、セットアップも用意で、外部システムに依存することもない アプリケーション全体の担保にはならないので、結合部分のテストがどのみち必要なことと、たいていはQAでなく開発者向けのテストになる

たいていは E2E テストは認証認可や課金機能などのミッションクリティカルな機能の検証に使ったり、画面を跨いでのデータの永続性といった単体では見れない範囲に限定して実施するのが望ましいようです。

セットアップ

Cypress でテストするためのサンプルプロジェクトを作成します。

Vue 3 + TypeScript + Vite のスキャフォルド

vite でスキャフォルドするのが最も早いので今回はそれでセットアップします。

$ yarn create vite --template vue-ts
$ cd vite-project
$ yarn install

Cypress のセットアップ

cypress を追加します。

$ yarn add -D cypress

cypress をコンポーネントテストモードで起動します。通常は E2E テストモードかを選択する画面を挟みますが、 --component オプションを付与することでショートカットできます。

$ yarn cypress open --component

初回起動時(プロジェクトルートに cypress ディレクトリがない場合)はプロジェクトのセットアップナビゲーションが始まります。

初めにフレームワーク及びバンドラツールの選択画面になります。自動で検知されることもありますが、検知されない場合は手動で、 Vue.js 3 及び Vite を選択しましょう。

続けて Install Dev Dependencies 画面になりますが、本記事の手順通りなら既にインストール済みなので Continue を押下するだけです。

cypress ディレクトリが作成され、以下のような初期設定ファイルが自動生成された旨が表示されます。

ファイル名 内容
cypress.config.ts コンポーネントテストで Vue + vite を使うことが宣言されてる
cypress/support/component.ts コンポーネントテスト用の型定義と mount コマンドが追加されてる
cypress/support/command.ts カスタムコマンドを定義するための場所
cypress/support/component-index.html ビルドしたコンポーネントをレンダリングするための HTML
cypress/fixtures/example.json テストデータのサンプル(不要)

続けて Continue を押下すると、テストに使用するブラウザの選択画面になるため、任意のブラウザを選択してください。
(本記事は Chrome を使用します)

これで Cypress のセットアップは完了しましたが、まだテストコードはもちろん、テスト対象のコンポーネントもない状態なので、 Cypress は一旦終了して先にコンポーネントの実装を行います。

テスト用コンポーネントを作成

以下のような Stepper コンポーネントを作成します。

src/components/Stepper.vue
<template>
  <div>
    <button data-cy="decrement" @click="count--">-</button>
    <span data-cy="counter">{{ count }}</span>
    <button data-cy="increment" @click="count++">+</button>
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";

export default defineComponent({
  props: {
    initial: {
      type: Number,
      default: 0,
    },
  },
  emits: ["change"],
  data() {
    return {
      count: this.initial,
    };
  },
});
</script>

仕様は単純で以下の通りです。

  • カウント値 count を内部で持っている
  • countprops(initial) 経由で初期値を指定することができ、省略した場合の初期値は0になる
  • + ボタンを押下すると、 count がインクリメントされる
  • - ボタンを押下すると、 count がデクリメントされる
  • 最新の count の値が表示される

テストコードを作成

前項で Stepper.vue を作成したので、再度 Cypress を起動しましょう。

Chrome を使用する場合、--browser オプションで指定することでさらに手順を省略できます。

$ yarn cypress open --component --browser chrome

Chrome が起動し、 Create your first spec 画面が表示されるので、 Create from component を押下し、先程作成した Stepper コンポーネントを選択します。

するとコンポーネントと同ディレクトリに、 Stepper.cy.ts というテストコードが作成され、最初から Stepper コンポーネントをマウントする最低限のコードが記述され、実行可能な状態となっています。

src/components/Stepper.cy.ts
import Stepper from './Stepper.vue'

describe('<Stepper />', () => {
  it('renders', () => {
    // see: https://test-utils.vuejs.org/guide/
    cy.mount(Stepper)
  })
})

見事、Stepper コンポーネントが Cypress アプリケーション上の iframe 内にレンダリングされ、コンポーネント単体でのテストが可能な状態になりました。

このように、Cypress では既存のコンポーネントから直接テストコードを作成することが可能で、コンポーネントを mount する一見面倒そうな下準備が完了した状態から始めることができます。

TypeScript 対応

前項で作成されたテストコードには、 describe it cy といった、 Cypress モジュール上のオブジェクトに暗黙に依存しているため、そのままだと TypeScript で解釈することができません。

これらの型宣言が行われている、 cypress/support/*.ts を、 tsconfiginclude フィールドに追加することで、型サポートを受けることができます。

tsconfig.json
  "include": [
    "src/**/*.ts",
    "src/**/*.d.ts",
    "src/**/*.tsx",
    "src/**/*.vue",
    "cypress/**/*.ts",
  ],

これでセットアップはすべて終了です。Stepper.cy.ts にテストコードを追記していきましょう。

レンダリング内容の検証

改めて、生成されたテストコードを見てみます。

src/components/Stepper.cy.ts
import Stepper from './Stepper.vue'

describe('<Stepper />', () => {
  it('renders', () => {
    // see: https://test-utils.vuejs.org/guide/
    cy.mount(Stepper)
  })
})

最も重要なのは cy.mount です。

vue-test-utils の mount に似ていますが、ここでは戻り値 (vue-test-utils では慣習的に wrapper) を使用することはありません。

Cypress のテストコード内で mount されたコンポーネントは (ここでは vite によって) 単体でビルドされ、Cypress アプリケーション上の iframe 内に描画されます。

そのため、あとは cy モジュールを経由することで、E2Eテストと同様の API を用いてテストすることができます。

Stepper コンポーネントの count は、 props を省略した場合は 0 が初期描画されるので、まずはそれをテストしてみましょう。

src/components/Stepper.cy.ts
const counterSelector = '[data-cy=counter]'

describe('<Stepper />', () => {
  it('ステッパーの初期値は 0 である', () => {
    cy.mount(Stepper)
    cy.get(counterSelector).should('have.text', '0') // カウント値を表示する要素に 0 が描画されている
  })
})

Cypress では要素セレクタに慣習的に data-cy 属性を使用するので、カウント値を表示している要素のセレクタを使用します。

あとはE2Eテストと同様に、 cy.get should などを使用して、初期値である 0 が描画されていることを確認して完了です。

props の検証

Stepper コンポーネントの props である initial を指定して、それがカウント値の初期値として描画されていることをテストします。

src/components/Stepper.cy.ts
  it("ステッパーの初期値を props で指定することができる", () => {
    cy.mount(Stepper, { props: { initial: 100 } }); // <Stepper :initial="100" />
    cy.get(counterSelector).should("have.text", "100");
  });

mount メソッドの第二引数でマウントオプションが指定できるので、そこで props を挿入するだけになります。

インタラクションの検証

Stepper コンポーネントは、 + - 2種類のボタンがあり、それらをクリックすることで count を増減させられます。

両ボタンのセレクタを追加し、あとはE2Eテストと同様のAPIを使用できます。

src/components/Stepper.cy.ts
const incrementSelector = "[data-cy=increment]";
const decrementSelector = "[data-cy=decrement]";
src/components/Stepper.cy.ts
  it("+ボタンが押下されると、カウンターがインクリメントされる", () => {
    cy.mount(Stepper);
    cy.get(incrementSelector).click();
    cy.get(counterSelector).should("have.text", "1");
  });

  it("-ボタンが押下されると、カウンターがデクリメントされる", () => {
    cy.mount(Stepper);
    cy.get(decrementSelector).click();
    cy.get(counterSelector).should("have.text", "-1");
  });

Event の Emit の検証

ここまでの Stepper コンポーネントは、ボタン押下時にカウント値が増減するだけでしたが、親コンポーネントからその変更を監視したくなることがあると思います。

Stepper コンポーネントを改修し、両ボタンクリック時に親コンポーネントに対して change イベントを発火するようにしましょう。

src/components/Stepper.vue
<template>
  <div>
    <button data-cy="decrement" @click="decrement">-</button>
    <span data-cy="counter">{{ count }}</span>
    <button data-cy="increment" @click="increment">+</button>
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";

export default defineComponent({
  props: {
    initial: {
      type: Number,
      default: 0,
    },
  },
  emits: ["change"],
  data() {
    return {
      count: this.initial,
    };
  },
  methods: {
    increment() {
      this.count++;
      this.$emit("change", this.count);
    },
    decrement() {
      this.count--;
      this.$emit("change", this.count);
    },
  },
});
</script>

上記コンポーネントに対して、 change イベント経由で最新のカウント値を受け取るれるかをテストします。

src/components/Stepper.cy.ts
  it("+ボタンが押下されると、change イベントでカウンターの値が取得できる", () => {
    const onChangeSpy = cy.spy().as("onChangeSpy");
    cy.mount(Stepper, { props: { onChange: onChangeSpy } }); // <Stepper @change="onChangeSpy" />
    cy.get(incrementSelector).click();
    cy.get("@onChangeSpy").should("be.calledWith", 1);
  });

  it("-ボタンが押下されると、change イベントでカウンターの値が取得できる", () => {
    const onChangeSpy = cy.spy().as("onChangeSpy");
    cy.mount(Stepper, { props: { onChange: onChangeSpy } }); // <Stepper @change="onChangeSpy" />
    cy.get(decrementSelector).click();
    cy.get("@onChangeSpy").should("be.calledWith", -1);
  });

cy.spy で、スパイ関数を作成し、それを後から Cypress で参照できるよう as で別名を付け、 change イベントにバインドします。

ここで change イベントなのに、 onChange というイベント名、かつイベントなのに props で渡すという、 Vue らしくないコードに見えますが、これは vue-test-utils の慣習に寄せているそうです。

You may notice the syntax above in the non-JSX example relies on binding events to the props key in mount. While this isn't "idiomatic Vue", it's the current signature of Vue Test Utils.

スロットの検証

スロットに対して任意の要素を注入できることをテストすることも可能です。

Stepper にはスロットを使用する余地が無いので、スロットをふんだんに使った Modal コンポーネントを新たに実装します。

src/components/Modal.vue
<template>
  <div class="modal">
    <div data-cy="modal-header">
      <!-- 名前付きスロット(フォールバックあり)-->
      <slot name="header">
        <span>No Title</span>
      </slot>
      <hr />
    </div>
    <div data-cy="modal-content">
      <!-- デフォルトスロット -->
      <slot />
    </div>
    <template v-if="$slots.footer">
      <div data-cy="modal-footer">
        <hr />
        <!-- 名前付きスロット(フォールバックなし)-->
        <slot name="footer" />
      </div>
    </template>
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({});
</script>

<style>
.modal {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  background-color: white;
  border-radius: 5px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.26);
  width: 30rem;
  height: 25rem;
}
</style>

非常に簡素ではありますが、上記コンポーネントは3種類のスロットを提供します。

  • header: ヘッダーに差し込む要素で、省略時フォールバック用の初期値 No Title を持つ
  • default: デフォルトスロットでモーダルコンテンツを差し込む要素
  • footer: フッターに差し込む要素で、省略時はフッター自体が非表示になる

上記の仕様を元に、以下の観点のテストを行います。

  • header を注入できる
  • header を省略するとフォールバックする
  • default を注入できる
  • footer を注入できる
  • footer を省略するとフッター要素自体が表示されない

作成したテストコードが以下になります。

src/components/Modal.cy.ts
import Modal from "./Modal.vue";

const headerSelector = "[data-cy=modal-header]";
const contentSelector = "[data-cy=modal-content]";
const footerSelector = "[data-cy=modal-footer]";

describe("<Modal />", () => {
  it("ヘッダーにスロットを注入しない場合、ヘッダーはデフォルトタイトルにフォールバックする", () => {
    cy.mount(Modal);
    cy.get(headerSelector).should("have.text", "No Title");
  });

  it("ヘッダーに名前付きスロットを注入した場合、ヘッダーにスロットの内容が表示される", () => {
    // <Modal><template #header>Custom Title</template></Modal>
    cy.mount(Modal, { slots: { header: "Custom Title" } });
    cy.get(headerSelector).should("have.text", "Custom Title");
  });

  it("デフォルトスロットを注入した場合、モーダルコンテンツにスロットの内容が表示される", () => {
    // <Modal>Custom Title</Modal>
    cy.mount(Modal, { slots: { default: "Modal Content" } });
    cy.get(contentSelector).should("have.text", "Modal Content");
  });

  it("フッターにスロットを注入しない場合、フッター要素自体が表示されない", () => {
    cy.mount(Modal);
    cy.get(footerSelector).should("not.exist");
  });

  it("フッターに名前付きスロットを注入した場合、フッターにスロットの内容が表示される", () => {
    // <Modal><template #footer>Custom Footer</template></Modal>
    cy.mount(Modal, { slots: { footer: "Custom Footer" } });
    cy.get(footerSelector).should("have.text", "Custom Footer");
  });
});

mount 関数の第二引数で slots を指定して注入することができます。

上記コードではテキストノードのみを注入していますが、以下のように h 関数を使って仮想DOMを注入することも可能です。

  it("ヘッダーに名前付きスロットを注入した場合、ヘッダーにスロットの内容が表示される", () => {
    // <Modal><template #header><h1 data-cy="custom-element">タイトル</h1></template></Modal>
    cy.mount(Modal, {
      slots: { header: h("h1", { "data-cy": "custom-element" }, "タイトル") },
    });
    cy.get("[data-cy=custom-element]").should("have.text", "タイトル");
  });

また、今回は登場しませんでしたが、スロットスコープから props を受け取る場合は、関数経由で参照することが出来ます。

// <Modal><template #default="{ props }"></template></Modal>
cy.mount(Modal, { slots: { default: props => props.value } });

Vue プラグイン対応

最後に、Vue アプリケーションを開発する場合に多くのケースで導入される Vue I18n, Vue Router, Pinia に依存したコンポーネントのテストをできるようにします。

やや強引ですが、上記3ライブラリすべてに依存する以下のようなコンポーネントを用意し、それを単体テストできるようにします。

インストール

$ yarn add pinia vue-i18n vue-router

Pinia

piniavuex の後継であるグローバルステート管理ライブラリです。

今回はシンプルにカウンターを増減するだけのストアを用意します。

src/store.ts
import { createPinia, defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => {
    return {
      count: 0
    }
  },
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    }
  }
})

export default createPinia()

VueI18n

VueI18nVue における多言語対応をサポートするライブラリです。

今回は一種類のメッセージを、日本語・英語それぞれ定義しておきます。

src/i18n.ts
import { createI18n } from 'vue-i18n'

const messages = {
  en: {
    message: {
      hello: 'hello world'
    }
  },
  ja: {
    message: {
      hello: 'こんにちは、世界'
    }
  }
}

export default createI18n({ locale: 'ja', messages })

VueRouter

VueRouterVue におけるルーティングライブラリです。

通常はコンポーネントの単体テストにおいてルーティングを意識する必要はありませんが、VueRouter に対して依存したコードがコンポーネント内で即時実行されることは少なくないと思うので、単体テストであれど必要な場合があります。

今回は適当に2種類のルートを定義したルーターを用意します。

router.ts
import { createRouter, createWebHistory } from 'vue-router'

const Home = { template: '<div>Home</div>' }
const About = { template: '<div>About</div>' }

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About }
]

export default createRouter({ history: createWebHistory(), routes })

コンポーネントからの利用

pinia VueI18n VueRouter すべてに依存したコンポーネント SuperComponent.vue を実装します。細かいコードまで追う必要はありませんが、各プラグインのセットアップが完了していないと一切動かないコンポーネントと理解していただければ大丈夫です。

src/components/Child.vue
<template>
  <!-- vueI18n 依存-->
  <h1 data-cy="message">{{ $t('message.hello') }}</h1>
  <button data-cy="toggle" @click="toggleLocale">toggleLocale</button>

  <!-- pinia 依存-->
  <div>
    <button data-cy="decrement" @click="store.decrement">-</button>
    <span data-cy="count">{{ store.count }}</span>
    <button data-cy="increment" @click="store.increment">+</button>
  </div>

  <!-- vue-router 依存-->
  <div>
    <router-link data-cy="link" to="/about">About</router-link>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import { useCounterStore } from '../store'

export default defineComponent({
  setup() {
    const store = useCounterStore()
    return { store }
  },
  methods: {
    toggleLocale() {
      this.$i18n.locale = this.$i18n.locale === 'ja' ? 'en' : 'ja'
    }
  }
})
</script>

プラグイン注入

pinia VueI18n VueRouter いずれも Vueプラグイン として提供されているため、アプリケーションルートである main.ts にて、 Vue インスタンスにプラグインを注入します。

src/main.ts
import { createApp } from 'vue'
import SuperComponent from './components/SuperComponent.vue'
import i18n from './i18n'
import router from './router'
import store from './store'

const app = createApp(SuperComponent) // SuperComponent をルートに
app.use(router) // 注入!
app.use(store) // 注入!!
app.use(i18n) // 注入!!!
app.mount('#app')

アプリケーション上での動作確認

ここまでで、 yarn dev で開発サーバーを起動した場合に、 SuperComponent が意図通り描画され、各操作ができることが確認できました。

$ yarn dev

単体テストを書く

開発サーバー上では動作しましたが、 Cypress を用いた単体テストではどうでしょうか。

これまでと同様の手順で、 SuperComponent.cy.ts を作成し、テストの実行を試みます。

おや、pinia のセットアップが行われていないというエラーが発生しました。

これは yarn dev の場合は src/main.ts が最初に実行されるため、そこで各種プラグインのセットアップが行われますが、 Cypress の場合は SuperComponent.vue を単体でビルドしているだけで、 src/main.ts が実行されていないため、セットアップが出来ていないことに起因します。

ではどうするか。 cy.mount 関数を拡張して、プラグインのセットアップを行った Vue インスタンス上でコンポーネントを描画するようにします。

現状では cypress/support/component.ts にて、以下のようにデフォルトの mount 関数をそのままコマンド登録しています。

cypress/support/component.ts
import { mount } from 'cypress/vue'
Cypress.Commands.add('mount', mount)

これを拡張し、以下のように対象の Vue コンポーネント経由でグローバルフィールドにアクセスし、プラグインを追加します。

cypress/support/component.ts
import { mount } from 'cypress/vue'
import store from '../../src/store'
import i18n from '../../src/i18n'
import router from '../../src/router'

Cypress.Commands.add('mount', component => {
  return mount(component, {
    global: {
      plugins: [store, i18n, router]
    }
  })
})

※もちろん必要に応じてモックを定義したり初期化したりテストコード側から注入するなどの調整が必要になることはあります

これでコンポーネント単体でもプラグインを参照できるようになりました。せっかくなのでテストコードをこのまま書いていきましょう。

cypress/
import SuperComponent from './SuperComponent.vue'

describe('<Child />', () => {
  beforeEach(() => {
    cy.mount(SuperComponent)
  })

  describe('メッセージ', () => {
    it('デフォルトで日本語メッセージが表示されている', () => {
      cy.get('[data-cy=message]').should('have.text', 'こんにちは、世界')
    })

    it('トグルボタンを押下すると、英語メッセージが表示される', () => {
      cy.get('[data-cy=toggle]').click()
      cy.get('[data-cy=message]').should('have.text', 'hello world')
    })
  })

  describe('カウンター', () => {
    it('カウンターの初期値は0', () => {
      cy.get('[data-cy=count]').should('have.text', '0')
    })

    it('+ボタンと-ボタンで押下で、カウンターの値を増減できる', () => {
      cy.get('[data-cy=increment]').click()
      cy.get('[data-cy=increment]').click()
      cy.get('[data-cy=count]').should('have.text', '2')
      cy.get('[data-cy=decrement]').click()
      cy.get('[data-cy=count]').should('have.text', '1')
    })
  })

  describe('フッターリンク', () => {
    it('フッターに /about へのリンクが表示されている', () => {
      cy.get('[data-cy=link]').should('have.attr', 'href', '/about')
    })
  })
})

これですべてのテストがパスしました。

まとめと所感

本記事では、オープンソースのE2Eテストフレームワークである CypressComponent Testing を用いて、 Vue 3 のコンポーネントのテストを書く手法といくつかのレシピを紹介しました。

個人的には Vue コンポーネントの単体テストを行う最も敷居の低くコスパの高い手法だったなと感動し、その勢いのまま記事化するぐらいには刺さりました。

StorybookInteraction tests についても同様の感動はしたものの、(やり方が悪かったのか) 微妙にテストが安定しなく、まだまだ使い勝手が良くないと感じていましたが、 Cypress は自動リトライ機構が含まれているのもあって、抜群の安定感があるなと感じました。

本機能は記事公開時点ではβ機能ではありますが、 Cypress を使った E2E テストを運用できているプロジェクトなら既に実用できるレベルだと感じているので、これからもウォッチしていきたいと思います。

GitHubで編集を提案

Discussion