LoginSignup
61
55

ユニットテストで避けるべき罠: 最適な品質保証のために落とし穴を避ける

Last updated at Posted at 2023-11-21

はじめに

新卒として、実務に入って3ヶ月目のお話。なぜかテストコードの正式な導入の動きにより、先輩から、「テストコードの規約かいて〜〜〜」と超ラフに言われ、「ん?」となりつつ、作りました。そんな中で、テストコードの作成やお試し実装などをしている時に、みんなこれハマるよな...というものをざっと上げてみます。

ユニットテストにおけるテスト自動化は、システムのテストにおいて最も難易度が高いと言われているそうです。その要因の1つとして、やはり注意すべき点や陥りやすい罠が多いことが挙げられます。この記事では、テストコードの一般的な罠に焦点を当ててみました。

対象の読者

  • 今後、会社にユニットテストを導入しようと考えている人
  • 品質管理をしているエンジニア
  • テストコードに興味があるエンジニア

1. Over-Testing と Under-Testing

テストをする上で、テストシナリオとして必要なものをカバーすることが必要です。しかし、オーバーテストのように必要のない部分まで過剰にテストすることや逆にアンダーテストのように必要な範囲のテストができていないことがあります。正直、テストの中で一番、バランスが難しいところです。ただ、これがうまくバランスを取ることができれば、保守性信頼性の確保に繋がります。

どんな影響があるのか?

  • オーバーテスト:
    • テストコードの作成工数などのリソースやコストの無駄になる
  • アンダーテスト:
    • システムの信頼性が担保されない
    • バグの発見が遅れたり、調査や修正のためのコストがかかってしまう
    • セキュリティリスクが高まる

どうすればいいのか?

一言でいえば、「バランス」が鍵です。ただ、そんなことを言われても...となりますね(笑)意識すべき観点としては、

  • テストシナリオの把握(要件定義や設計から考えうるもの)
  • 現実的な使用ケース(実際に想定されるもを中心とする)

に焦点を当てることです。


2. テストコードのメンテナンス

ソフトウェアやサービスの修正や変更が行われたと同時に、要件定義や設計から考えうるシナリオに合わせて、変更していく必要があります。そのためにテストコードのメンテナンスとして修正・変更が必要になります。これをすることで品質保証や信頼性などを維持することができます。

メンテナンスしないとどうなるのか?

  • テストそのものが時代遅れになり、パスするはずなのにエラーが起こることやその逆が起こる
  • テストパッケージのバージョンによるバグの発生するかもしれない。
  • テストスイートへの信頼を損なう可能性がある

どうすればいいのか?

定期的にテストを見直し、更新することがベストです。開発プロセス・開発フローの中に、一環としてテストメンテナンスを組み込むことで、インシデントの量を抑制したり、リファクタリングの数の減少ができます。また、最新の状況のテストコードがあることで、初めてその実装部分に関わる人が理解をするための時間的コストが削減できます。


3. アサーションによる細かすぎるテスト

過剰に具体性を持ったアサーションは、出力の些細な側面に焦点を当てた、詳細すぎるテスト条件のことといえるでしょう。例えば、ある関数が正しい型の値を返すことを単純に検証するのではなく、関数の目的に付随する値や無関係な値も含めて、正確な値をチェックするようなことです。

  • ある関数がソートされた項目のリストを返す場合、過剰固有アサーションは、リストが正しくソートされていることだけを保証するのではなく、付随的な順序やプロパティを含むリストの内容全体をチェックする
  • タイムスタンプを生成する関数の場に過剰なアサーションは正確なタイムスタンプ (ミリ秒単位まで) をチェックする
ソートされた項目のリストを返す関数の例(過剰にしたやつ)
package main

import (
    "reflect"
    "sort"
    "testing"
)

// sortNumbers は、整数のスライスを受け取り、それをソートして返す
func sortNumbers(numbers []int) []int {
    sort.Ints(numbers) 
    return numbers
}

// 過剰に具体的な期待値を使用
func TestSortNumbers_OverSpecific(t *testing.T) {
    input := []int{3, 1, 2} // 入力値
    expected := []int{1, 2, 3} // 過剰に具体的な期待値
    result := sortNumbers(input) // 関数を呼び出して結果を取得

    // 結果が期待値と一致するか確認
    if !reflect.DeepEqual(result, expected) {
        t.Errorf("Expected %v, got %v", expected, result)
    }
}
ソートされた項目のリストを返す関数の例(適切なアサーション)
// 適切なアサーションを使用
func TestSortNumbers_Appropriate(t *testing.T) {
    input := []int{3, 1, 2} // 入力値
    result := sortNumbers(input) // 関数を呼び出して結果を取得

    // 結果がソートされているか確認
    if !sort.IntsAreSorted(result) {
        t.Errorf("Result is not sorted: %v", result)
    }
}

影響

  • ソフトウェアが実際に正しく動作しているにもかかわらず、テストが失敗する偽陰的な事象を引き起こす
  • テストが特殊すぎて、小さな改修でも、テストを何度も更新する必要があり、テスト工数がかかってしまう
  • テストが特殊すぎるアサーションは、テストの実際の意図を不明瞭にし、テストがどのような機能を検証するものなのかを理解にくくなる
  • コードベースの柔軟性と適応性を低下させる

どうすればいいのか?

アサーションは、実装の詳細に過度に結びつかないように、正確性を検証する程度にすべきです。プロセスではなく "結果" に焦点を当てることで、このようなことは防げるように思います。


4. カバレッジ率

テストカバレッジ(もしくは、コードカバレッジ)はテストによって実行されるコードの割合を測ります。カバレッジ率は、テストによって実行されるコードの割合を示す指標ですが、必ずしも品質や徹底性を意味するものではありません。よくある誤解として、「100%のテストカバレッジだから完璧!」とすることです。そうではなく、「100%でなければならないというのは嘘」 です。

カバレッジを意識しすぎることによる影響

  • 「高いテストカバレッジ率だから大丈夫!」となり油断が生まれ、テストケースでカバーされていない潜在的なバグを見落としてしまう可能性があることを見落とす
  • 100%のカバレッジを目指しすぎて、工数やリソースの無駄な消費によって、コスパが悪くなる
  • 意味のないテストを生成する
  • 可読性、可用性の損なわれたコードになり、テストコードのメンテナンスや偽陰性が生まれる

どうすればいいのか?

カバレッジはいろんな記事が出ていますが、簡単に要約すると以下の3つになります。

  • 量より質を意識する
  • カバレッジを目標として捉えるより、1つのステータスとして捉える
  • エッジケースや正常系/準正常系/異常系を含む一連のシナリオをカバーする意味のあるテストを目指す意識にする

5. オーバーモッキング

オーバーモッキングは、テストで多くのモックオブジェクトやスタブを使用することを指し、依存関係や実際の動作を不明瞭にする可能性があります。

考えられる影響

  • 実際のシステムや外部サービスとのやり取りが適切にテストされていないために、本番環境と差異が生まれてしまう
  • 本番環境と違う場合には、テストの結果が正常であっても、潜在的な問題が隠れており、発生する可能性がある
  • システムの異なる部分間の統合や相互作用が適切にテストされないため、統合時の問題を見逃すリスクが高まる
  • テストの複雑化させ、理解やメンテナンスにコストがかかってしまう。

どうすればいいのか?

  • 必要最低限のモック使用する
  • ユニットテストだけでなく、統合テストにも重点を置き、異なるコンポーネント間の相互作用をテストする
  • 本番環境と似た条件でテストを行い、実際の挙動をできるだけ再現する
  • 外部サービスやネットワーク呼び出しなど、テスト中に制御が難しい要素に対してのみモックを使用する
  • そもそもモックが実際のオブジェクトやサービスの振る舞いを正確に反映していることを確認する

まとめ

この記事のポイントをまとめると以下のようになります。

  • テストケースのバランスを考える
  • テストコードもメンテナンスする
  • 細かすぎるテストはしない
  • カバレッジに囚われすぎない
  • モックを使う時には本番環境との差がないようにする

これはユニットテストだけではなく、他のテストでも言えるものです。ぜひ、テストコードを書くエンジニアの方や導入しようとしている方々に何かの参考になればと思います。



【コラム】Goでテストを実装するときのポイント

Go言語でのテストコードを書く際に守るべき推奨の項目と、それらを反映した初心者向けの実装例を以下に示します。

ポイント!

  1. 明確なテストケースの作成する!
    テストする関数や機能に対して、その動作を明確に示すテストケースを作成しましょう!

  2. 小さい単位でテストする!
    個々の関数やメソッドに対して、小さい単位でテストを行いましょう!これにより、特定の問題を素早く特定しやすくなります。

  3. テスト名に意味を持たせる!
    テスト名はそのテストが何をしているのかを明確に示すようにしましょう!

  4. エラーメッセージを具体的に!
    テストが失敗した場合に有益な情報を提供するため、エラーメッセージを具体的かつ明確にしましょう!

  5. テーブル駆動テスト(TDD)!
    同じ構造のテストケースを繰り返す場合、テーブル駆動テストを利用してコードの重複を避けましょう!

実装例

以下の例では、単純な足し算を行う関数に対するテストを行います。

package main

import (
    "testing"
)

// add は二つの整数 a と b を受け取り、それらの和を返す
func add(a, b int) int {
    return a + b // 与えられた二つの整数の和を計算して返す
}

// TestAdd は add()関数のテストを行う
func TestAdd(t *testing.T) {
    // テストケースのセットを定義します。
    // 各テストケースは名前、入力値 a と b、期待される結果を持つ
    cases := []struct {
        name   string
        a, b   int
        expect int
    }{
        {"two positives", 2, 3, 5},       // 両方の数が正のケース
        {"positive and negative", 2, -2, 0}, // 正と負の数のケース
        {"two negatives", -2, -3, -5},    // 両方の数が負のケース
    }

    // テーブル駆動テストを利用して、各テストケースを実行
    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            result := add(c.a, c.b) // add 関数をテストケースの入力値で呼び出し
            // 結果が期待される値と一致するか検証します。
            if result != c.expect {
                // 一致しない場合、エラーメッセージを出力します。
                t.Errorf("add(%d, %d) = %d; want %d", c.a, c.b, result, c.expect)
            }
        })
    }
}
  • 明確なテストケース: add 関数に対するさまざまな入力値と期待される出力値をテストケースとして定義しています。
  • テーブル駆動テスト: 複数のテストケースを構造体スライスで定義し、forループでそれらを繰り返しテストしています。
  • 具体的なエラーメッセージ: t.Errorfを使用して、テストが失敗した場合に詳細な情報を提供しています。

このような構造を利用することで、Go言語におけるテスト工数の削減、可読性の向上、保守性や信頼性の向上に繋がります。

参考文献

61
55
1

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
  3. You can use dark theme
What you can do with signing up
61
55