📝

コツコツ始めるフロントエンドのテスト拡充活動(ユニットテスト編)

2022/12/05に公開

どうもoreoです。

私は株式会社iCAREでフロントエンド開発を主に担当しており、2022年6月ごろから有志メンバーでフロントエンドのテスト拡充活動を始めました。機能開発を優先しながら、コツコツと活動を続け、4ヶ月間で共通モジュール内の純粋関数に対してユニットテストを追加することができました。この記事では、そこに至るまでの活動について記載します。

機能開発に並行してフロントエンドのテストを追加したい人の参考になれば幸いです!

1 要約

  • 毎週0~2h/人程の工数をかけてフロントエンドのテスト拡充活動を始めた。
  • テストを書く目的をすり合わせて簡単なロードマップを作成し、まずは共通モジュールの純粋関数に対してユニットテストを書くことにした。
  • 全ての純粋関数に対してユニットテストを追加し、カバレッジを100%にすることができた。
  • また、純粋関数に関して、CircleCIでカバレッジの自動チェックをできるようにした。

2 活動を始めた背景

弊社のフロントエンド開発では、テストについての体系的な方針がなく、またテストを書けるメンバーが限定的だったため、テストがあまり書かれていない状態でした。

一方で、サービス拡大に伴ってプロダクトは肥大化しており、今後のプロダクトの安全性を担保するために、テストを拡充しながら、その方針を作成する活動を有志で始めました。

3 進め方

メイン業務である機能開発を優先しながら、以下のような形で進めました。

  • メンバーはフロントエンジニア3~5人 + 弊社フロントエンド技術顧問1名。メンバーで議論や実装を行い、都度、技術顧問の方からアドバイスを頂きながら進めました。
  • 各メンバーの工数は1週間で0~2h程度。内訳としてはメンバー間議論0.5h +各自の調査/実装時間1.5h。
  • ナレッジを貯める為に、議論内容はGithub Discussionに都度記載。

4 やったこと

4ヶ月間の活動では以下5つを行いました。

  1. テストを書く目的のすり合わせ
  2. テストのカバレッジを計測できるように
  3. 簡単なロードマップを作成
  4. 純粋関数にユニットテストを追加
  5. 純粋関数に関してはCircleCIでカバレッジを自動チェックするように

4-1 テストを書く目的のすり合わせ

テストを書くことを目的としても意味がないので、まずはフロントエンドでテストを書く目的をメンバー間で擦り合わせました。各メンバーがテストが必要な理由を考えた上で議論し、「修正を加えた際に、見た目と機能(動き)が壊れないことを保証する」為に、テストを書くこととしました。

4-2テストのカバレッジを計測できるように

続いて、カバレッジの計測方法を調べました。弊社はJest、Vue Test Utilsを使ってテストを記載しています。Jestでは--coverageオプションを使うことでカバレッジを確認できる為、それを使用しました。--coverageオプションをつけてテストを実行すると以下のようなサマリーが表示されます。

https://jestjs.io/docs/cli#--coverageboolean

フォルダを指定してカバレッジを確認する場合は、--collectCoverageFromオプションを利用しました。

https://jestjs.io/docs/cli#--collectcoveragefromglob

また、--coverageオプションをつけると、coverageというディレクトリが生成され、その中のindex.htmlでカバレッジ詳細を確認できます。

このファイルでは、具体的にどのファイルのどのlineに対してテストが書かれていないかなどを確認できます。

カバレッジ指標の見方に関しては以下記事を参考にしました。

https://qiita.com/s_karuta/items/c464f220a4b65f70f214

4-3 簡単なロードマップを作成

カバレッジの確認方法がわかった後、具体的にどこからテストを書いていくかを議論し、大雑把にロードマップを考えました。フロントメンバーのテストの経験があまり無いこともあり、まずはスキル向上を目的としながら、共通モジュールの純数関数からテストを書き始めました。

第一フェーズ 「共通モジュールの純粋関数に対してユニットテストを書く」

  • 状態や副作用がある共通モジュールはユニットテストが書きづらいので、まずはスコープから外し、メンバーがJestの書き方に慣れることも目標として純粋関数に対してテストを追加していく。
  • 単純な純粋関数が対象なので、カバレッジは100%を担保する。

第二フェーズ 「共通モジュールで状態や副作用を持つ関数に対してユニットテストを書く」

  • 次は状態や副作用をもつ共通モジュールに対してテストを書き、Jest、Vue Test Utilsの使い方をより習熟させる。
  • 目標とするカバレッジの具体的な数字はテストを書きながら都度議論し決めていく。

第三フェーズ 「Storybookを活用してテストを書く」

  • 弊社はStorybookを使用してUIコンポーネントを管理している為、Storybookを利用したUIコンポーネントに対するテスト方法を模索する。

4-4 純粋関数に対してユニットテストを書く

簡単なロードマップを作成した後は、第一フェーズである共通モジュールの純粋関数に対してユニットテストを追加して行きました。ここではユニットテストを追加した際のTipsを記載します。

テストの説明には事前状況、実行内容、結果を明記する

Jestではitメソッドでテストを記載します(testメソッドでも同様に書けます)。itメソッドの第一引数に渡すテストの説明では、他メンバーがテスト内容を正確に把握できるように、事前状況、実行内容、結果を明示するようにしました。

it('テストの説明', () => {
  // テストコード
})

describeを使ってテストをまとめる

Jestではdescribeメソッドを使用すると下記のようにテストケースをまとめることができます。テストする対象や前提条件の違いをこのdescribeメソッドを使ってグルーピングしました。

一番外側のdescribeの第一引数にはテスト対象を記載しました。

describe('targetModule()', () => {
  it('テスト1', () => {
    // テストコード
  })
  it('テスト2', () => {
    // テストコード
  })
})

また、describeはネストすることができるので、条件ごとにdescribeをネストしました。

describe('targetModule()', () => {
  describe('条件1の場合', () => {
    it('テスト1', () => {
      // テストコード
    })
    it('テスト2', () => {
      // テストコード
    })
  })
  describe('条件2の場合', () => {
    it('テスト1', () => {
      // テストコード
    })
    it('テスト2', () => {
      // テストコード
    })
    it('テスト3', () => {
      // テストコード
    })
  })
})

適切なマッチャを使う

Jestでは、マッチャと呼ばれるメソッドでテスト結果を評価します。Jestでは、さまざまなマッチャが用意されており、その中からテストケースに応じて適切なマッチャを使うように心がけました。

https://jestjs.io/docs/expect

例えば、以下のようにマッチャを使い分けました。

  • .toBe()
    • プリミティブ値を評価します。
  • .toEqual()
    • オブジェクトインスタンスのすべてのプロパティを評価します。
    • .toBe()でのオブジェクト判定では参照先が評価される一方で、.toEqual()ではオブジェクトの全てのプロパティを===で評価します。
  • .toStrictEqual()
    • オブジェクトの構造だけでなく型まで評価します。
    • .toEqual()ではundefinedのキーに対して比較されませんが、.toStrictEqual()ではundefinedのキーまで評価されます。
  • toBeTruthy()
    • JavaScriptにおけるtruthyな値がどうか評価します。
    • JavaScripでfalsyとなるのは以下6つの値であり、truthyな値とはこれらfalsyな値以外です。
      • false、 0、 ''、 null、 undefined、 NaN
  • .toBeFalsy()
    • 上記したfalsyな値かどうか評価します。

ローカルストレージをモックする

ローカルストレージを使用する純数関数に関しては、テストファイルでローカルストレージをモックすることで対応しました。

const localStorageMock = (() => {
  let store = {}
  return {
    getItem(key) {
      return store[key]
    },

    setItem(key, value) {
      store[key] = value
    },

    clear() {
      store = {}
    },

    getAll() {
      return store
    },
  }
})()
Object.defineProperty(window, 'localStorage', { value: localStorageMock })

以下のようにローカルストレージへの操作が可能になります。

//ローカルストレージへの値の保存
window.localStorage.setItem(hogekey, JSON.stringify(hoge))
//ローカルストレージから特定の値を取得
window.localStorage.getItem(hogekey)
//ローカルストレージの値を削除
window.localStorage.clear()
//ローカルストレージからすべての値を取得
window.localStorage.getAll()

セットアップ関数を利用する

Jestでは、テストの開始前後で実行したい処理を設定するためにセットアップ関数が用意されています。

https://jestjs.io/docs/setup-teardown

beforeEachメソッド、afterEachメソッドを利用するとitメソッドの前後で毎回実行したい処理を追加できます。例えば、前述のローカルストレージをモックした後に、itメソッドの処理前後でローカルストレージへの操作を加えたい場合は、以下のように記載できます。

// itメソッドの処理前にローカルストレージへの値の保存
beforeEach(() => {
	window.localStorage.setItem(hogekey, JSON.stringify(hoge))
});

// itメソッドの処理後にローカルストレージへの値を削除
afterEach(() => {
  window.localStorage.clear()
});

テストファイルの実行前後で一度だけ、セットアップ処理を実行したい場合は、beforeAllメソッド、afterEachメソッドを使用しました。

4-5 純粋関数に関してはCircleCIでカバレッジ自動チェックをするように

続いて、純数関数用のpureフォルダを共通モジュールフォルダ配下に作成し、そこに純粋関数をまとめました。この段階でpureフォルダ内のカバレッジは100%にできました。

この状態を維持するために、pureフォルダに純粋関数を追加する際には、CircleCI上でカバレッジの自動チェックを実行するようにしました。

カバレッジのチェックはこちらの記事を参考にさせていただきました。

https://zenn.dev/nus3/articles/ad7e2582765ea8

Jestでは、テスト実行時に、--coverageReporters=json-summaryオプションをつけることで、カバレッジ結果をjson形式で出力することができます。

https://jestjs.io/docs/configuration/#coveragereporters-arraystring--string-options

出力したjsonファイルからデータを読み込み、pureフォルダのカバレッジチェックを行うスクリプトを書きました。pureフォルダのカバレッジは100%を閾値としましたが、今後フォルダ毎にカバレッジを指定できるような形でスクリプトを作成しました

import { readFileSync } from 'fs'

const main = () => {
  try {
    const dirNames = new URL(import.meta.url).pathname.split('/')
    const filterNames = dirNames.filter((dirName) => dirName !== 'script' && dirName !== 'check-coverage' && dirName !== 'index.mjs')
    const dir = filterNames.join('/')

    const input = JSON.parse(readFileSync(`${dir}/coverage/coverage-summary.json`, 'utf-8'))

    const coverage = (input.total.lines.pct + input.total.statements.pct + input.total.functions.pct + input.total.branches.pct) / 4

    let mustCoverage = 0
    // テスト実行コマンドにpathを引数と渡し、そのpath毎に閾値を設定する。
    if (process.argv[2] === 'modules/pure') {
      mustCoverage = 100
    }

    if (coverage < mustCoverage) {
      const errMsg =
        'frontend/modules/pure/に純粋関数を追加した場合は、ユニットテストも追加してください'
      throw new Error(errMsg)
    }
  } catch (e) {
    console.error(e)
    process.exit(1)
  }
}

main()

後は、CircleCIで上記スクリプトを実行できるようにconfig.ymlで設定を行いました。

5 活動は続く

これまでの活動で第一フェーズは完了し、現在は第二フェーズをコツコツと進めています。引き続きこの活動を続けながら、テストを書く目的を忘れずに意味のある形でフロントエンドのテスト方針を作っていけたらなと思います。また、それに加えてメンバーのテストを書くスキルも磨いて行きたいと思います。

6 謝辞

これまで一緒に活動を続けてくれた@watsuyo_2さん、@st_0qさん、@utamaruSan1415さん、@toyo_seaさん、また、多くのアドバイスをくださった弊社技術顧問@ozu_syoさんにこの場を借りて感謝申し上げます。これからも引き続きよろしくお願いいたします。

7 参考

https://jestjs.io/

https://zenn.dev/tentel/books/08b63492b00f0a

https://zenn.dev/nus3/articles/ad7e2582765ea8

https://zenn.dev/koki_tech/articles/a96e58695540a7

https://kentcdodds.com/blog/static-vs-unit-vs-integration-vs-e2e-tests

Discussion