Cypress で Vue 3 コンポーネントのテストの書き心地を試そう
記事化しました。
Cypress にはまだβであるものの、従来の E2E テストとは別にコンポーネントテスト機能が用意されてる。
おそらく多くの人は React で試用してると思うのと、Vue はこの手のエコシステムに対して面倒な手続きが必要であることが多いので、それがどれぐらいコストかかるのかを試してみようと思う。
Vue のバージョンは3系に。もうこれからは新しいエコシステムは Vue 3 が優先して成熟していくのは間違いないので。
Cypress コンポーネントテストってなんぞや
- Cypress のコンポーネントテストは、ブラウザベース
- コンポーネントはページから分離されたサンドボックスにレンダリングされ、そのスタイルやAPIを検証できる
身近なものだと Storybook とそのインタラクティブテスティングにあたるものっぽい。
テスト以外の多くの用途への応用ができる Storybook と違って、 Cypress はテストに特化してるし、 Cypress の書き心地の良い API でコンポーネントテストを書けるとしたら期待できそう。
Component vs E2E
- Cypress にはそもそも E2E テストモードと、コンポーネントテストモードが明確に分離されてる
- とはいえどちらのテストも同じテストランナー、コマンド、APIが提供されてる
- 最大の違いは E2E が実際の Webサイトを対象にテストするのに対し、コンポーネントテストは開発サーバー上に対象コンポーネントのみをビルドした結果を描画する
- いずれにしても Cypress はユーザー視点でのテストに特化しているため、特定のフレームワークに依存しないテストを提供する
ここも概ねイメージ通り。開発用サーバーにコンポーネントをビルドするためのプロセスがどのぐらいお手軽かがポイントになりそう
Testing Types
E2E とコンポーネントテストの対比はここにも書いてるから軽く目を通しておこう
E2E テストのメリット
- アプリケーション全体を包括的にテストできる
- テストシナリオがそのまま UX になる
- QAチームでも書きやすい
- 結合テストとしても書きやすい
E2E テストのデメリット
- 環境のセットアップ、実行、メンテコストが高い
- CI 上に実行環境を構築する必要がある
- 正確なシナリオ実行のためにはさらなるセットアップが必要 (サードパーティAPIとか?)
E2E テストの一般的なシナリオ
- 認証認可や課金機能など、クリティカルな機能の検証
- 画面を跨いでのデータの永続性や表示の検証
- デプロイ前のスモークテスト、システムチェック
コンポーネントテストのメリット
- コンポーネント単位で独立したテストができる
- 高速で高信頼性
- シナリオのセットアップが容易
- 外部システムに依存することがない
コンポーネントテストのデメリット
- アプリケーション全体の担保にはならない
- 外部API、サービスの呼び出しが行われない
- たいていはQAでなく開発者のみがテストを実施する
コンポーネントテストの一般的なシナリオ
- date picker がちゃんと動くか
- Form が入力値に応じて表示されたり非表示になったりするか
- Design system のテスト
- コンポーネントに紐付かないテスト
(最後のはコンポーネントテストで書く必要ないんじゃないかな…?)
Cypress + Vue 3 のセットアップ
Vue 3 プロジェクトのセットアップはいつものことなので省略。このリポジトリ使う。
Cypress 追加
$ yarn add -D cypress
コンポーネントテスティングのセットアップ
$ yarn cypress open
Vue 3 + Vite であることを認識してくれて、プロジェクトセットアップはサクサクできそう。
Vite を認識するってことは、コンポーネントのビルドも Vite 任せでやってくれるってことかな。
勝手に設定ファイルが作られた
- cypress.config.ts: コンポーネントテストで Vue + Vite を使うことが宣言されてる
- cypress/support/component.ts: コンポーネントテスト用の型定義と mount コマンドが追加されてる
- cypress/support/command.ts: カスタムコマンドを定義するための場所
- cypress/support/component-index.html: 多分ビルドしたコンポーネントをレンダリングするための HTML
- cypress/fixtures/example.json: テストデータのサンプルで多分いらない
これでセットアップ完了。
テストコードはもちろん、コンポーネント自体も何も作ってないので試してく。
テスト対象コンポーネントを実装
単純なカウント値を上下するボタンが付いたカウンターコンポーネントを用意
<template>
<div>
<button aria-label="decrement" @click="count--">-</button>
<span data-cy="counter">{{ count }}</span>
<button aria-label="increment" @click="count++">+</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps(['initial'])
const emit = defineEmits(['change'])
const count = ref(props.initial || 0)
</script>
何気に <script setup>
と TypeScript フル活用したコンポーネントだ。
aria-label はアクセシビリティのテストに使うのかな?
もし記事化することを考えると、 Vue 3 独自の書き方より普通に Option API 使ったほうが良い気がするな。
ので書き換えちゃう。
<template>
<div>
<button aria-label="decrement" @click="count--">-</button>
<span data-cy="counter">{{ count }}</span>
<button aria-label="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>
マウントコンポーネントについて
- マウントはコンポーネントテストにおける準備段階で、E2E テストにおける visit にあたるもの
- マウント関数は Cypress がフレームワークごとに用意したもので、Cypress のサンドボックス上に iframe でコンポーネントを描画するための関数
- Vue 3 は
'cypress/vue'
Vue 2 はcypress/vue2
から import す
既に初期設定用のファイルの中で mount コマンドが登録されてるので、テストコード中のどこでも使えるようになってる。
You can customize cy.mount to fit your needs. For instance, if you are using plugins or other global app-level setups in your Vue app, you can configure them here.
の通り、Vuex とか VueRouter とかのモックのためにゴニョる必要はありそう。
import { mount } from 'cypress/vue'
// Augment the Cypress namespace to include type definitions for
// your custom command.
// Alternatively, can be defined in cypress/support/component.d.ts
// with a <reference path="./component" /> at the top of your spec.
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount
}
}
}
Cypress.Commands.add('mount', mount)
既存コンポーネントを元にテストコードを生成する
Cypress UI 上からテストコードの追加を選択したら勝手にファイルが作られた。
import Stepper from './Stepper.vue'
describe('<Stepper />', () => {
it('renders', () => {
// see: https://test-utils.vuejs.org/guide/
cy.mount(Stepper)
})
})
TypeScript 対応
そのままだとテストコード内での Cypress の型解決ができないので、 tsconfig の include オプションに追加する
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "cypress/support/*.ts"],
これでレンダーに成功。 Vite のおかげもあってかかなり楽だったな。
If you're coming from Vue Test Utils, please note that the return value of mount is not used. Cypress Component tests can and should be agnostic to the framework internals and accessing the wrapper that Vue Test Utils rely on is rarely necessary.
Cypress 自体がサンドボックス上に直接アクセスしに行くから、 mount
の返り値を扱う必要がないって話ね。
CLI からコンポーネントのテストだけをする場合はオプション指定で良いらしい
cypress open --component
Cypress 用の testing-library もあるけど使わなくてもテストはできるよとのこと。
元々 Cypress には E2E テスト用の強力なAPIがあるから、これなくても良いような気がするな。
Cypress の API はユーザー視点のものが主だから、だいぶでどのメソッドが実行されたかとかのアサートのためにはあると便利なのかな。
あとから触ってみるのはアリ
セレクタを用意する。
const counterSelector = '[data-cy=counter]'
const incrementSelector = '[aria-label=increment]'
const decrementSelector = '[aria-label=decrement]'
ここで wai-aria を使うのは宗派が分かれそうだけど、とりあえずはドキュメントの通りに使う。
あと [data-cy=...]
のためのショートカットは欲しそう。どっかで出てくるかな。
ステッパーの初期値が0であることを担保してく。
describe('<Stepper />', () => {
it('ステッパーの初期値は 0 である', () => {
cy.mount(Stepper)
cy.get(counterSelector).should('have.text', '0')
})
})
mount
した時点で、Cypress のサンドボックス上に描画されてるから、あとは普段の Cypress の E2E テストと同じ要領ってことか。
プロパティを与えた場合のテスト
it('ステッパーの初期値を props で指定することができる', () => {
cy.mount(Stepper, { props: { initial: 100 } })
cy.get(counterSelector).should('have.text', '100')
})
props も簡単に渡せてありがたいけど、mount の第2引数が型安全になってほしい。 props の内容はまだしも props 自体補完できても良いのに。
What Else Should You Test in This Component?
このコンポーネントテストで何をテストしようかってコラム。
コンポーネントの単体テストにおいては、フレームワークを意識するようなホワイトボックステストをするより、UX を意識したユーザー視点のテストをしようって感じ。
インタラクティブな操作に関するテストを追加する
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')
})
リアルワールドのユーザーは1回きりじゃなくて複数回色んな操作をするからその考慮もしてみようという例。
この辺は派閥が分かれそう。ステッパーをどういう用途で使うかによるか。
it('+ボタンと-ボタンを複数回押下すると、押下に応じてカウンタが増減する', () => {
cy.mount(Stepper, { props: { initial: 100 } })
cy.get(counterSelector).should('have.text', '100')
cy.get(incrementSelector).click().click()
cy.get(counterSelector).should('have.text', '102')
cy.get(decrementSelector).click().click().click()
cy.get(counterSelector).should('have.text', '99')
})
個人的にはこれはやりすぎかなとも感じるけど、もう少し複雑なインタラクションを提供するコンポーネントだったら実際のユーザー操作で見るだけでもそうとう担保できるか…?
あとは上限下限の境界値を見るとかかな。0 でデクリメントしたらどうなるのかとか。
Emitted Event のテスト
Event Emitter はフレームワーク内部的なものだから、これをテストするということはユーザーのためのでなく開発者のためのテストになることを念頭に置く。
change
イベントの emit をテストを試すためにコンポーネントのコードを修正する。
<template>
<div>
<button aria-label="decrement" @click="decrement">-</button>
<span data-cy="counter">{{ count }}</span>
<button aria-label="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>
Cypress に Spy 機能が付いてるので、こんな感じにテストが書ける。
it('+ボタンが押下されると、change イベントでカウンターの値が取得できる', () => {
const onChangeSpy = cy.spy().as('onChangeSpy')
cy.mount(Stepper, { props: { onChange: 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 } })
cy.get(decrementSelector).click()
cy.get('@onChangeSpy').should('be.calledWith', -1)
})
-
as('onChangeSpy
) で Spy に別名をつけることで、あとからスパイを参照できるようにする - イベントハンドラも
props
で指定可能なのは Vue っぽくないけど、 test-utils の慣習に従ってる
JSX で書くともうちょっと自然
cy.mount(() => <Stepper onChange={onChangeSpy} />)
emit のテストと画面表示のテストを1回のテストシナリオでやるかは開発者の裁量次第。混ぜちゃったほうが実行時間は短くて済むけど、可読性の低下や、失敗時のどこで失敗したかが不明瞭になるなどのデメリットも。
一般的に E2E と比べるとコンポーネントテストは高速なので、パフォーマンスを気にせず分けちゃっても良いと思う。
VueTestUtils と組み合わせることも出来るけど、既存のテストコードととの互換性の都合なので、ゼロから書くなら不要そうなのでスキップ
Slot のテスト
スロットのテストを書くためにスロットをふんだんに使ったモーダルコンポーネントを用意する。
<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>
以下のパターンは網羅してる
- デフォルトスロット
- 名前付きスロット(フォールバックあり)
- 名前付きスロット(フォールバックなし)
スロットスコープは Stepper
のほうを改修してみる。
<template>
<div>
<button aria-label="decrement" @click="decrement">-</button>
<slot name="counter" :count="count">
<span data-cy="counter">{{ count }}</span>
</slot>
<button aria-label="increment" @click="increment">+</button>
</div>
</template>
これでスロット周りのテストをする準備はOK
Modal のテストはこんな感じ
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('ヘッダーに名前付きスロットを注入した場合、ヘッダーにスロットの内容が表示される', () => {
cy.mount(Modal, { slots: { header: 'Custom Title' } })
cy.get(headerSelector).should('have.text', 'Custom Title')
})
it('デフォルトスロットを注入した場合、モーダルコンテンツにスロットの内容が表示される', () => {
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('フッターに名前付きスロットを注入した場合、フッターにスロットの内容が表示される', () => {
cy.mount(Modal, { slots: { footer: 'Custom Footer' } })
cy.get(footerSelector).should('have.text', 'Custom Footer')
})
})
- mount 時に
slots
属性でスロットの注入が可能 - デフォルトスロットへの注入の場合はそのまま default を使用する
ステッパーのスロットスコープの方はこう
it('スロットを注入することで、カウンターの出力をカスタマイズすることができる', () => {
cy.mount(Stepper, {
props: { initial: 100 },
slots: {
counter: props => h('span', { 'data-cy': 'counter' }, props.count)
}
})
cy.get(counterSelector).should('have.text', '100')
})
h 関数で直接要素を描画しなきゃならないのがちょっと大変かも。
JSXだとこの辺楽なんだけどね。
Custom Mount Commands and Styles
最後のドキュメント
- リアルワールドのコンポーネントは HelloWorld コンポーネントほど単純じゃないので、マウントコマンドをカスタマイズする必要が出てくる
記事化の準備も整ったのでスクラップはクローズ