Zenn
💢

過去の自分に伝えたいこと:ユニットテストをなめるな!

2025/03/22に公開

はじめに

長年、SE兼PGとしてシステム開発に従事してきました。

そのキャリアのかなりの部分を先人たちの不具合対応に費やしてきました。

その経験を活かして、2023年9月に ISTQB (International Software Testing Qualifications Board:国際ソフトウェアテスト資格認定委員会)のCertified Tester Advanced Level Test Analyst ( CTAL-TA )の資格を取りました。

ISTQB は、ソフトウェアテスト技術の普及とそのスキル認定を行っている国際団体です。日本では、 ISTQB に加盟する JSTQB という団体が資格試験の運営を行っています。

CTAL-TAは、ソフトウェアテストの技術的な側面に精通したテストエンジニアのための応用的な資格です。

https://www.istqb.org/certifications/test-analyst

ISTQB® Advanced Level Test Analyst (CTAL-TA) 認定資格は、ソフトウェア開発ライフサイクル全体にわたって、構造化された徹底的なソフトウェアテストを実施するために必要なスキルを提供します。 標準的なテストプロセスの各ステップにおけるテストアナリストの役割と責任について詳しく説明し、重要なテストテクニックについて解説します。

この記事では、私が自身のキャリアを通じて学んだこと、CTAL-TAの勉強をしつつ自分のキャリアを振り返って気づいたことを紹介したいと思います。

ユニットテストはテストのカナメ

テストには、供試体をどこまで利用時と同じ状態にするか、結合するか、という観点でいくつかの種類があります。

一般に、E2Eテスト、インテグレーションテスト、ユニットテストなどの種類が知られています。

この際、テストは結合度を高めるほど以下のような現象が見られるようになります。

  1. デメリット
    1. テストの準備にかかるコストが高くなる
    2. テストの速度が低下する
    3. テストが毎回同じように安定して動くという決定性が低下する
  2. メリット
    1. 利用時の挙動を反映した忠実性が高くなる

この現象はテストピラミッドとして知られています。

テストピラミッドとは、コスト(記述コストと実行コスト)と忠実性(本物の挙動を反映している度合い)が高く、実行速度と決定性(テストが毎回同じように安定して動く度合い)が低いテストほどケース数を減らすべきだという、自動テストケース数の望ましい比率をピラミッド型に視覚化したものです。

図1は、テストの粒度をユニットテスト、インテグレーションテスト、E2E(end to end)テストの三段階で示しており、テストピラミッドの説明によく用いられます。ユニットテストがもっとも多く、E2Eテストがもっとも少ない状態に近づけることで、開発速度と信頼性の高いバランスが得られると言われています。

図1 テストピラミッド
image.png

ref. https://gihyo.jp/dev/serial/01/savanna-letter/0005

こうしてみてみると、実は「結合度を上げた場合のメリットは忠実度しかない」ことに気づきます。

そうなんです。

ユニットテストは忠実度こそ低いですが、速度、決定性、コストのいずれももっとも良いため、テストケースを増やしてテストの網羅性を高めやすいのです。

ユニットテスト軽視は負債をあと送りしているだけ

実際の開発の現場では、設計や実装フェーズにおける開発の進捗遅延を挽回するためにユニットテストの網羅度を下げたり、手を抜いたりしているのではないでしょうか。

しかし、これは単に負債を後送りしているだけで、そのツケは後工程のインテグレーションテストやE2Eテストで不具合が多発してテストケース数が爆発することになります。

その結果、ピラミッドはアイスクリームと化します。

図2 アイスクリームコーン型
image.png

ref. https://gihyo.jp/dev/serial/01/savanna-letter/0005

ユニットテストでどれだけ忠実度を上げられるか

ユニットテストの弱点は忠実度でしたね。

後はユニットテストでも忠実度を一定のレベルでキープできれば、効率的で効果的なテストを自動的に実行することができるでしょう。

ここで、忠実度を高めるポイントは、テストダブルです。

image.png

ref. http://xunitpatterns.com/Test Double.html

テストダブルは、供試体(SUT)から呼び出され、供試体が依存するコンポーネント(DOC: Dependented-on Component)を置き換えるオブジェクトです。

データベースや外部システム、APIなどを置き換える際に使われます。

とくにユニットテストでは、プログラマーのそれぞれの環境にデータベースや外部システムを用意する手間やコストを省くため、テストダブルを多用するケースがしばしば見られます。

このテストダブルが、ユニットテスト実行時と供試体の利用時の主たる相違点であり、テストダブルが実際のコンポーネントに忠実であればあるほどユニットテストの忠実度は高まると言えます。

より良いテストダブルを学ぶべし

したがって、忠実度の高いテストダブルを作る術を学ぶことがユニットテストの忠実度向上の近道であり、ユニットテストの積極的な活用がテスト全体の効率性・効果性を高めるカナメと言えます。

だめなユニットテストはこれだ

ユニットテストのアンチパターンがいくつか知られています。

  1. Obscure Test (曖昧なテスト)
  2. Fragile Test (壊れやすいテスト)
  3. Flaky Test (脆いテスト)

ここでは、それぞれのアンチパターンについて簡単に説明します。

Obscure Test (曖昧なテスト)

Obscure Test は、テストが供試体の振る舞いを明確に表現していないため、テストの意図が不明瞭であるテストのことです。

Obscure Test となる要因は、以下のようなものがあります。

  • CaseA:Eager Test
    1つのテストメソッドに対して複数のテストケースを熱心に書き込みすぎによって、読んで理解ができない

  • CaseB:前提データ準備で理解できない(Irrelevant Information or Mystery Guest or General Fixture)
    データ準備(Given)を長々と羅列して記述してしまうことで、期待結果(Then)と本当に関係のある変数がわからなくなってしまう

  • CaseC:Hard-Coded Test Data
    入力値や期待結果がベタ書きの値のまま記載されてしまうと、なぜこの結果になるのかが読んですぐに理解できない

ref. 曖昧なテスト(Obscure Test): テストコードを読んでも意図が理解できない問題 | Agile Studio

Fragile Test (壊れやすいテスト)

Fragile Test は、テストが供試体の内部実装に依存しているため、供試体の内部実装が変更されると容易にテストが壊れてしまうテストのことです。

このようなテストは、テストの維持コストが高くなり、テストの網羅性が低下したり、テストの信頼性が低下したりする原因となります。

Fragile Test を避けるためには、テストが供試体の振る舞いに依存するように工夫することが重要です。

Flaky Test (脆いテスト)

一方で、Flaky Test は、テストが毎回同じように安定して動かないテストのことです。

自動テストが、テストの実行順序や環境に依存してテスト結果が変わってしまうことはないでしょうか。

そのようなテストのことを、Flaky Test と呼びます。

Flaky Test は、自動テストの信頼性や生産性を損なう重大な問題であり、2009年から多数の研究論文(Paper)も発表されています。

image-20241215095724148

ref. A Survey of Flaky Tests | ACM Transactions on Software Engineering and Methodology

いいユニットテストはこれだ

ユニットの単位を適切に設定する

ユニットテストがテスト対象とするユニットに対して、一貫したユニットの単位を保つことが重要です。

ユニットの単位をメソッドとするか、クラスとするか、パッケージとするか、モジュールとするか。

ユニットテストの単位をどのように設計するかは、テスト毎に異なってよく、ケースバイケースです。

Javaのユニットテスト・フレームワークのJUnitでは、通常、ユニットの単位をメソッドとして設計します。

一方で、Pythonのユニットテスト・フレームワークのunittestやpytestでは、ユニットの単位は自由に設計でき、クラス、モジュールなどの単位で設計されることが多いように感じます。

いずれにせよ、ユニットはソフトウェアの特性、組織のルール、開発チームのカルチャー等をふまえて総合的に判断して決定されます。

ユニットの単位が小さいと

ユニットの単位が大きすぎると、テストの網羅性が低下し、テストの信頼性が低下します。

ユニットの単位が大きいと

また、ユニットの単位が大きい場合は内部処理が複雑に入り組んでいてFlakey Test が発生しやすくなる傾向があります。

大きいすぎるユニットに対する Flaky Test の原因究明は、実に骨が折れる作業です。

ユニットの単位のトレードオフ

一方で、ユニットの単位が小さすぎると、テストの網羅性が高くなり、テストの信頼性も上がりますが、テストの維持コストが高くなります。

ユニットの単位のトレードオフをまとめた表を以下に示します。

ポイント 大きい 小さい
Obscure Test の発生しやすさ 発生しやすい ❌ 発生しにくい 👍
Fragile Test の発生しやすさ 発生しやすい ❌ 発生しにくい 👍
Flaky Test の発生しやすさ 発生しにくい 👍 発生しやすい ❌
テストの網羅しやすさ 低い ❌ 高い 👍
テストの維持コスト 低い 👍 高い ❌
テストの速度 遅い ❌ 速い 👍
テストの決定性 高い 👍 低い ❌
テストのコスト 低い 👍 高い ❌

ユニットの単位に正解はありません。

トレードオフ表を参考に、ソフトウェアの特性、組織のルール、開発チームのカルチャー等をふまえて総合的に判断して決定することをオススメします。

ユニットテストのデザインパターンをまねぶ

ユニットテストではAAA(Arrange-Act-Assert)パターンというシンプルなデザインパターンがよく使われます。

class Calc():
    def add(self, a, b):
        return a + b

class TestCalc():
    """Test class for Calc"""

    def __init__(self):
        self.target = Calc()
    
    def test_add(self):
        """ Test add method (テストケース) """

        # Arrange (データ準備)
        left = 1
        right = 2
        actual = 3 # = left + right

        # Act (テスト実行)
        result = self.target.add(left, right)

        # Assert (期待結果の検証)
        assert result == actual

このAAAパターンでは、3つのフェーズに分けてテストを記述します。

  1. Arrangeフェーズ
    • テストの入力値、期待値を宣言します。
    • Arrangeフェーズが長くなる場合は、テスト対象のメソッドの入力値や出力値の種類が多すぎて複雑すぎる可能性があります。
  2. Actフェーズ
    • テスト対象のメソッドを実行します。
    • 通常はテスト対象のメソッドを実行する1行のみとすべきです。
    • Actフェーズが長くなる場合は、テスト対象のメソッドの入力値の数が多すぎて複雑すぎる可能性があります。
  3. Assertフェーズ
    • テストの期待値と実際の値を比較します。
    • Assertフェーズが短すぎる場合は、テストの網羅度が低い可能性があります。
    • Assertフェーズが長すぎる場合は、テスト対象のメソッドの出力の種類が大具して複雑すぎる可能性があります。

このAAAパターンには以下のメリットがあります。

  1. テストの意図が明確になり、Obscure Test を防ぐ
  2. テストが壊れた際に修正しやすくなり、 Fragile Test の修正コストを下げる
  3. テストコードの作成を通じて、プログラマーがテスト対象メソッドの複雑度に目を向けるようになる
  4. テストが不安定になる要因になりやすいActフェーズが短いため、テストが安定しやすくなり、Flaky Test が起きにくくなる

おわりに

ユニットテストは、テストのカナメです。

この記事では、ユニットテストの重要性、ユニットテストのアンチパターン、ユニットテストのデザインパターンについて紹介しました。

ぜひ、ユニットテストを活用して、より良いソフトウェアを開発していただければ幸いです。

Discussion

ログインするとコメントできます