next-simple-template
next-simple-template copied to clipboard
テストについて
@fumi-sagawa issueを拝借して、どのようにテストを進めていくのがいいかまとめました。佐川さんが書いた内容とかぶる部分もあるかもしれないですが、参考にしていただければと思います。 工数がとれない、人員もテスト経験も限られる状況では全部のテストを実行していくのは難しいと思いますので、どのテストを優先するか考えました。
ユニットテスト
(依存関係)----.
|
引数------------->f(x)---->返値(検証対象)
- 環境構築も実行も容易
- テストの感覚をつかむのに最適 フロントエンドにおいてはユニットテストの比重は高くないですが、上記の理由からユニットテストをスタート地点にするのがいいと思います。 また、テストを書く過程で関心の分離について考えていただくとコード品質の向上にも寄与するものと思います(詳細を後段に書きました)。 依存関係によってはモック関数が必要になります。代表的なものは端末の時計に依存したテストで、Dateをモックする必要が出てきます。最初はテストに集中するため依存関係がない純粋な処理に対してテストを書くのがいいです。
test('now', () => {
const now = new Date() // 現在時刻はテスト実行ごとに異なるので、`now`変数によるテストは冪等でない
expect(now).toBe(/* ... */)
})
https://github.com/boblauer/MockDate
E2Eテスト
データソース--------.
|
(ヘッドレス)ブラウザ----------->画面操作(検証対象)
- 実行が容易
- 環境構築はまあまあ大変 ユニットテストがある程度書けるようになったら、現実の問題に対処するためE2Eテストを行うのがいいと思います。こちらも実行が容易という点で先に挙げました。代表的なE2EテストスイートにはGUIでテストコードを生成するツールが付属していますので簡単にテストコードが書けます。 E2Eテストでは環境(データソース)をどうするか考える必要がありますが、テスト(専用)環境を用意するのが難しい場合はある程度妥協する必要があるかもしれません。
- MSWのモックAPIを用意する
- 検証対象がサーバーレスポンスに依存しているテストは書けない
- APIがGraphQLなど複雑な場合、モックを構築するコストが高い
- 開発環境で変化が少ないAPIを選んでテストに利用する
- テストが壊れる危険性はある 最初からMSWでモックAPIを用意して開発することができれば、のちのちサーバーサイドに依存せずに容易にE2Eテストが導入可能と思います。 またDOMを指定する関係上、壊れやすいテストでもあります。Playwrightにはそのものずばりのベストプラクティスがあるので参考にするといいです。 https://playwright.dev/docs/selectors#best-practices
flakyなテスト
flakyなテストとは、成功したり失敗したり結果が一定でないテストのことです。E2Eテストではタイミングによっては必要なDOMが生成するより先に操作が進んでしまうことがあります。たいていの場合は修正可能なのでしっかり潰しておくことが重要です。
その他のテスト
フロントエンドでは統合テストを厚くするのがいいとされますが、現実的には一番実行難易度が高いテストなので、テスト経験が少ない状況では無理に行う必要はないと考えます。 また、例えば情報の取得・表示がメインのプロジェクトで本当に必要なのか等、案件の性質も考慮する必要があると思います。 難易度の側面から統合テストは私もあまり書いたことがなく、これと言えるような知見はもっていないというのも正直なところです。
フック
(依存関係)------.
|
(データソース)----|
|
(上位のフック)----|
|
(プロパティ)-------->f(x)---->返値(検証対象)
^
|
`-------状態変更
単純なフックであればユニットテストと同等にテストは容易です。依存関係が少ないフックを選んでテストすることは可能と思います。 一方、windowオブジェクト(テストはNode.jsで実行されますので存在しません)・非同期通信・上位のフックなどが関わるフックは、モック関数の検討が必要なので実行難易度が上がります。書けるに越したことはないのですが、最初に挑戦するのは避けたほうがいいと思います。
# テストが簡単なフック
f(x)---->返値(検証対象)
^
|
`-------状態変更
コンポーネント
コンポーネントについてもフックと同様、簡単に実行できるものと実行難易度が高いものがあります。テストに慣れてからのほうがいいと思います。
デプロイプロセスとの連携
テストをデプロイプロセスに組み込んで、テストに失敗するコードをリリースしないようにすることもよく行われます。しかしながら、テストに未習熟な状態でこれを行ってしまうと、緊急時に無理やりレッドなテストをコメントアウトしたり、果てはテストが邪魔者とみなされ放置されるといった最悪のケースになってしまうこともあります。テストのメリットを理解するまではそれぞれのローカル環境での実行でも十分と思います。
付録:関心の分離とテスト
言葉だけでは何のことかわからないという方もいるかも知れないので、少し実例を挙げておきます。
雑多な関数群
a
、b
、c
はDateに相当するようなモックが必要なライブラリと仮定します。calcA
〜calcC
は互いに依存しない処理とします。
// common.js
import A from 'a'
import B from 'b'
import C from 'c'
export const calcA() { /* ... Aを使用する処理 */ }
export const calcB() { /* ... Bを使用する処理 */ }
export const calcC() { /* ... Cを使用する処理 */ }
これをテストするにはすべてのライブラリをモックする必要があります。
// common.test.js
import { calcA, calcB, calcC } from 'common'
jest.mock('a')
jest.mock('b') // ?
jest.mock('c') // ??
test('calcA', () => { /* ... calcAのテスト */ })
test('calcB', () => { /* ... calcBのテスト */ })
test('calcC', () => { /* ... calcCのテスト */ })
極端な例ですが、互いに必要ないモックを定義しなければならずテストを書くとき負荷になります。common.js
といった共通処理を集めたファイルがこのような状態になることがあります。
// aUtils.test.js
import { calcA } from 'common'
jest.mock('a')
test('calcA', () => { /* ... calcAのテスト */ })
このように、可能な限り関心事を少なくするように考えます。
なんでもできる関数
あるいは、実際に散見されますが以下のような例。
// iKnowEverything.js
export function iKnowEverything(type, wantToGet) {
if (type === 1) {
if (wantToGet === 'category') {
return 'cat1'
}
if (wantToGet === 'label') {
return 'label1'
}
if (wantToGet === 'text') {
return 'text1'
}
} else if (/* ... */) {
// ...
}
}
これでもテストはできるかもしれませんが、複雑なテストになります。何でもできる関数というのは不用意に関心が集まっている可能性があり注意が必要です。
// iKnowEverything.test.js
test.each(`
a | b | expected
1 | category | cat1
1 | label | label1
1 | text | text1
...
`)('i know everything', (expected, a, b) => {})
このような複雑性が本当に必要なのか、問題を小さくできないか、単純な関数で代替できないか考えます。
const dictionary = {
'1': {
category: 'cat1',
label: 'label1',
text: 'text1',
},
// ...
}
export function getCategoryByType(type) {
return dictionary[type]['category']
}
export function getLabelByType(type) {}
export function getTextByType(type) {}
テストも単純になります。
test('get category by type', () => {
expect(getCategoryByType(1).toBe('cat1');
})
test('get label by type', () => {})
test('get text by type', () => {})
これはあくまで例なので、これが良いかどうかは状況によります(本当に条件分岐が複雑なロジックもありえます)。
テストを書くこと自体が難しい
なんでもできる関数と似ていますが、いろんな処理をいっぺんに1つの関数で行うと内部の処理がテストしにくい状況になることがあります。
const getXxx = () => {
return new Promise((resolve) => {
const xxxIDs: string[] = [];
state.aaa.bbb.forEach((xxx: any) => {
xxx.item.forEach((x: any) => {
// ... リファクタリングのためテストしたいが外部から参照できない
});
});
if (xxxIDs.length > 0) {
axios.post()
.then(({ data }) => {
// ...
})
.catch(() => {
// ...
});
} else {
// ...
}
});
};
適切に関数を分割することでテストが書きやすくなります。
// この単位であればテストできる(実際書くかどうかは別にして)
function getXxxIdsFrom(xxxs: Xxx[]): string[] {
return xxxs
.map((xxx) => {
return xxx.item.map((x) =>/* ... */);
})
.flat() // or _.flatten()
.uniq(); // or _.uniq()
}
// データ取得の詳細は知らなくてもよさそう
function promiseXxxs(xxxIds: string[]): Promise<Response> {
return axios.post();
}
// ここまでする必要はないかもしれないが、vuexにならってmutationを定義してみる
function mutateXxxs(state, data: Xxx[]): void {}
// ユースケースあるいは vuex における action
async function loadXxxs({ state }): void {
const xxxIds = getXxxIdsFrom();
const response = await promiseXxxs(xxxIds).catch(() => {});
mutateXxxs(state, response.data);
}
このように、テストしやすいコードを考えることと関心の分離について考えることは表裏の関係にあり、テストを書くことはコード品質の向上に寄与すると考えます。 関数についてばかり書いていますが、コンポーネントやフックのレベルになると依存関係が多くなりがちで、何を考えるにも複雑性が伴います。良いコードを考えるにあたっては関数という単位はとても扱いやすいのです。
付録:なぜテストを書くのか
プログラムというのは書いたとおりにしか動きませんので、予測可能なはずなのですが、複雑になっていくにつれ認識の漏れやブラックボックスが増え、予測不可能な動きをするようになります。
function sum(a, b) {
return a + b // 期待通り動く。入力に対して出力が一意
}
アプリケーションをある種の関数と捉えると以下のようなイメージになります。
function bigApp(a, b, c, d, e, /* ... たくさんの依存関係 */) {
sum(a, b) // ここは予測可能
// ... いろいろな処理
// ... 認識していない条件
// ... 中身はよくわからないけど便利なライブラリを使った処理
return unexpectable // 期待していない動作
}
フロントエンドのアプリケーションは様々な依存ライブラリから成り立っており、言ってみればブラックボックスの塊です。テストによって不透明な部分を減らし、期待通り動く部分を増やすことができれば、アプリケーションは全体的に(sum
関数のような)予測可能な状態に近づきます。アプリの予測可能性を保ち、予測可能な範囲を増やすことはテストの大きな意味での役割と思います。
デグレーションの回避
テストが書いてあり、常に実行されていればデグレーションを恐れることなく安心して変更ができるという側面もあります。最初に期待されるテストの効果はこちらかもしれません。 テストを書いていない処理を変更する場合は、対象のテストを先に書いてグリーンにしておき、コードの修正前後でグリーンであることを確認してデグレが起きていないことを保証することができます(エッジケースに気をつける必要はありますが)。