# Nuxtの自動テスト環境を構築しました(2)

今回、僕が関わっているNuxtのプロジェクトに後付けで自動テストを導入しました。

巷では自動テストは必須だの必要だのとよく言われる割に、 ベストプラクティスというのが欠けている感じがして環境の構築にかなり苦労しました。

今回、Nuxtのプロジェクトに広く使えそうな手法を見つけましたので、 同じように悩んでいるみなさんの助けになればと思います。

# 環境構築

さて、前回の記事で自動テストの方針決め・技術選定までができましたので、ここからはいよいよ環境構築に入ります。

テスト用のディレクトリ構成は下記のようにしました。

tests/
┃
┣ unit/
┃  ┣ components/
┃  ┃  ┣ SomeComponent.test.js
┃  ┃  ┗ ...
┃  ┗ setup.js
┃
┗ e2e/
    ┣ pages/
    ┃  ┣ index.test.js
    ┃  ┗ ...
    ┣ utils/
    ┃  ┗ functions.js
    ┣ setup.js
    ┗ teardown.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

見たらわかると思いますが、それぞれNuxtの components/pages/ のディレクトリに対応するようにしています。

これによってカバレッジを確認しなくともテスト未作成のコンポーネントやページをすぐに確認できるようになっています。

ディレクトリ構成ができたところで、設定ファイルなどを作成していきましょう。

# ■単体テスト用の設定

まず jest を利用するために、 jest.config.js を作成していきます。

ユニットテストは単純にコンポーネントの入出力をテストするだけなのでシンプルです。

// jest.config.js
module.exports = {
  collectCoverage: true,
  collectCoverageFrom: [
    '<rootDir>/components/**/*.vue',
  ],
  moduleNameMapper: {
    '^@@/(.*)$': '<rootDir>/$1',
    '^@/(.*)$': '<rootDir>/$1',
    '^~/(.*)$': '<rootDir>/$1',
    '.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
  },
  moduleFileExtensions: ['js', 'vue', 'json'],
  setupFiles: ['<rootDir>/tests/unit/setup.js'],
  testMatch: ['<rootDir>/tests/unit/components/**/?(*.)+(spec|test).[jt]s?(x)'],
  transform: {
    '^.+\\.js$': 'babel-jest',
    '.*\\.(vue)$': 'vue-jest',
  },
  verbose: true,
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

基本的にコピペで十分だと思いますが、軽く補足しておくと

  • collectCoverageFrom でテスト対象のディレクトリを指定することで、カバレッジを表示できるようにしています。
  • setupFilessetup.js を指定することで、テスト開始前に初期化処理を入れています。
  • verbose はテストの詳細を表示するもので、出力結果がドキュメントとして読めるようになり、テストレビューがしやすくなります。おすすめ。

次に setup.js の中身を見てみましょう。

// setup.js
import 'intersection-observer'
import Vue from 'vue'
import Vuex from 'vuex'
import { config, RouterLinkStub } from '@vue/test-utils'
Vue.use(Vuex)
config.stubs = {
  Nuxt: '<div />',
  NuxtLink: RouterLinkStub,
  RouterLink: RouterLinkStub,
  ClientOnly: '<span><slot /></span>',
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

ユニットテストでは、Nuxtフレームワークとしてではなく、Vueのコンポーネント単体としてテストを実施します。

そのためNuxtでは自動的に読み込んでくれていたVuexやVuetifyなどのコンポーネントセット、プラグイン等は必要に応じて明示的に読み込む必要があります。 こうした初期化処理はまとめて setup.js で行うようにします。

# ■機能テスト用の設定

機能テストでは puppeteer や swagger といった複数のアプリケーションを起動する必要があるため、 設定が複雑になります。

まずはコンフィグファイルから見ていきましょう。

// jest.config.e2e.js
module.exports = {
  bail: 1,
  collectCoverage: false,
  globalSetup: '<rootDir>/tests/e2e/setup.js',
  globalTeardown: '<rootDir>/tests/e2e/teardown.js',
  moduleNameMapper: {
    '^@@/(.*)$': '<rootDir>/$1',
    '^@/(.*)$': '<rootDir>/$1',
    '^~/(.*)$': '<rootDir>/$1',
    '.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
  },
  moduleFileExtensions: ['js', 'vue', 'json'],
  preset: 'jest-puppeteer',
  testMatch: ['<rootDir>/tests/e2e/**/?(*.)+(spec|test).[jt]s?(x)'],
  transform: {
    '^.+\\.js$': 'babel-jest',
    '.*\\.(vue)$': 'vue-jest',
  },
  verbose: true,
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

ここでも補足をしておきます。

  • ブラウザを利用するテストは時間がかかるため、時間短縮のために bail でテストに1回失敗したら即終了するように設定しました。 これは稼働時間で課金されるCIのコスト低減が目的です。
  • puppeteer を利用したテストでは、カバレッジが正しく測れないため collectCoverage は無効にしています。
  • globalSetup globalTeardown は最初と最後に1回だけ実行される処理です。ブラウザやサーバの起動・終了を管理しています。

そして jest.config.e2e.js を読み込むための設定が必要です。 package.json に下記のように記述をします。

{
  "scripts": {
    "test:e2e": "jest --config=jest.config.e2e.js --runInBand"
  }
}
1
2
3
4
5

これで機能テスト向けの設定ができました。

--runInBand はテスト1個1個を同期的に実施するためのオプションです。 APIサーバの戻り値によってテストの結果が変化してしまうため、非同期でのテストができなかったための対処です。

では次に globalSetup の内容を見てみましょう。

// setup.js
import jestPuppeteerSetup from 'jest-environment-puppeteer/setup'
import { Builder, Nuxt } from 'nuxt'
import config from '../nuxt.config'
import server from '../swagger/app'
import './utils/functions'
/**
 * Nuxtのビルドとサーバーの起動
 */
const setupNuxtServer = async () => {
  global.nuxt = new Nuxt(config)
  await new Builder(nuxt).build()
  nuxt.listen(3000)
}
/**
 * SwaggerAPIのモックサーバを起動
 */
const setupApiMockServer = async () => {
  global.server = await server
}
export default async () => Promise.all([
  setupNuxtServer(),
  setupApiMockServer(),
  jestPuppeteerSetup(),
])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

注目していただきたいのが export default の部分です。

Nuxtサーバ・APIモックサーバ・そして puppeteer(ブラウザ)が全て起動すればテストが開始されるようになっています。 Promise.all() の使い方はしっかりと覚えておいてくださいね。

続いて、 Swagger もインストール後にちょっと手を加えていますのでお見せします。

// swagger/app.js
const SwaggerExpress = require('swagger-express-mw')
const bodyParser = require('body-parser')
const app = require('express')()
const config = {
  appRoot: __dirname, // required config
}
module.exports = new Promise(resolve => {
  SwaggerExpress.create(config, function(err, swaggerExpress) {
    if (err) throw err
    app.use(bodyParser.urlencoded({ extended: false }))
    app.use(bodyParser.json())
    // install middleware
    swaggerExpress.register(app)
    const port = process.env.PORT || 10010
    resolve(app.listen(port))
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

ここでは module.exports の戻り値に app.listen() が非同期で返されていると思います。 これにより、 globalTeardown でAPIサーバを終了させることができます。

TIP

globalSetup で設定したグローバル変数は、 globalTeardown に引き継がれます。 セットアップした状態を簡単に終了させることができるようになっているんですね。

globalTeardown はこのようになっています。

// teardown.js
import jestPuppeteerTeardown from 'jest-environment-puppeteer/teardown'
export default () => {
  global.nuxt.close()
  global.server.close()
  jestPuppeteerTeardown()
}
1
2
3
4
5
6
7
8
9

これで環境構築はできました。次はいよいよテストコードを書いていきましょう。

# 単体テストの書き方

単体テストでは、その名の通りVueのコンポーネント単体をテストしていきます。 書き方としては、 propsdata の項目を網羅する ようにテストを書くのがコツです。

実際に見ていきましょう。

例として、Webアプリの「ホームに戻る」という機能を持つ HomeButton というボタンコンポーネントを用意しました。 これはVueのフレームワークである Vuetifyv-btn をラップしたコンポーネントです。

<template>
  <v-btn
    v-bind="$attrs"
    :to="to"
    :outlined="outlined"
    class="home-button no-active"
    color="blue"
    nuxt
  >
    ホームに戻る
  </v-btn>
</template>
<script>
export default {
  name: 'HomeButton',
  props: {
    to: { type: String, default: '/' },
    outlined: { type: Boolean, default: true },
  },
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

このコンポーネントは props が to と outlined の2種類のみです。 それぞれ「リンク先のパス」「ボタン背景を枠線のみにする/しない」の設定項目となります。

これに対応する単体テストはこのようになります。

// unit/components/HomeButton.test.js
import Vuetify from 'vuetify'
import { createLocalVue, mount, RouterLinkStub } from '@vue/test-utils'
import Component from '@/components/HomeButton'
const localVue = createLocalVue()
const vuetify = new Vuetify()
const wrapper = mount(Component, {
  localVue,
  vuetify,
})
describe(`[${Component.name}]のテスト`, () => {
  describe('デフォルトの表示', () => {
    test('「ホームに戻る」と表示されている', () => {
      expect(wrapper.text()).toBe('ホームに戻る')
    })
    test('button ではなく aタグである', () => {
      expect(wrapper.is('a')).toBe(true)
    })
    test('リンク先が "/" になっている', () => {
      expect(wrapper.find(RouterLinkStub).props().to).toBe('/')
    })
    test('文字色が blue である', () => {
      expect(wrapper.classes()).toContain('blue--text')
    })
    test('枠線のみになっている', () => {
      expect(wrapper.classes()).toContain('v-btn--outlined')
    })
  })
  describe('カスタマイズ', () => {
    describe('リンク先を "/home" に変更', () => {
      beforreAll(() => {
        wrapper.setProps({ to: '/home' })
      })
      test('リンク先が "/home" になっている', () => {
        expect(wrapper.find(RouterLinkStub).props().to).toBe('/home')
      })
    })
    describe('枠線のみを解除', () => {
      beforeAll(() => {
        wrapper.setProps({ outlined: false })
      })
      test('背景色が blue になる', () => {
        expect(wrapper.classes()).toContain('blue')
        expect(wrapper.classes()).not.toContain('blue--text')
      })
    })
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60

フレームワークの読み込みを忘れないように

※Vuetifyを使用するためには、 setup.js で読み込み設定が必要になります。

このように「初期状態」と「カスタマイズして変わること(または変わらないこと)」をテストに記述しておくことで、 無駄なく漏れの少ないテストとなり、文書としても読みやすくなります。

またお気づきのように、 propsdata の項目数が増えるごとにテストしなければならない数が増えていき、 メンテナンスの手間も増えていってしまいます。

テスタブルなコンポーネントを設計する際には、 propsdata の数が少ないほどテストが楽になる ということを覚えておくといいかもしれません。

コンポーネントにはなるべく状態を持たせないようにしよう

data のテストは大変です。 コンポーネントの外部から値を注入しづらい上に、内部では自由に値を変えられるために状態を固定するのが難しいためです。 components/ の中ではなるべく data を使わないようにしましょう。 Vuexも同様です。

ちなみに僕が最近開発したPJでは、 components/ 内のコンポーネントには data は一切使っていませんでした。

# 機能テストの書き方

単体テストでは props に対して値を直接入力した結果をテストしていました。 これが機能テストになると、APIの戻り値やVuexの状態、フォームの入力値など様々な値が画面に影響を及ぼします。

そのため、画面の状態に大きな影響を及ぼす変化からカテゴリ分けをしてテストのフローを組み立てていきます。

例えばECサイトの商品ページのテストであれば、このような構造になると思います。

APIの戻り値
┃
┣ 404 エラー(商品が見つかりませんでした)
┃  ┃
┃  ┗ エラー画面のため、Vuexやフォームは検証不可
┃
┗ 200 OK(商品ページ)
    ┃
    ┗ ログイン状態の検証(Store)
        ┃
        ┣ ログイン済み
        ┃  ┗ 送付先フォームは非表示
        ┃
        ┗ 未ログイン
            ┗ 送付先フォームの入力検証
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

基本的には APIの戻り値 > ローカルの状態管理 > フォームの値 の順に影響範囲が大きいと思いますので、 影響範囲の大きさ順に階層を作ることで、抜け漏れなく網羅することができます。

実際のコードにするとこんな感じになります。

describe(`商品ページのテスト`, () => {
  beforeEach(() => {
    page.goto(url)
  })
  describe('商品が見つからない場合', () => {
    test('「商品が見つかりませんでした」と表示される', async done => {
      const text = await page.$eval('.error-message.', el => el.textContent)
      expect(text).toBe('商品が見つかりませんでした')
      done()
    })
    test('「ホームに戻る」ボタンが表示される', async done => {
      const text = await page.$eval('.home-button', el => el.textContent)
      expect(text).toBe('ホームに戻る')
      done()
    })
  })
  describe('商品が存在する場合', () => {
    beforeAll(async () => {
      await axios.post('/products', sampleProduct)
      await page.goto(url)
    })
    describe('未ログインの場合', () => {
      test('送付先フォームが存在する', async done => {
        const $el = await page.$('#profile-input-form')
        expect(!!$el && (await $el.boundingBox()) !== null).toBe(true)
        done()
      })
      describe('入力値の検証', () => {
        describe.each([
          ['郵便番号: なし', '', '郵便番号は必須項目です'],
          ['郵便番号: 0001111', '0001111', ''],
          ['郵便番号: 000-1111', '000-1111', ''],
          ['郵便番号: 12345678', '12345678', '郵便番号の入力形式が間違っています'],
          ['郵便番号: 1234-5678', '1234-5678', '郵便番号の入力形式が間違っています'],
        ])('%s', (title, input, error) => {
          test(error || 'OK', async done => {
            await page.type('#profile-input-form .zipcode', input)
            const text = page.$eval('#profile-input-form .zipcode error-message', el => el.textContent)
            expect(text).toBe(error)
            done()
          })
        })
        // ...各フォーム値の検証
      })
    })
    describe('ログイン済みの場合', () => {
      beforeAll(async () => {
        await axios.post('/users', user)
        await page.goto(url)
      })
      test('送付先フォームが存在しない', async done => {
        const $el = await page.$('#profile-input-form')
        expect(!!$el).toBe(false)
        done()
      })
    })
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66

このテストではAPIのモックサーバとして Swagger を使用していますが、 axios でPOSTされた値を保持しておき、次にGETされた時にその値をそのまま返すようにしてあります。

これによって、DBを使用せずにテストコードからAPIの戻り値を編集できるようになっているんですね。

そして大きな項目から小項目へとネストをすることで、無駄な処理やテストの重複を極力減らす形になっていると思います。

またテスト中に「ホームへ戻る」ボタンが出てきますが、 これは単体テストで動作を保証しているので存在チェックだけで問題ありません。 機能テストはテストの作成にも実行にも時間がかかりますので、 できるだけコンポーネントは分割して単体テストで検証しておくことも重要なコツです。

# ページをまたぐテストをしたい場合

機能テストを行う中で、ページをまたぐテストを行いたい場合があると思います。

例えば 「カートに入れる」 という機能で、こんなテストを実施したいという場合です。

  1. 商品ページで「カートに入れる」ボタンを押すと、「カートに追加完了」画面に遷移する
  2. ホームに戻ると、カートのアイコンに数字が追加されているのが確認できる
  3. カートのアイコンをクリックすると、追加した商品が入っているのが確認できる

こういったパターンへのアプローチは色々あると思いますが、 何も考えずに実装すると後々メンテナンスができずに苦労することになります。(実体験)

僕なら 1, 2, 3 それぞれを別画面のテストとして分ける形にすると思います。 具体的に言うと、

  1. 商品ページのテスト
  2. 「カートに追加完了」画面のテスト
  3. ホーム画面のテスト

といった形でページごとにテストを分け、 2 や 3 をテストする場合はAPIの戻り値でカートに入れた時点の状態を再現するようにします。

各ページの責務を完全に分けてしまうことで、「どのテストをどこに書いたら良いか」で迷うことを減らす目的があります。 また動作に変更が入った場合に影響範囲の切り分けがしやすくなるメリットもあります。

状態をVuexで持ち回っている場合などはVuexのテストが難しい問題点はありますが、 実も蓋もない話ですがそもそもVuexはテストしづらいので、あんまりデータをVuexで管理するのはおすすめしません。

# まとめ

自動テストの重要性を語る記事や、何の役にも立たないサンプルコードが載っている記事は巷に溢れていますが、 実践的な自動テストの記事が全然見つからなかったことからこの記事を書くに至りました。

  • 自動テストって言うけど、そもそも何をテストしたらいいのかわからない
  • テスタブルな実装にはしてみたけど、これをどうやってテストに起こせば良いのかわからない
  • テストを自動化はしてみたけど、このやり方で合ってるのか不安

そんな思いを感じているみなさまの役に立ってくれたら幸いです!