Closed28

Cypress で Vue 3 コンポーネントのテストの書き心地を試そう

shingo.sasakishingo.sasaki

Cypress にはまだβであるものの、従来の E2E テストとは別にコンポーネントテスト機能が用意されてる。

https://docs.cypress.io/guides/component-testing/quickstart-vue

おそらく多くの人は React で試用してると思うのと、Vue はこの手のエコシステムに対して面倒な手続きが必要であることが多いので、それがどれぐらいコストかかるのかを試してみようと思う。

Vue のバージョンは3系に。もうこれからは新しいエコシステムは Vue 3 が優先して成熟していくのは間違いないので。

shingo.sasakishingo.sasaki

Cypress コンポーネントテストってなんぞや

https://docs.cypress.io/guides/component-testing/writing-your-first-component-test

  • Cypress のコンポーネントテストは、ブラウザベース
  • コンポーネントはページから分離されたサンドボックスにレンダリングされ、そのスタイルやAPIを検証できる

身近なものだと Storybook とそのインタラクティブテスティングにあたるものっぽい。
テスト以外の多くの用途への応用ができる Storybook と違って、 Cypress はテストに特化してるし、 Cypress の書き心地の良い API でコンポーネントテストを書けるとしたら期待できそう。

Component vs E2E

  • Cypress にはそもそも E2E テストモードと、コンポーネントテストモードが明確に分離されてる
  • とはいえどちらのテストも同じテストランナー、コマンド、APIが提供されてる
  • 最大の違いは E2E が実際の Webサイトを対象にテストするのに対し、コンポーネントテストは開発サーバー上に対象コンポーネントのみをビルドした結果を描画する
  • いずれにしても Cypress はユーザー視点でのテストに特化しているため、特定のフレームワークに依存しないテストを提供する

ここも概ねイメージ通り。開発用サーバーにコンポーネントをビルドするためのプロセスがどのぐらいお手軽かがポイントになりそう

shingo.sasakishingo.sasaki

Testing Types

E2E とコンポーネントテストの対比はここにも書いてるから軽く目を通しておこう
https://docs.cypress.io/guides/core-concepts/testing-types#What-you-ll-learn

E2E テストのメリット

  • アプリケーション全体を包括的にテストできる
  • テストシナリオがそのまま UX になる
  • QAチームでも書きやすい
  • 結合テストとしても書きやすい

E2E テストのデメリット

  • 環境のセットアップ、実行、メンテコストが高い
  • CI 上に実行環境を構築する必要がある
  • 正確なシナリオ実行のためにはさらなるセットアップが必要 (サードパーティAPIとか?)

E2E テストの一般的なシナリオ

  • 認証認可や課金機能など、クリティカルな機能の検証
  • 画面を跨いでのデータの永続性や表示の検証
  • デプロイ前のスモークテスト、システムチェック

コンポーネントテストのメリット

  • コンポーネント単位で独立したテストができる
  • 高速で高信頼性
  • シナリオのセットアップが容易
  • 外部システムに依存することがない

コンポーネントテストのデメリット

  • アプリケーション全体の担保にはならない
  • 外部API、サービスの呼び出しが行われない
  • たいていはQAでなく開発者のみがテストを実施する

コンポーネントテストの一般的なシナリオ

  • date picker がちゃんと動くか
  • Form が入力値に応じて表示されたり非表示になったりするか
  • Design system のテスト
  • コンポーネントに紐付かないテスト

(最後のはコンポーネントテストで書く必要ないんじゃないかな…?)

shingo.sasakishingo.sasaki

Cypress + Vue 3 のセットアップ

Vue 3 プロジェクトのセットアップはいつものことなので省略。このリポジトリ使う。
https://github.com/s-sasaki-0529/vue-3-vite-sandbox

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: テストデータのサンプルで多分いらない

これでセットアップ完了。
テストコードはもちろん、コンポーネント自体も何も作ってないので試してく。

shingo.sasakishingo.sasaki

テスト対象コンポーネントを実装

単純なカウント値を上下するボタンが付いたカウンターコンポーネントを用意

src/components/stepper.vue
<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 はアクセシビリティのテストに使うのかな?

shingo.sasakishingo.sasaki

もし記事化することを考えると、 Vue 3 独自の書き方より普通に Option API 使ったほうが良い気がするな。
ので書き換えちゃう。

src/components/stepper.vue
<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>
shingo.sasakishingo.sasaki

マウントコンポーネントについて

https://docs.cypress.io/guides/component-testing/mounting-vue

  • マウントはコンポーネントテストにおける準備段階で、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)
shingo.sasakishingo.sasaki

既存コンポーネントを元にテストコードを生成する

Cypress UI 上からテストコードの追加を選択したら勝手にファイルが作られた。

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

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

TypeScript 対応

そのままだとテストコード内での Cypress の型解決ができないので、 tsconfig の include オプションに追加する

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

これでレンダーに成功。 Vite のおかげもあってかかなり楽だったな。

shingo.sasakishingo.sasaki

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 の返り値を扱う必要がないって話ね。

shingo.sasakishingo.sasaki

CLI からコンポーネントのテストだけをする場合はオプション指定で良いらしい

cypress open --component
shingo.sasakishingo.sasaki

Cypress 用の testing-library もあるけど使わなくてもテストはできるよとのこと。
https://testing-library.com/docs/cypress-testing-library/intro/

元々 Cypress には E2E テスト用の強力なAPIがあるから、これなくても良いような気がするな。
Cypress の API はユーザー視点のものが主だから、だいぶでどのメソッドが実行されたかとかのアサートのためにはあると便利なのかな。

あとから触ってみるのはアリ

shingo.sasakishingo.sasaki

セレクタを用意する。

const counterSelector = '[data-cy=counter]'
const incrementSelector = '[aria-label=increment]'
const decrementSelector = '[aria-label=decrement]'

ここで wai-aria を使うのは宗派が分かれそうだけど、とりあえずはドキュメントの通りに使う。
あと [data-cy=...] のためのショートカットは欲しそう。どっかで出てくるかな。

shingo.sasakishingo.sasaki

ステッパーの初期値が0であることを担保してく。

describe('<Stepper />', () => {
  it('ステッパーの初期値は 0 である', () => {
    cy.mount(Stepper)
    cy.get(counterSelector).should('have.text', '0')
  })
})

mount した時点で、Cypress のサンドボックス上に描画されてるから、あとは普段の Cypress の E2E テストと同じ要領ってことか。

shingo.sasakishingo.sasaki

プロパティを与えた場合のテスト

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

props も簡単に渡せてありがたいけど、mount の第2引数が型安全になってほしい。 props の内容はまだしも props 自体補完できても良いのに。

shingo.sasakishingo.sasaki

What Else Should You Test in This Component?

このコンポーネントテストで何をテストしようかってコラム。

コンポーネントの単体テストにおいては、フレームワークを意識するようなホワイトボックステストをするより、UX を意識したユーザー視点のテストをしようって感じ。

shingo.sasakishingo.sasaki

インタラクティブな操作に関するテストを追加する

  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')
  })
shingo.sasakishingo.sasaki

リアルワールドのユーザーは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 でデクリメントしたらどうなるのかとか。

shingo.sasakishingo.sasaki

Emitted Event のテスト

https://docs.cypress.io/guides/component-testing/events-vue#Testing-Emitted-Events

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>
shingo.sasakishingo.sasaki

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} />)
shingo.sasakishingo.sasaki

emit のテストと画面表示のテストを1回のテストシナリオでやるかは開発者の裁量次第。混ぜちゃったほうが実行時間は短くて済むけど、可読性の低下や、失敗時のどこで失敗したかが不明瞭になるなどのデメリットも。

一般的に E2E と比べるとコンポーネントテストは高速なので、パフォーマンスを気にせず分けちゃっても良いと思う。

shingo.sasakishingo.sasaki

VueTestUtils と組み合わせることも出来るけど、既存のテストコードととの互換性の都合なので、ゼロから書くなら不要そうなのでスキップ

shingo.sasakishingo.sasaki

Slot のテスト

https://docs.cypress.io/guides/component-testing/slots-vue#The-Simplest-Slot

スロットのテストを書くためにスロットをふんだんに使ったモーダルコンポーネントを用意する。

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>

以下のパターンは網羅してる

  • デフォルトスロット
  • 名前付きスロット(フォールバックあり)
  • 名前付きスロット(フォールバックなし)

スロットスコープは Stepper のほうを改修してみる。

src/components/Stepper.vue
<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

shingo.sasakishingo.sasaki

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だとこの辺楽なんだけどね。

このスクラップは2022/09/08にクローズされました