BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

Vue 2 + TypeScript 環境に Testing Library を導入する

Vue 2 でコンポーネントテストを書くために

こんにちは。プログラミングをするパンダ(@Panda_Program)です。本記事は BASE アドベントカレンダー 2022 の4日目の記事です。

本記事では Vue 2 + TypeScript 環境に Testing Library を導入する方法をご紹介します。なお、Testing Library の使い方については本記事では触れていません。当該ツールの具体的な利用方法を知りたい方は公式ドキュメントをご覧ください。

Testing Library とは何か

Testing Library は Kent C. Dodds 氏が作成したコンポーネントテスト用のフレームワークです。 test-utils など他のテスト用フレームワークと異なり、コンポーネントの内部実装を意識する必要がなく、ユーザーがアプリケーションを使用する観点からテストを記述できる点に特徴があります。

以下で test-utils と Testing Library の公式ドキュメントからテスト記述例をピックアップして見ました。以下は完全に同等のテストではないですが、書き味や設計思想が異なる様子がテストコードから見て取れます。

vue test utils のテスト実装例

import { shallowMount } from '@vue/test-utils'
import HelloWorld from '../HelloWorld.vue'

describe('HelloWorld.vue', () => {
  test('renders props.msg when passed', () => {
    const msg = 'new message'
    const wrapper = shallowMount(HelloWorld, {
      propsData: { msg }
    })
    expect(wrapper.text()).toMatch(msg)
  })
})

Testing Library のテスト実装例

import { render, fireEvent } from '@testing-library/vue'
import Component from './Component.vue'

test('increments value on click', async () => {
  const {getByText} = render(Component)

  getByText('Times clicked: 0')

  const button = getByText('increment')

  await fireEvent.click(button)
  await fireEvent.click(button)

  getByText('Times clicked: 2')
})

Vue でコンポーネントテストを記述するのであれば、少し前までは test-utils というツールを使うのが一般的でした。

しかし、Vue 3 の公式ドキュメントが Testing Library を推奨していること、2022年現在では React でテストを書くにあたり Testing Library がまず選ばれること、Storybook を使ったインタラクションのテストCypress を使ったE2Eテストの記述に Testing Library が使えることから、フロントエンドのテスト実装方法の主流を学べるのみならず、コンポーネントテスト以外にも応用できる知識が得られると考えてこちらを採用しました。

Testing Library は Vue 向けの Vue Testing Library を用意しており、これは test-utils を薄くラップして Testing Library 方式でテストの記述を可能にしたものです。Vue Testing Library は Vue 3 向けのみならず、最新ではないものの Vue 2 にも対応したバージョンが存在します。

ところが、次で説明するように Vue Testing Library をインストールしただけでは弊社の Vue 2 環境で Testing Library は動作しませんでした。そこで、以下では Vue 2 環境でテストを実行可能にするために実施した工夫を紹介します。

Testing Library を Vue 2 に導入する

Testing Library Vue が使えなかった理由

Testing Library Vue が使えなかった理由を簡単に記します。

Testing Library の公式ドキュメントによると、Vue 2 では@testing-library/vue@5 を使うことを求められています。2022年12月時点で@testing-library/vue v5 の最新バージョンは v5.8.3(以下、5系と呼ぶ) です。

この Vue 2 対応の5系ではライブラリ側で Vuex に対する依存が明示的に存在しており、これが弊社の環境で5系の動作しない原因でした。render.js 内の以下のコード store が必ず Vuex に限定されるため、テストを実行すると Vuex 関連のエラーが発生するのです。

// src/render.js

export function render(...) {
  /// ...省略

  if (store) {
      const Vuex = require('vuex')
      localVue.use(Vuex)

      vuexStore = store instanceof Vuex.Store ? store : new Vuex.Store(store)
    }
  /// ...省略
}

BASE では、コンポーネントを跨ぐグローバルな状態管理に独自実装の Store を利用しています。これは弊社のプリンシパルテックリードである @ukyo が TypeScript で実装したものです。同期処理、非同期処理をシンプルに分けられて使いやすく型補完が効く上に、目まぐるしく変わるフロントエンドのトレンドや、「Vuex は辛い」「最近はPinia(ピーニャ)がいいらしい」など Vue コミュニティの動向を気にすることなく、サービス開発に集中できる代物です(なお、現在は社内での利用のみを想定しており、OSSとして公開していません)。

つまり、弊社では Vuex を使用していません。ただし、ライブラリ側の制約から Store に Vuex を 利用しないと Store に接続するコンポーネントのテストが不可能です。

そこでチーム内で検討した結果、Testing Library Vue の render.js をコピーして一部書き換えることにより、Vuex への依存を不要としつつ Testing Library を利用したテストを書けるようにする対策を取ることを決めました。フォークしたりコードをコピーするデメリットよりも、コンポーネントのテストを書いてリグレッションに備えるというメリットが上回ると考えた結果の意思決定でした。

Vue 2 で Testing Library のテストを書くための対策

弊社のVue 2 環境で Vue Testing Library に依存せず Testing Library のテストを書くための具体的な対策は2つあります。

1つ目は render.js の Vuex 依存の箇所を削除することです。これはあまり特筆することはありません。まず Testing Library Vue の src 配下のファイル index.js fire-event.js render.js をプロジェクトにコピーし、前記の render.js の Vuex 依存箇所を削除しました。

そして、export された render 関数を使ってコンポーネントテストを記述したところ、Vuex 関連のエラーは解消されました。

しかし、グローバルなコンポーネントである社内向け共通コンポーネント集 BBQ を利用したコンポーネントに対するテストや、 Vue Router を使ったルーティングのテストを記述するにあたり、別のエラーに遭遇しました。これらを一つ一つ解消していき、最終的に出来上がった関数が2つ目の対策です。

それは、render.js をラップする関数 customRender を作ったことです。この関数の責務は、Store や Props を引数として render 関数に渡したり、グローバルなコンポーネントを登録する処理を実行することです。

// custom-render.ts
import BBQ from '@baseinc/bbq'
import { Store } from '@web/vue-store'
import { createLocalVue } from '@vue/test-utils'
import { I18n } from '@web/shopadmin'
import Vue from 'vue'
import VueRouter, { RouteConfig } from 'vue-router'
import VeeValidate from 'vee-validate'

import { render } from './render'

import { addCustumValidators, getCustomOptions } from '../vee-validator-setup'

type State = Parameters<typeof Store.create>[0]
type Options = {
    initialState: State // Store の初期状態
    props?: Record<string, any> // コンポーネントに渡す Props
    routes?: VueRouter | RouteConfig[] // ルーティングの定義
}
type ConfigurationOption = {
    localVueOption?: (localVue: Vue) => void
    storeOption?: (store: State) => void
    routerOption?: (router: VueRouter) => void
}

export const customRender = <V extends Vue>(
    component: any, // this makes me sad :sob:
    // https://github.com/testing-library/vue-testing-library/blob/main/types/index.d.ts#L51
    { initialState, props, routes }: Options,
    { localVueOption, storeOption, routerOption }: ConfigurationOption = {}
) => {
    const localVue = createLocalVue()
    // バリデーションライブラリの登録
    addCustumValidators()
    localVue.use(VeeValidate, getCustomOptions())
    // 社内共通コンポーネント集の登録
    localVue.use(BBQ, {
        prefix: 'bbq-',
    })
    // Store の登録
    localVue.use(Store)
    // i18n 対応
    localVue.use(I18n)

    return render(
        component,
        {
            localVue,
            store: Store.create(initialState),
            routes,
            props,
        },
        // この第3引数は Testing Library Vue の v.5.8.3 の render 関数の第3引数に合わせている
        (localVue: Vue, store: State, router: VueRouter) => {
            localVueOption?.(localVue)
            storeOption?.(store)
            routerOption?.(router)
        }
    )
}

customRender 関数を使ったテストの記述例を掲載します。これは名前の入力欄に空文字を書き込んだ時、バリデーションエラーとなりエラー文言が表示されるというテストです。input で入力した内容は直接 Store で管理しています。

import { customRender as render } from '../custom-render'

test('名前が空欄の時、エラー文言を表示する', async () => {
    render(Component, { initialState: getInitialState() })

    const input = screen.getByLabelText(/名前/i)
    await fireEvent.update(input, '')
    expect(screen.queryByText('名前を入力してください')).toBeInTheDocument()
})

このテストが正常にパスすることにより、「BASE の独自 Store に接続したコンポーネントに対するテストが可能なこと」「グローバルなバリデーションライブラリのインタラクションがテストできること」「input は社内共通コンポーネント由来であるが、それに依存するコンポーネントのテストができること」の3点が確認できました。

なお、普段 React を書いている方向けに補足すると Vue 2の場合は input の入力値を直接 Store に書き込んでも、当該コンポーネント以外のコンポーネントが再レンダリングされることはありません。その結果、アプリケーションのパフォーマンスが劣化する懸念はないため、このような作りを許容しています。詳しくはVue.js の公式ドキュメント「他のフレームワークとの比較」のページをご覧ください。

以上で「Vue 2 で Testing Library の導入方法を紹介する」という本記事の目的は達成しました。ただ、せっかくのアドベントカレンダーなので、ここからは付録として Vue Router で画面遷移するテストの書き方の紹介と、Testing Library 導入がアクセシビリティ(a11y)の段階的な改善に繋がったというエピソードを紹介します。

付録

Vue Router で画面遷移をするテスト

本環境での Vue Router を使ったテストの書き方を紹介します。ページのアクセス時にログイン判定を行い、ログイン済みであれば /mypage に、非ログイン状態であれば /login に遷移するという架空の仕様を想定します。

この時、テストコードは以下のようになるでしょう。

import '@testing-library/jest-dom'
import { waitFor } from '@testing-library/dom'

import Component from '../settings-container.vue'

import { render } from '../../testing-library'
import { getInitialState } from '../../store'
import { routes } from '../../routes'

type Options = Partial<ReturnType<typeof getInitialState>>
const setup = (options?: Options) => {
    const { route } = render( // この render は上記で紹介した customRender
        Component,
        { initialState: { ...getInitialState(), ...options }, routes },
        {
            routerOption: (router) => {
                router.push('/settings')
            },
        }
    )
    return { route: route() }
}

test('ログイン済みのユーザーはマイページに遷移する', async () => {
    const { route } = setup({ isLoaded: true })
    await waitFor(() => expect(route.path).toBe('/mypage'))
})

test('非ログインユーザーはログインページに遷移する', async () => {
    const { route } = setup({ isLoaded: true, isCreated: true })
    await waitFor(() => expect(route.path).toBe('/login'))
})

render の返り値である route の中身は以下のような関数です。

// render.js
export function render(...) {
  // ...
    return { ...,  route: () => wrapper.vm.$route }
}

なお、Testing Library v5.8.3 での Vue Router のテストの書き方の例は公式の GitHub にも掲載されているので、そちらも併せてご覧ください。また、もう少し上手く書けそうな気はしているものの現時点では十分に役割を果たしているため、今後さらに Vue Router を使ったテストケースが増えた段階でリファクタリングを実施しようと思います(インクリメンタルな設計)。

テスト駆動の a11y 改善

コンポーネントテストを書くことが、共通コンポーネントの a11y(アクセシビリティ)を改善するきっかけにもなりました。ここでは2つ事例を紹介します。

1つ目は、「ドラッグ&ドロップでファイルをアップロードできる」というコンポーネントの対応です。このコンポーネントを使って画像アップロードのテストを記述していた際、Testing Library で一般的なセレクタである getByLabelText() で input タグを取得できませんでした。

ドラッグアンドドロップで画像をアップロードできるコンポーネントの例
upload

原因は、bbq-upload-boxが Props に id 属性を受け取れなかったことです。screen.getByLabelText('ファイルアップロード')と記述してテストを実行すると、ラベル「ファイルアップロード」に対応する form が見つからないというエラーが表示されます。

// セレクタで input を取得できない
<div>
    <label for="file-upload">ファイルアップロード</label>
    <bbq-upload-box
        label="画像"
        icon="image"
        accept="image/png"
        @change="uploadImage"
    ></bbq-upload-box>
</div>

そこで、bbq-upload-box が Props に id 属性を受け取れるように改修し、テストの記述と実装を続けました。

// セレクタで input を取得できない
<div>
    <label for="file-upload">ファイルアップロード</label>
    <bbq-upload-box
      id="file-upload" // この行を追加
        label="画像"
        icon="image"
        accept="image/png"
        @change="uploadImage"
    ></bbq-upload-box>
</div>

2つ目は、ボタンとしてクリックできるアイコンの対応です。アイコンボタンを含むコンポーネントのテストを書くにあたり、Testing Library のクエリを使ってこのボタンをクリックしようとしました。しかし、このままでは要素をうまく取得できませんでした。

icon button のサンプルが並んでいる
icon buttons

そこで、bbq-icon-buttonコンポーネントに aria-label を渡せるように書き換え、利用側のコンポーネントからラベルを指定する変更を加えました。結果的にアイコンボタンをクリックした際に発火するコールバックのテストが書けたのみならず、Storybook の a11y プラグインの「Buttons must have descernible text」という指摘も無くなりました。

Storybook でViolationがなくなったことをbeforeとafterで比較
icon button

a11y 対応の観点では、これらの改修内容はそれ自体ごく基本的なものだと思います。しかし、テストを書かなければ自分達でこの状態を発見することはなかったでしょう。もし気づいているのであれば既に誰かが修正しているはずです。対応内容の大小よりも、テストを書くことが a11y 改善のきっかけに繋がったことを喜ばしく思います。

ぜひ Testing Library を使ってフロントエンドでもテストを書いてみてください。

明日は @toshi-oliver さんの「AWS LambdaをPHPで使うためのベストな方法」です!ぜひご覧ください。