search
LoginSignup
17

posted at

updated at

Organization

Vue, Vitest, Testing Library, MSWを使ってテスト駆動開発するチュートリアル

今回は以下のライブラリを中心にVueにおけるテスト駆動開発(TDD)の進め方を説明します。

  • Vue3
  • Vitest
  • Testing Library
  • Mock Service Worker

Options APIで書きますが、テストコードはComposition APIでも動くので、
Composition APIの実装に多少慣れてる人はぜひとも挑戦してください。

今回の記事の中で作ったコードは以下のリポジトリに収めました。

テスト駆動開発(TDD)ってなに?

TDDとはTest Driven Development(テスト駆動開発)の略であり、その文字通り、
テストを先に書いてその後にそのテストを満たすコードを書くことを繰り返しながら進める開発手法です。
XP(Extreme Programming)のアプローチの1つとしてとても有名です。
実施においては以下の図のRedから順番に開発をサイクルで回していきます。

image.png

細かい仕様ごとにRedに立ち返るようなサイクルで開発できると良いです。
例えば、バリデーションエラー表示つきの入力フォーム1つをとっても以下の1つずつテストを回します。

  1. 入力フォームが表示されていること
  2. 入力フォームに入力があった場合、値が表示されていること
  3. 入力フォームに誤った値がある時にエラーが表示されること
  4. 入力フォームに正しい値がある時にエラーが表示されないこと

まれにある勘違いとして、「テスト工程が開発工程の前に来る」という誤解がありますがそうではなく、
テスト「駆動」開発なので「テストを書く⇒開発」を小さい単位で進めていくことが重要です。
工程は明確に分かれるのではなくほぼ同時に進めるイメージです。

環境構築

必要なライブラリをインストールします。
Vueのベースとなるプロジェクトをどう作ったかによってインストール済みのライブラリが変わります。
よくある例としてはnpm init vue@latestnpm create vite@latestの2通りです。
vue@latestを使う場合はオプションでUnit Testingの環境も込みで作成可能です。
どちらにせよ、自分の環境に合わせて必要なものは追加してください。

$ npm install -D vitest @vue/test-utils jsdom @testing-library/vue

これで必要なライブラリがインストールできたら、次はVitestを起動するスクリプトを書きます。
package.jsonscripts"test:unit"を追加しましょう。

package.json
{
  ...
  "scripts":{
    "dev": "vite",
    "build": "vite build",
    "test:unit": "vitest --environment jsdom",
    ...
  },
  ...
}

この状態で追加したコマンドを実行します。

$ npm run test:unit

> vue-vite-ts@0.0.0 test:unit
> vitest --environment jsdom


 DEV  v0.18.0 C:/dev/occfront-des

include: **/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}
exclude:  **/node_modules/**, **/dist/**, **/cypress/**, **/.{idea,git,cache,output,temp}/**        
watch exclude:  **/node_modules/**, **/dist/**

No test files found, exiting with code 1

これでテストが走りました。テストコードがない場合は上の表示になります。
vue@latestベースの場合は1つサンプルがあるのでテストがパスするはずです。
ただ、今回は新たなコンポーネントを作成してそれをベースにテストを進めようと思うので削除してもOKです。

TDDのチュートリアル

まずは作るページの仕様が決まっていないとTDDできないので、
サインアップのページを作っていきます。

  • Sign Upのヘッダーが存在する
  • 登録用フォーム
    • ユーザー名の入力
    • メールアドレスの入力
    • パスワードの入力
    • パスワード再確認の入力
    • サインアップボタン
  • 登録ボタンを押したらサーバーにリクエストが送られる
  • サーバーからエラーが帰ってきた場合、エラーメッセージを表示する

まずはベースとなるテストのファイルを用意します。
vitestはディレクトリ内のspec.jsを探して実行してくれるので、
保存場所はどこでも大丈夫ですが私はコンポーネントと同じ場所にセットで保存してます。
理由はspec.jsspecはSpecification、つまり仕様を意味します。
テストコード=仕様書となるように更新し続けられるとベストですよね(実際は色々あるけども)。

SignUpPage.spec.js
import SignUpPage from './SignUpPage.vue'
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/vue'
SignUpPage.vue
<template>
</template>

このぐらいでOKです。

レイアウトのTDD

まずは「Sign Upのヘッダーが存在する」のテストコード実装します。

SignUpPage.spec.js
import SignUpPage from './SignUpPage.vue'
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/vue'

it('Sign Upヘッダーが表示される', () => {
  render(SignUpPage)
  const header = screen.getByRole('heading', { name: 'Sign Up' })
  expect(header).toBeTruthy()  
}

インポートしたSignUpPageをレンダリングして、
レンダリング結果のscreenからgetByRoleSign Upとう文字列を含んだ要素を取得しています。
この結果が存在するかどうかをexpect(header).toBeTruthy()というアサーション(検証条件)で、
「ヘッダー(header)がTruthy(true判定される)な値であることを期待(expect)する」とテストしています。

これを実際にテスト実行してみましょう。
失敗してFAILEDと表示されると思います。

$ npm run test:unit

> vue-test-project@0.0.0 test:unit
> vitest --environment jsdom     

 DEV  v0.18.1 C:/dev/vue-test-with-vitest

 ❯ src/pages/SignUpPage.spec.js (1)
   × Sign Upヘッダーが存在する     

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
 FAIL  src/pages/SignUpPage.spec.js > Sign Upヘッダーが表示される
TestingLibraryElementError: Unable to find an accessible element with the role "heading" and name "Sign Up"

There are no accessible roles. But there might be some inaccessible roles. If you wish to access them, then set the `hidden` option to `true`. Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole

Ignored nodes: comments, <script />, <style />
<body>
  <div />
</body>
 ❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:40:19
 ❯ node_modules/@testing-library/dom/dist/query-helpers.js:90:38
 ❯ node_modules/@testing-library/dom/dist/query-helpers.js:62:17
 ❯ node_modules/@testing-library/dom/dist/query-helpers.js:111:19
 ❯ src/pages/SignUpPage.spec.js:9:29
      7|     it('Sign Upヘッダーが存在する', () => {
      8|       render(SignUpPage)
      9|       const header = screen.getByRole('heading', { name: 'Sign Up' })
       |                             ^
     10|       expect(header).toBeTruthy()

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files  1 failed (1)
     Tests  1 failed (1)
      Time  2.49s (in thread 64ms, 3893.38%)


 FAIL  Tests failed. Watching for file changes...
       press h to show help, press q to quit

みなさんのターミナルにはもう少し見やすいカラーで表示されていると思いますが、
どこでテストが落ちたのかも一目瞭然でわかるようになってます。
getByRoleでアクセスしようとした要素が無いというエラー(TestingLibraryElementError)ですね。
これが冒頭に説明した図でいうRedの状態、テストを先に作成して落ちている状態です。
次に、このテストが通るよう実装をコンポーネント側にしていきます。

SignUpPage.vue
<template>
  <h1>Sign Up</h1>
</template>

するとどうでしょうか。テストが自動的に再実施され以下のように表示されると思います。

 ✓ src/pages/SignUpPage.spec.js (1)

Test Files  1 passed (1)
     Tests  1 passed (1)
      Time  122ms


 PASS  Waiting for file changes...
       press h to show help, press q to quit

PASSと表示されましたね。これがGreenの状態です。
このようにテストは高速に再実施されるので、実装に誤りがあった時はすぐ検出できます。
実装で思わぬ副作用からバグを埋め込んでも積上げたテストコードが教えてくれる可能性が増えるわけです。
テストコードが実装とともに増えていくのでアプリ&テストの両面でリファクタリングがしやすくなります。

TDDのRefactoringを実施しましょう。
ヘッダーはリファクタの余地ないので、テストコード側をリファクタします。
itでテストを表現してましたがdescribeも組合せてみましょう。
itは一つのテストを意味し、describeは複数のテストをまとめるための関数です。

SignUpPage.spec.js
...

describe('SignUpPage', () => {
  it('Sign Upヘッダーが表示される', () => {
    render(SignUpPage)
    const header = screen.getByRole('heading', { name: 'Sign Up' })
    expect(header).toBeTruthy()
  })
})

このdescribeitは変わった関数名ですが、実は英語だとわかりやすいんです。
Describe SignUpPage, it has Sign Up as a header
(SignUpPageの説明、Sign Upヘッダーが存在する)の用に通常の文章の用に仕様が書けるのです。
ただ日本語なので若干そこのニュアンスは伝えづらいですが、そういうものだと理解しておきましょう。
この状態でもテストが通ることを確認できたら順調です。
続いて、ユーザー名の入力フォームのテストを実装します。

SignUpPage.spec.js
...
import { describe, it, expect, afterEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/vue'

describe('SignUpPage', () => {
  afterEach(cleanup)
  it('Sign Upヘッダーが表示される', () => {
    render(SignUpPage)
    const header = screen.getByRole('heading', { name: 'Sign Up' })
    expect(header).toBeTruthy()
  })

  it('ユーザー名の入力フォームが表示される', () => {
    const { container } = render(SignUpPage)
    const input = container.querySelector('input')
    expect(input).toBeTruthy()
  })
})

今度はrender結果のcontainer(Document)から、input要素を探し存在チェックをするテストです。
複数テストになったタイミングでafterEach(cleanup)が追加されました。
これはrender後にレンダー結果をリセットするためにいれます。
cleanupがないとレンダ結果が積み上がりテストが壊れます(Jestは自動でcleanupが走るがVitestは走らない)。
このテストで保存するとFAILになるので、PASSするようコンポーネントを実装しましょう。

SignUpPage.vue
<template>
  <h1>Sign Up</h1>
  <input type="text">
</template>

これで良いのか?と思うかもしれませんが一旦このまま進みます。
続いて、Eメールアドレス用入力フォームのテストを書きます。

SignUpPage.spec.js
...

describe('SignUpPage', () => {
  ...
  it('ユーザー名の入力フォームが表示される', () => {
    const { container } = render(SignUpPage)
    const input = container.querySelector('input')
    expect(input).toBeTruthy()
  })

  it('Eメールの入力フォームが表示される', () => {
    const { container } = render(SignUpPage)
    const input = container.querySelector('input')
    expect(input).toBeTruthy()
  })
})

さて、テストが落ち……ずに通ってしまいました。
これは当たり前で、両テストともinputの要素が存在していることしかチェックしていないからです。
より良いテストをするためにテストを書き直しましょう。
入力フォームは通常ラベルlabelと併用されることが多いので、それを利用したテストにします。

SignUpPage.spec.js
describe('SignUpPage', () => {
  ...
  it('ユーザー名の入力フォームが表示される', () => {
    render(SignUpPage)
    const input = screen.queryByLabelText('ユーザー名')
    expect(input).toBeTruthy()
  })

  it('メールアドレスの入力フォームが表示される', () => {
    render(SignUpPage)
    const input = screen.queryByLabelText('メールアドレス')
    expect(input).toBeTruthy()
  })
})

これでテストが落ちるので、コンポーネントを更新しましょう。

SignUpPage.vue
<template>
  <h1>Sign Up</h1>
  <label for="username">ユーザー名</label>
  <input id="username" type="text" />
  <label for="email">メールアドレス</label>
  <input id="email" type="text" />
</template>

これでテストが通るはずです。
これでちゃんとテストの意味があるのかを確認するためにも、
labelの文字を変えてみたり、input要素を削除してテストが失敗するかを確認してみてください。
同じ要領でパスワードとパスワード確認のテストと実装も進めてみましょう。
テストは問題ないとは思いますが、以下のようにパスワードが表示されてしまいます。

image.png

これを避けるためにはinputtype="password"にしないといけませんが、
それもテストしてみましょう。

SignUpPage.spec.js
...
  it('パスワードの入力フォームのtypeがpasswordであること', () => {
    render(SignUpPage)
    const input = screen.getByLabelText('パスワード')
    expect(input.type).toBe('password')
  })
...

input要素のtype属性がpasswordであることを確認しています。
こんな形で要素の属性にアクセスできるので他のケースでもよく使うやり方です。
まとめてテストを進めるのはあまり推奨されませんがここまで同じ作業なら多少はokです。
パスワードとパスワード確認の両テストを実装しRedになったらコンポーネントを実装してください。

SignUpPage.vue
<template>
  <h1>Sign Up</h1>
  <label for="username">ユーザー名</label>
  <input id="username" type="text" />
  <label for="email">メールアドレス</label>
  <input id="email" type="text" />
  <label for="password">パスワード</label>
  <input id="password" type="password" />
  <label for="passwordcheck">パスワード確認</label>
  <input id="passwordcheck" type="password" />
</template>

input周りのテストが終わったら次は登録ボタンです。
ボタンはまたgetByRoleで要素の取得をします。

SignUpPage.spec.js
...
  it('登録用ボタンが表示される', () => {
    render(SignUpPage)
    const button = screen.getByRole('button', { name: '登録' })
    expect(button).toBeTruthy()
  })
})

テストがRedになったらGreenにするための実装をしましょう。

SignUpPage.vue
<template>
  <h1>Sign Up</h1>
  ...
  <button>登録</button>
</template>

初期表示時には登録ボタンが押せないようにいたいため、disabledのテストも追加します。

SignUpPage.spec.js
...
  it('登録ボタンが初期表示時はdisabledとなっている', () => {
    render(SignUpPage)
    const button = screen.getByRole('button', { name: '登録' })
    expect(button.disabled).toBeTruthy()
  })
})

Red ⇒ Green にしましょう。

SignUpPage.vue
<template>
  <h1>Sign Up</h1>
  ...
  <button disabled>登録</button>
</template>

ここまでできたら初期表示のレイアウトとしてテストは十分なので、
一旦次に備えてこれまでのテストをdescribeで囲み、レイアウトのテストであることを明記します。

SignUpPage.spec.js
...
describe('SignUpPage', () => {
  afterEach(cleanup)

  describe('レイアウト', () => {
    // これまでのテスト
  })
})

インタラクションのTDD

レイアウトのTDDは静的な状態に対するテストが主だったのでシンプルでした。
インタラクションのTDDはコンポーネントの<script>も実装するので少しだけ難易度があがります。
まずは先程設定したdisabledが、
「全フォームの内容が入力済かつパスワードが一致」の場合に解除されるテストを書いてみましょう。

SignUpPage.spec.js
...
import { render, screen, cleanup, fireEvent } from '@testing-library/vue'
...
describe('SignUpPage', () => {
  ...
  describe('インタラクション', () => {
    it('全フォーム入力済み、かつパスワードとパスワード確認が同じ値の場合、登録のdisabledが解除される', async () => {
      render(SignUpPage)
      const usernameInput = screen.getByLabelText('ユーザー名')
      const emailInput = screen.getByLabelText('メールアドレス')
      const passwordInput = screen.getByLabelText('パスワード')
      const passwordCheckInput = screen.getByLabelText('パスワード確認')
      await fireEvent.update(usernameInput, 'Usern')
      await fireEvent.update(emailInput, 'user@example.com')
      await fireEvent.update(passwordInput, 'P4ssw0rd')
      await fireEvent.update(passwordCheckInput, 'P4ssw0rd')
      const button = screen.getByRole('button', { name: '登録' })
      expect(button.disabled).toBe(false)
    })
  })
})

レイアウトと分けてグループ化するためにインタラクションのdescribeを作ります。
その中で、レイアウトでも使ったinput要素の取得をしながら、
fireEventを新たにインポートして使っていることがわかります。
これはTesting LibraryによるAPIで様々なイベントを発火させることができます。
今回はupdateイベントを使って指定したinput要素に値を入力しています。
fireEventはVueの画面更新の特性上非同期なので、async/awaitを追加しています。
保存してRedになったらGreenにするために実装しましょう。

SignUpPage.vue
<template>
  <h1>Sign Up</h1>
  <label for="username">ユーザー名</label>
  <input id="username" type="text" v-model="username" />
  <label for="email">メールアドレス</label>
  <input id="email" type="text" v-model="email" />
  <label for="password">パスワード</label>
  <input id="password" type="password" v-model="password" />
  <label for="passwordcheck">パスワード確認</label>
  <input id="passwordcheck" type="password" v-model="passwordCheck" />
  <button :disabled="isDisabled">登録</button>
</template>

<script>
export default {
  name: 'SignUpPage',
  data() {
    return {
      username: '',
      email: '',
      password: '',
      passwordCheck: '',
    }
  },
  computed: {
    isDisabled() {
      if (
        this.username &&
        this.email &&
        this.password &&
        this.password === this.passwordCheck
      )
        return false
      return true
    },
  },
}
</script>

一気に実装しましたが、そんなに複雑なことはしていません。
各フォームに対してv-modelを使って値をdataと紐づけます。
その次にボタンの活性非活性を管理するdisabledcomputedに作り、
指定の条件下でtrue/falseを返すようにしています。
試しに、パスワードが不一致の場合にエラーになるかをチェックするテストを追加しましょう。

SignUpPage.js
...
    it('全フォーム入力済でも、パスワードが不一致の場合、登録ボタンがdiasbledになる', async () => {
      render(SignUpPage)
      const usernameInput = screen.getByLabelText('ユーザー名')
      const emailInput = screen.getByLabelText('メールアドレス')
      const passwordInput = screen.getByLabelText('パスワード')
      const passwordCheckInput = screen.getByLabelText('パスワード確認')
      await fireEvent.update(usernameInput, 'Usern')
      await fireEvent.update(emailInput, 'user@example.com')
      await fireEvent.update(passwordInput, 'P4ssw0rd')
      await fireEvent.update(passwordCheckInput, 'password')
      const button = screen.getByRole('button', { name: '登録' })
      expect(button.disabled).toBe(true)
    })
  })
})

今回は特に何もしなくてもGreenになると思います。
先程のコードと重複している点が多いので、テストコードをリファクタしましょう。
全項目を入力するメソッドを切り出します。

SignUpPage.js
...
  describe('インタラクション', () => {
    it('全フォーム入力済、かつパスワードとパスワード確認が同じ値の場合、登録のdisabledが解除される', async () => {
      render(SignUpPage)
      await fillAllForm('Usern', 'user@example.com', 'P4ssw0rd', 'P4ssw0rd')
      const button = screen.getByRole('button', { name: '登録' })
      expect(button.disabled).toBe(false)
    })

    it('全フォーム入力済でも、パスワードが不一致の場合、登録ボタンがdiasbledになる', async () => {
      render(SignUpPage)
      await fillAllForm('Usern', 'user@example.com', 'P4ssw0rd', 'password')
      const button = screen.getByRole('button', { name: '登録' })
      expect(button.disabled).toBe(true)
    })
  })
})

async function fillAllForm(username, email, passowrd, passwordCheck) {
  const usernameInput = screen.getByLabelText('ユーザー名')
  const emailInput = screen.getByLabelText('メールアドレス')
  const passwordInput = screen.getByLabelText('パスワード')
  const passwordCheckInput = screen.getByLabelText('パスワード確認')
  await fireEvent.update(usernameInput, username)
  await fireEvent.update(emailInput, email)
  await fireEvent.update(passwordInput, passowrd)
  await fireEvent.update(passwordCheckInput, passwordCheck)
}

実装やテストに手を入れてもGreenのままであればリファクタリングがうまくいった証拠です。
テストコードがあることで実装や変更に対して大胆に挑戦することができるようになります。
続いては同じ関数を使えるAPI呼び出しのテストを書いていきます。
今回はHTTPクライアントであるAxiosを利用するので、まずはインストールします。

$ npm install axios

新たなライブラリをインストールした時は一度VitestやViteは立ち上げ直した方が無難です。
続いてはテストコードを書いていきます。

SignUpPage.spec.js
import SignUpPage from './SignUpPage.vue'
import { describe, it, expect, afterEach, vi } from 'vitest'
import { render, screen, cleanup, fireEvent } from '@testing-library/vue'
import axios from 'axios'
...

    it('登録ボタン押下時にユーザー名、メールアドレス、パスワードをサーバーに送信する', async () => {
      render(SignUpPage)
      await fillAllForm('Usern', 'user@exmaple.com', 'P4ssw0rd', 'P4ssw0rd')
      const button = screen.getByRole('button', { name: '登録' })

      const mockFn = vi.fn()
      axios.post = mockFn

      await fireEvent.click(button)

      const firstCall = mockFn.mock.calls[0]
      const body = firstCall[1]

      expect(body).toEqual({
        username: 'Usern',
        email: 'user@example.com',
        password: 'P4ssw0rd',
      })
    })
  })
})

importでは先程インストールしたaxiosvitestからviをインポートしてます。
コンポーネント内でAxiosが呼ばれたとしても実際にサーバーにPOSTさせるのではなく、
viでモックを作成しそのモックが正しく呼び出されたかを検証するイメージです。
vi.fn()のモック関数をaxios.postに再代入して上書きしてモック化しています。
その後にfireEvent.clickでボタンを押し、mockFn.mock.calls[0]でモックの呼び出し、
そして実際に呼び出された際のリクエストボディが定義どおりに呼び出されてるかアサーションでテストします。
テストがRedになったので、Greenにしていきます。

SignUpPage.vue
<template>
  ...
  <button :disabled="isDisabled" @click="submit">登録</button>
</template>

<script>
import axios from 'axios'

export default {
  ...
  methods: {
    submit() {
      axios.post('/api/v1/users', {
        username: this.username,
        email: this.email,
        password: this.password,
      })
    },
  },
}
</script>

これでGreenになるはず!と思ったらFAILのまま……
テスト結果を確認してみます。

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/pages/SignUpPage.spec.js > SignUpPage > インタラクション > 登録ボタン押下時にユーザー名、メールアドレス、パスワードをサーバーに送信する
AssertionError: expected { username: 'Usern', …(2) } to deeply equal { username: 'Usern', …(2) }
 ❯ src/pages/SignUpPage.spec.js:93:20
     91|       const body = firstCall[1]
     92| 
     93|       expect(body).toEqual({
       |                    ^
     94|         username: 'Usern',
     95|         email: 'user@example.com',

  - Expected  - 1
  + Received  + 1
  
    Object {     
  -   "email": "user@example.com",
  +   "email": "user@exmaple.com",
      "password": "P4ssw0rd",
      "username": "Usern",
    }

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files  1 failed (1)
     Tests  1 failed | 11 passed (12)
      Time  302ms


 FAIL  Tests failed. Watching for file changes...

アサーションが原因をわかりやすく表してくれてますね。
user@exmaple.comとメールアドレスにタイポがありました。
こういうのを気づかせてくれるのもTDDの良いところ。修正するとGreenになりました。

Axiosをモック化するこの方法でもテスト自体は可能ですが、
最近はMock Service Worker(MSW)というライブラリを使ったモッキングがオススメです。
MSWはその名の通りWebのService Workerであり、
ネットワークレベルでリクエストをインターセプトしてモックしたレスポンスを返すことができます。
これの何がメリットかというと、アプリケーションレベルではモックがない、
アプリケーションの実装に依存しないモックになるという点です。
例えばAxiosからFetchやjQueryやSuperAgentに乗り換える場合、
Axiosをモック化している場合はテストコードも修正が必要ですが、
MSWでレスポンスをモック化してしまえばそれも不要になるということです。
実際にやっていきましょう。まずインストールします。

$ npm install -D msw

そしてテストを実装していきます。

SignUpPage.spec.js
import SignUpPage from './SignUpPage.vue'
import { describe, it, expect, afterEach } from 'vitest'
import { render, screen, cleanup, fireEvent } from '@testing-library/vue'
import { setupServer } from 'msw/node'
import { rest } from 'msw'

describe('SignUpPage', () => {
  ...
  it('登録ボタン押下時にユーザー名、メールアドレス、パスワードをサーバーに送信する', async () => {
      let requestBody
      const server = setupServer(
        rest.post('/api/v1/users', async (req, res, ctx) => {
          requestBody = await req.json()
          return res(ctx.status(200))
        })
      )
      server.listen()

      render(SignUpPage)
      await fillAllForm('Usern', 'user@example.com', 'P4ssw0rd', 'P4ssw0rd')
      const button = screen.getByRole('button', { name: '登録' })
      await fireEvent.click(button)

      await server.close()

      expect(requestBody).toEqual({
        username: 'Usern',
        email: 'user@example.com',
        password: 'P4ssw0rd',
      })
    })
  })
})

import文からaxiosviを消してmsw周りからsetupServerrestを追加しています。
setupServerは文字通りサーバーの設定で、restはREST APIベースのモックを提供するものです。
実際のコードではrequestBodyを変数定義してリクエストボディの受け皿として用意しています。
setupServerでサーバー準備し、その中のリクエストハンドラでrestを利用します。
これで/api/v1/usersに対するPOSTリクエストをインターセプトしハンドラーの処理を実行します。
あとはモックサーバーの開始終了でlisten()close()を実施するだけです。
このリファクタリングを加えた場合でもテストがGreenのままであることを確認してください。
続いて、サーバーからエラーが返ってきたパターンも実装してみましょう。

SignUpPage.spec.js
  ...
    it('登録時にサーバーからエラーが返された場合、エラーメッセージを表示する', async () => {
      const server = setupServer(
        rest.post('/api/v1/users', async (req, res, ctx) => {
          return res(
            ctx.status(500),
            ctx.json({
              error: {
                message: 'サーバーエラーです。時間を置いて試してください。',
              },
            })
          )
        })
      )
      server.listen()

      render(SignUpPage)
      await fillAllForm('Error1', 'user@example.com', 'P4ssw0rd', 'P4ssw0rd')
      const button = screen.getByRole('button', { name: '登録' })
      await fireEvent.click(button)
      await server.close()

      const text = await screen.findByText(
        'サーバーエラーです。時間を置いて試してください。'
      )
      expect(text).toBeTruthy()
    })
  })
})

MSWのモックサーバーがステータスコード500でエラーメッセージを返すようにしています。
そのエラーメッセージがそのまま画面に表示されるのを確認するために、
queryByTextではなくfindByTextをawaitで使っています。
findBy~は後から画面が更新される場合に非同期で使えるので便利です。
RedになったらGreenにしていきましょう。

SignUpPage.vue
<template>
  <form>
    ...
    <button :disabled="isDisabled" @click.prevent="submit">登録</button>
    <p v-if="errorMessage">{{ errorMessage }}</p>
  </form>
</template>

<script>
...
  data() {
    return {
      ...
      errorMessage: '',
    }
  },
  ..
  methods: {
    async submit() {
      try {
        await axios.post('/api/v1/users', {
          username: this.username,
          email: this.email,
          password: this.password,
        })
      } catch (error) {
        this.errorMessage = error.response.data.error.message
      }
    },
  },
}
</script>

axiosはサーバーからのエラーをエラーとして投げるので、
try/catchで返ってきたエラーのレスポンスをdataerrorMessageに突っ込みます。
その値を元にv-if{{ }}でメッセージがある時のみ表示しています。
これでテストがGreenになるはずです。
テストコード側のリファクタリングを少ししましょう。
MSWのサーバーを作成するコードが少し冗長になっています。
切り出してインタラクションのdescribeのすぐ下に移動させてみます。

SignUpPage.spec.js
  describe('インタラクション', () => {
    // モックサーバー準備
    let requestBody
    const server = setupServer(
      rest.post('/api/v1/users', async (req, res, ctx) => {
        requestBody = await req.json()
        if (requestBody.username === 'Error1') {
          return res(
            ctx.status(500),
            ctx.json({
              error: {
                message: 'サーバーエラーです。時間を置いて試してください。',
              },
            })
          )
        }
        return res(ctx.status(200))
      })
    )

    async function responseServerCheck(username) {
      server.listen()
      render(SignUpPage)
      await fillAllForm(username, 'user@example.com', 'P4ssw0rd', 'P4ssw0rd')
      const button = screen.getByRole('button', { name: '登録' })
      await fireEvent.click(button)
      await server.close()
    }
    ...

    it('登録ボタン押下時にユーザー名、メールアドレス、パスワードをサーバーに送信する', async () => {
      await responseServerCheck('Usern')
      expect(requestBody).toEqual({
        username: 'Usern',
        email: 'user@example.com',
        password: 'P4ssw0rd',
      })
    })

    it('登録時にサーバーからエラーが返された場合、エラーメッセージを表示する', async () => {
      await responseServerCheck('Error1')
      const text = await screen.findByText(
        'サーバーエラーです。時間を置いて試してください。'
      )
      expect(text).toBeTruthy()
    })
  })
})

リクエストハンドラーの中でrequestBodyusernameにより分岐を設けています。
そのためusernameを元にレスポンスを取得するserverResponseCheck関数を切りだしました。
この方法でサーバーから返すステータスコードやレスポンスデータのパターンを設けることが可能です。
この記事では最後に少しリファクタリングして終わりにしたいと思います。

SignUpPage.spec.js
import SignUpPage from './SignUpPage.vue'
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
import { render, screen, cleanup, fireEvent } from '@testing-library/vue'
import { setupServer } from 'msw/node'
import { rest } from 'msw'

describe('SignUpPage', () => {
  beforeEach(() => {
    render(SignUpPage)
  })
  afterEach(cleanup)

  describe('レイアウト', () => {
    it('Sign Upヘッダーが表示される', () => {
      const header = screen.getByRole('heading', { name: 'Sign Up' })
      expect(header).toBeTruthy()
    })

    it('ユーザー名の入力フォームが表示される', () => {
      const input = screen.getByLabelText('ユーザー名')
      expect(input).toBeTruthy()
    })

    it('メールアドレスの入力フォームが表示される', () => {
      const input = screen.getByLabelText('メールアドレス')
      expect(input).toBeTruthy()
    })
    ...
})

どのテストでも必ず行っているrender(SignUpPage)を、
vitestのbeforeEachを使って毎テストの最初に行うよう設定しました。
他に実装していたrender(SignUpPage)はすべて削除しています。
これでもGreenのままであることを確認できたらOKです。
だいぶテストの内容に集中したコードになってるかと思います。

伝えたいこと

TDDを始めると言う時、最初はテストの書き方そのものに慣れていないので、
とてもまどろっこしかったり、コストがかかる!なんて思われがちかと思います。
ですが、コードがシンプルなうちにテストコードを書くほうが圧倒的に簡単です。
モブプロでもしていない限り誰が書いたかわからないコードを読み解きながら、
テストコードを書くのは結構しんどい作業です。
コンポーネントの構造が複雑化していたらなおさらです。
基本時間が経つに連れ難易度は右肩上がりになります。
それよりもコードベースが少ないうちからテストを書くのに慣れていって、
徐々にテスト、コード、スキルを積み重ねていくのが圧倒的にオススメです。
ぜひ、次なる挑戦でもTDDに取り組んでみてください。

TDDで守ってほしい方針

  • できるだけ早い段階でTDDを導入する
    • 「伝えたいこと」にも書きましたが大事なことなのでここでも
  • 1テスト(it)、1アサーション(expect)にする
    • エラーがどこで起きたかが一目瞭然になる
    • テストの内容も具体的にできる
  • リファクタリングを積極的に行う
    • TDDはGreenに一度できたら品質を保ったままリファクタに挑戦する土台ができているということ
    • アプリもテストもどちらのコードもリファクタリング
    • ボーイスカウトルールみたいにやる

その他

Vitestはテストが爆速なのであまり困ることは無いとは思いますが、
重たい特定のテストだけをスキップして効率化をしたかったり、
1つの画面開発中には1つの画面に紐づくテストだけを実施したい時があるかもしれません。
そんな時は以下を参考にしてください。itだけじゃなくてdescribeにも使えます。

特定のテストだけ実行したい場合

it.only('テストケース名', () => {})

特定のテストをスキップしたい場合

it.skip('テストケース名', () => {})

複数のテストを同時並行に実行したい場合

it.concurrent('テストケース名', () => {})

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
17