テスト戦略

テスト戦略

これ以外にも戦略はありますが、このチームに合いそうな戦略としてまとめました。

なぜ書くか?

  • デバッグを減らす
    • 一度書かれたテストは、プロジェクトの存続期間中、配当を支払い、調査や修正コストのかかるバグを防ぎます
  • 自信を増やす
    • 安心してリリースできるようになります
  • ドキュメントの改善
    • テストを読めば挙動がわかります。テストが失敗していると言うことは挙動が変わっていることを表します。そしてドキュメントの更新が必要なことがわかります。
  • レビューをシンプルにする
    • 検証済みのコードはロジックのレビュー時間を減らすことができます。
  • [大事] 設計を考えるようになる
    • 新しいコードのテストを書くことは、コード自体のAPI設計をする実用的な手段になります。新しいコードのテストが難しい場合は、多くの場合テストされているコードに責任や管理が難しい依存関係が多すぎるためです。適切に設計されたコードはモジュール化され、密結合を避け、特定の責任に焦点を当てる必要があります。設計上の問題を早期に修正することは、多くの場合、後でやり直しが少ないことを意味します。
  • 早くて、高品質なリリースができるようになる
  • [大事] 気力を減らさない
    • 1日の気力量は有限。 テストで発見するバグが使用する気力量は少ないです。同じバグでも本番で発見されるとその修正対応に使う気力量はそれに比べて体感的に多くなります。

E2Eテスト、結合テスト、ユニットテストの割合

以下の二つの図は理想的な割合を表しています。

Screenshot 2023-12-19 at 10.01.47.png
Untitled

E2Eテストは少なめ

  • 結合テストとユニットテストが一番多い

どちらにも言えるのはE2Eテストの割合が少ないということです。

以下は アンチパターンです。

Screenshot 2023-12-19 at 10.01.58.png
  • 手動テストが多い
  • E2Eテスト(左の図だとAutomated GUI Tests)が多い
  • 結合テストが少ない

左の説明

このパターンになるのは、

  1. テストを書かずにPoCプロジェクトを進めます。
  2. その後にプロダクト規模が大きくなってきたのでテストを入れます
  3. 結合テストとユニットテストをスキップしてE2Eテストを書こうとします。 なぜなら、E2Eテストが一番ユーザーのリアルな操作に近く、本番環境に近い環境なので信頼度が高いためです。 しかし、

メリットは

  • 本番と同等の環境で、ユーザーのリアルな操作をシミュレートするので信頼度が高いです

デメリットは

  • プログラム、外部サービス、ネットワーク、ブラウザなど全ての依存関係を全て結合した状態でテストするのでテスト壊れやすいです。壊れないまでも、テストが通ったり、失敗したりします。これを「壊れやすいテスト」とか「Flaky test」と呼びます。壊れやすい問題は、複雑な依存の中で壊れるので、修正が難しい傾向にあります。つまり、メンテナンスコストが高いです。そして、メンテナンスコストは開発者が支払うことになります。
  • テストに失敗した時、何が原因で失敗したか特定が難しいです。プログラム、外部サービス、ネットワーク、ブラウザなど全ての依存関係を調べる必要があります
  • テスト環境の構築が大掛かりになりやすいです。本番と同等の環境を用意、テスト毎にDBの初期化などが必要なります
  • テストが遅いです。以前関わっていたプロジェクトだと、テストケースによっては100テストケースぐらいで1時間かかることもありました。
    • フィードバックをすぐに得られない。すぐに気づいてすぐに治すができない
以前関わっていたプロジェクトではテストが壊れやすく、結局、原因となる箇所が特定できないため「壊れやすいテスト」は直せませんでした。

ユニットテストや結合テストは、E2Eテストに比べて外部環境に依存することが少ないので、壊れにくいです。そしてブラウザなどを立ち上げなくていいのでとても早いです。テストが早い事は早くフィードバックをもらうために必要です。持続可能なテストになります。

誰のために書くのか?

  • サービスを使うユーザー。お客さん。
    • 不具合によるストレスが少なくなります。
  • サービスを作るメンバー
    • 自信を持ってリリースすることができます。
    • バグが減ることで気力が減りにくくなります。

何のテストを書くか

プロジェクトの粒度で見た時

プロジェクトで扱う全てのアプリケーションに対して書きます。

これには社内で使う管理画面も含まれます。誰が使うかではなく、アプリケーションの生存期間でテストが必要かを判断します。 仮に管理画面にテストを書かなかった場合、バグがあった場合、困るのは社内の人たちです。そして、それを修正する開発者がバグの対応に気力を奪われることになります。

コードで見た時

プロダクションコードの全てです。全てとは、例えばUI、ビジネスロジック、APIコール、DBコール、Cloud Functionのロジックなどです。

ただし、プロジェクトの途中からテストを入れる場合は、投資効果が低いテストは優先度を下げます。逆に 投資効果が高いテストは優先度を上げます。

投資効果が高いテストとは 以下のようなものがあります

  • ロジックが複雑でバグが発生しやすい
  • 不具合が起きたとき、多くのお客さんに影響が出るロジック

コストの関係でテスト対象を限定するのではなく、 プロダクションコードのすべてのテストは書くけど、テストに優先順位をつけるということが正しいです。ビジネスフェースによっても、お客さんへの影響範囲やビジネスロジックの複雑さは変化します、その時にテストの投資効果も変わるはずです。

例外としては、使い捨てコードなど生存期間の短い検証用のコードにはテストコードは必要ない場合が多いです。

Vueを使ったフロントエンド

各層に対して書きます。

以下のような依存関係になっているはずです。

Vue component → (optional) compostable → (interface) third party libraries or モック化されたthird party libraries

  • Vue componentはUIとロジックを組み合わせたコンポーネントテスト(結合テスト)を書きます。
  • composableはユニットテストを書きます
  • third party librariesはユニットテストを書きます
  • モック化されたthird party librariesはユニットテストを書きます

バックエンド

以下のような依存関係になっているはずです。

HTTP server(HTTPリクエストを受け取る) -> usecase (ビジネスロジック) -> (optional) service (共通ビジネスロジック) -> repository (DBや外部HTTPリソースなど) or モック化されたrepository`

  • HTTP serverは結合テストを書きます
  • usecaseはユニットテストを書きます
  • serviceはユニットテストを書きます
  • repositoryはユニットテストを書きます
  • mock化されたrepositoryはユニットテストを書きます

Cloud Function

  • handler

いつ書くの?

プロジェクトの粒度で見た時

プロジェクト初期から全てのアプリケーションに対して書きます。 初期から投資した方が配当が多くもらえます。

もちろん途中から増えるCloud Functionsなどのアプリケーションについても書きます。

コード変更の粒度で見た時

コードが変更されるということはアプリケーションの仕様が変更されかリファクタリングされるかのどちらかです。

  • ファイルを作成した時
    • 仕様通りに機能していることを検証します。
  • リファクタリング前
    • 元の仕様に変更がないこと。お客さんやそのロジックの依存元に影響がないことを検証します。
  • 修正があった時
    • 仕様通りに機能していることを検証します。

どうやって書くの?

describe, it パターン

describeには一つの条件を書きます。itにはその時の一つの期待していることを書きます。

このフォーマットにするには以下のような利点があります。

  • テストケースは仕様になります。その仕様を読んだ時、流れるように読みやすくするため
  • テスト結果を見た時にわかりやすくなる効果もあります。
  • 網羅性を上げるためです

例えば以下は条件と期待テストケースがあったとします。

describe(Aの条件かつBの条件かつCの条件のとき, () => {
    it(XかつYかつZになる)
})

ここにテストケースを追加していきます。

describe(Aの条件かつBの条件かつCの条件のとき, () => {
    it(XかつYかつZになる)
})

# 数週間後、テストケース追加
describe(Aの条件かつBの条件かつDの条件のとき, () => {
    it(XかつYになる)
})

# さらに数週間後、テストケース追加
describe(Aの条件かつCの条件かつDの条件のとき, () => {
    it(Zになる)
})

describeとitの条件と期待の組み合わせが分かりづらくなってきませんか?文章が簡単なのでわかりやすいかもしれませんが、複雑な文章になるとさらに分かりづらくなります。 これだと網羅できているか分かりません、そして仕様が読みにくいです。

Aの条件かつBの条件かつCの条件のとき -> XかつYかつZになる: Passed
Aの条件かつBの条件かつDの条件のとき -> XかつYになる: Failed
Aの条件かつCの条件かつDの条件のとき -> Zになる: Failed

良い例

describeには一つの条件を書きます。itにはその時の一つの期待していることを書きます。

describe(Aの条件のとき, () => {
    describe(Bの条件のとき, () => {
        describe(Cの条件のとき, () => {
            it(Xなる)
            it(Yなる)
            it(Zなる)
        })

        describe(Dの条件のとき, () => {
            it(Xなる)
            it(Yなる)
        })
    })

    describe(Cの条件のとき, () => {
        describe(Dの条件のとき, () => {
            it(Zなる)
        })
    })
})

テスト結果は

Aの条件のとき -> Bの条件のとき -> Cの条件のとき -> Xになる: Passed
Aの条件のとき -> Bの条件のとき -> Cの条件のとき -> Yになる: Passed
Aの条件のとき -> Bの条件のとき -> Cの条件のとき -> Zになる: Passed
Aの条件のとき -> Bの条件のとき -> Dの条件のとき -> Xになる: Failed
Aの条件のとき -> Bの条件のとき -> Dの条件のとき -> Yになる: Failed
Aの条件のとき -> Cの条件のとき -> Dの条件のとき -> Zになる: Failed

こちらの方が組み合わせが分かりやすく、読みやすくないでしょうか?

持続可能性

入力と出力にフォーカスする

テストケースを書くとき、ロジックやUIの入力と出力のみテストします。

関数を検証するユニットテストの場合

入力

  • 引数

出力

  • 返り値

Cypress Vue Componeのコンポーネントテストの場合

入力

  • ユーザーのUI操作(ボタンクリックなど)
  • props

出力

  • UIの変更
  • emit

ロジック内部の状態をチェックをしないのは、 コードのリファクタリングなどで変数名や値が変わってしまいテストが壊れやすくなるためです。

例外としては、外部ライブラリを使っていて、その部分の呼び出し関数をVitestやCypressでスパイしている場合です。

冗長はOK

テストコードを書くときに プロダクションコードのようなDRY を意識して書かないようにします。 その理由としては、抽象化しすぎるとテストの意図が分かりづらくなるためです。 そのため、テストの入力と出力は、明治的にテストケースの中に入れるようにします。

AAA (Arrange, Act, Assert)パターン

it('test', => {
  # Arrange
    # テストの準備を書きます。クラスのインスタンス生成や、初期データのセット。
  const instance = MyClass({ count: 1 })

  # Act
  const result = instance.add(1)

  # Assert
  expect(instance)
})

SOLID

イラストで理解するSOLID原則 #初心者 - Qiita

本記事は、掲載元で31K「いいね」を獲得したUgonna Thelma氏による「The S.O.L.I.D Principles in Pictures」(2020年5月18日公開)の和訳を、著者の許可を得て掲載してい…

外部依存をモック化する

DBやネットワークはモック化します。信頼性と利便性のトレードオフになりますが、テストを書くことの持続可能性が高いことが重要です。なので、外部依存の部分はモック化した方良い場合が多いと思います。

また、モックが期待通りの動作をしないと、それに依存するテストの結果の信頼性が下がってしまうので、モック自体のテストも書きます。

モジュールは複数の責務を持たない

テストケースファースト

プログラムを作成・変更するまでにテストケースを書くことで仕様の詳細を詰めることができ、期待する動作に抜け漏れが減らすことができます。

テストケースは書きますが、テストコードまでは一気に書く必要はありません。ただ、コードの詳細がイメージできれば書いても良いです。この目的は仕様の詳細を詰めて、テストケースの抜け漏れを減らすためだからです。

テストコードが上から下に読んで自然に理解できる

「冗長化はOK」と「AAA」にも似ていますが、テストコードを 上から下に読んで、自然に理解できるコードが良いです。誰かが作ったコードを読んだ時、認知負荷が高いとテストコードのバグの原因になったりコードを修正する人のストレスになってしまいます。

UIテストでWaitを書く必要がありますか?

Cypressなどのフレームワークを使ってコンポーネントテストやE2Eテストを書いている場合、テストフレームワークのクリックなどの動作速度が早く、フロントエンドフレームワークやネットワーク側の動作が遅い場合、壊れやすいテストになる可能性があります。例えば、ローカルマシンでは動いていたけど、CIでたまに失敗することがあります。

そのような場合、 人間の操作速度を再現して解決する方法があります。 具体的には、テストフレームワークのwaitを使ってクリックした後、1秒待ってから次の操作をしたりなどです。しかし、これは間違った解決方法です。

理由としては以下があります。

  • すべてのテストケースで1秒待つとして、1000テストケースあるとします。すると、テストケース全体では1000秒余分にかかることになります。
  • どのようにOOOms待つを決めますか?
  • そもそも、ユーザーの操作が早く、かつPCスペックが低くフロントエンドフレームワークやネットワーク速度が遅い場合、バグが発生する可能性があります。これは、操作速度によるバグを覆い隠しているだけになります。

ではどのように解決するかというと、元のコードをリファクタリングします。ローカル環境では再現しないことが多いので、原因特定にはコードの動作の理解やフロントエンドフレームワークの理解が必要になります。しかし、この問題を解決するために努力することは、開発者がテストを信頼し失望しなために必要なことです。

以下の記事が参考になるので、ページ翻訳で読んでみてください

Identifying Code Smells in Cypress | CodingItWrong.com

User automation tests are intended to closely replicate a real user interacting with your app, and Cypress purports to be even more realistic than past testing…

アプリが手動テストで機能する場合、どのようにバグがありますか?これはテストのためだけの努力ではありませんか?いいえ。ユーザーがまだバグをトリガーしていなくても、多くの場合、十分に速く、アプリが十分に遅く実行されている場合、バグをトリガーする可能性があります。これらの問題を解決すると、より安定して信頼性の高いアプリケーションにつながるため、多くの場合、十分な労力が費やされます。

どこに書くの?

ユニットテスト、結合テスト

テスト対象と同じディレクトリに’tests’ を作って、その中に書きます。 テスト対象のファイルの近くにあるとわかりやすいためです。

app/main.ts
app/__tests__/main.test.ts

component/Button.vue
component/__tests__/Button.test.ts

E2Eは別ディレクトリに書きます。

test/e2e

テストが機能している世界線

テストが機能していた場合どのような世界になるでしょうか? なぜ書くか? に書いたとおりですが、特に1日の気力を、「バグや手動テスト、リリースの不安」以外のことに使える世界になることが、開発者にとって大きい利点なのではないかと思っています。努力は必要ですが、心穏やかに本質的なプロダクトの価値追加に時間を使うことができるようになると思います。

さらにその先に、「継続的リファクタリング」に繋がっていくと思います。 テストがあるコードをファクタリングしてとき、 現在の仕様が変わってしまったかどうかすぐに気づけるので気軽にリファクタリングすることができるようになります。 継続的リファクタリングができるようになると、スパゲッティコードに気力を削られることなく、さらに心穏やかな開発ができる好循環につながっていくでしょう。

持続可能性

FAQ

カバレッジは必要?

カバレッジOO%に意味はないが、たまにテストコードの点検のために見るのは有用。

カバレッジ自体はテストコードがテスト対象に対しての網羅性を表現しているだけで、テスト対象が期待通り動作するか測るものではない。カバレッジが高いことと、動作が保証されていることはイコールではない。

それよりも、テスト対象のコードが仕様通りであること、入力と出力を網羅的に検証できていることが大事。別の言い方をすると、「意味のある」テストコードを書くことが大事。

カバレッジの誤用として、カバレッジOO%になったので、テストの追加は必要ないという誤った判断になる副作用もある。またはカバレッジOO%にするために不必要なテストコードになっている可能性もある。

ただ、テストコードの点検で、抜け漏れをチェックするには役立つこともある。

E2Eテストの割合は少ないということだけど、どこまで書くの?

クリティカルパスのみ

どのように継続するの?

一番大事なのは、メンテナンスが簡単であることだと思います。難しいと書くことが億劫になってしまいます。

メンテナンスを簡単にするには以下を意識する必要があります。

  • 依存ライブラリをモック化して、テストのセットアップを簡単にする。例えば、DBを用意せずにモックコードに置き換えたり、HTTPリクエストが必要なコードをモックに置き換えたりします。
  • テスティングライブラリの機能に依存しすぎない。例えば、テスティングライブラリにはspyやmock関数などを使って簡単にモック化できますが、これに依存しすぎると、その機能のフォーマット通り書く必要があったり、モックの動作がブラックボックスになり動作を追いづらくなります。
  • DRYにしすぎない。冗長性をなくして入力や出力が関数などにラップされてしまうと、何をテストしているか

もう一つ大事なのは、各人が自立してテストを書くことを習慣化することです。そのためにはテストを書いた方がお得だということを知ることだと思います。テストを書いた世界線と書かない世界線を定期的に想像してみると良いかなと思います。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です