電通総研 テックブログ

電通総研が運営する技術ブログ

テスタビリティを高めるために意識していること

こんにちは。X イノベーション本部 AI トランスフォーメーションセンター 所属の山田です。
本記事は電通総研 Advent Calendar 2024の 12 月 17 日の記事です。

はじめに

私達は普段、Python を使ってアプリケーション開発をしています。
本記事では、チームでアプリケーション開発を進めていく中でテスタビリティを上げるために意識していることを紹介したいと思います。
なおコード例は Python ではありますが、採用言語に関わらず品質を高めたいと考えているエンジニアやリーダーにとって参考になる内容を目指しました。

テスタビリティとはなにか

「テスタビリティ」はプロダクションコードをどれだけ容易にテストできるかを指します。
テスタビリティが低いコードはユニットテストや統合テストの作成に時間がかかるだけでなく、コードのメンテナンス性が低下しデバッグ、新機能の追加が難しくなる傾向にあります。

テスタビリティそのものを定量的な指標で測ることは難しいため、本記事ではテスタビリティを下げてしまう事例から、どのように改善するとテスタビリティを高められるかを紹介したいと思います。

テスタビリティが低くなる事例とその対応策

1. インスタンス化が困難なクラス

テスト対象クラスのコンストラクタを確認することで、そのクラスのテスタビリティについてはある程度把握できます。
テスト対象のクラスのインスタンス化が困難な場合、そのクラスのテスタビリティは低い傾向にあります。

インスタンス化を困難にする要因としては以下のことが考えられます。

  • 多すぎる引数
    これはビジネスロジックを記述するサービスクラス側で問題となることが多いものです。
    コンストラクタに渡すべき引数が多いと、テスト対象クラスを組み立てる際の設定が煩雑になります。
    またテスト時に多くのパラメータを準備する必要があり、設定ミスも発生しやすくなります。

  • 不適切な型の定義
    これはデータを保持するデータクラス側で問題となることが多いものです。
    特定の外部ライブラリの型(pandas.DataFramenumpy.arrayなど)を直接利用した設計は、テスタビリティを大きく下げる可能性があります。

対応策

  • クラス設計の見直し
    インスタンス化が困難なクラスを解決するには、多くの場合はクラス設計を見直す必要があります。
    コンストラクタで多くの引数を受けるクラスは大抵の場合、責務を持ちすぎています。クラスを分割できないかを考えてみましょう。

  • 外部依存を隔離して適切な型を利用する
    外部ライブラリの型に依存しないプリミティブな型やドメイン固有型のクラスによる設計をしましょう。
    pandas.DataFramenumpy.arrayなどからデータを変換する必要がある場合は、定義したドメイン固有型のクラスにファクトリーメソッドを用意するなどのアプローチを取りましょう。

import numpy as np
from dataclasses import dataclass

@dataclass
class Vector:
    values: np.ndarray # 外部ライブラリ依存

@dataclass
class Vector:
    values: list[float] # Python標準のfloat型のリストを利用する

2. 関数・メソッド内に依存関係が含まれている

関数・メソッド内に依存関係が含まれているコードもテスタビリティを下げます。
例えば以下のコードを考えてみましょう。

from dataclasses import dataclass

# データクラスとしてのUser型
@dataclass
class User:
    name: str
    email: str

class UserRepository:
    def save(self, user: User) -> User:
        # ユーザーデータをデータベースに保存する処理(詳細は省略)
        # ...
        return user

class UserService:
    def register_user(self, user: User) -> User:
        # メソッド内で直接リポジトリをインスタンス化
        repository = UserRepository()
        saved_user = repository.save(user)
        return saved_user

関数・メソッド内で依存関係が生成されると、テスト時に差し替えることが困難になります。
テスティングライブラリ側で差し替え方法が用意されている場合もありますが、そのようなコードはトリッキーで可読性が低くなりがちです。

対応策

関数・メソッド内に依存関係が含まれている場合は、外部から依存を注入できるようにパラメータ化した設計にします。
いわゆる、Dependency Injection: DI の考え方です。

上記のコード例はコンストラクタでUserRepositoryを注入するように変更することでテスタビリティを高めることができます。

from typing import Protocol

# リポジトリのインターフェースを定義
class UserRepository(Protocol):
    def save(self, user: User) -> User:
        ...

# リポジトリ実装
class DatabaseUserRepository:
    def save(self, user: User) -> User:
        # ユーザーデータをデータベースに保存する処理(詳細は省略)
        # ...
        return user

# サービスクラス
class UserService:
    def __init__(self, repository: UserRepository) -> None:
        self.repository = repository

    def register_user(self, user: User) -> User:
        saved_user = repository.save(user)
        return saved_user

これによってテストコードを書く際にUserRepositoryの実装を置き換えることも容易になります。

3. 返り値のない関数・メソッド

返り値のない関数・メソッドのテストは大抵、困難になります。
プロダクションコードにおいて、返り値のない関数・メソッドは副作用を伴う処理を実施している場合がほとんどだからです。
例えば、ファイルをクラウドストレージなどにアップロード処理をするコードに返り値がない場合を考えてみましょう。

class FileUploader:
    def upload(self, file_content: str) -> None:
        # 実際のアップロード処理
        # 返り値はない
        ...

この処理が完了したかを確認するためには、アップロード先のクラウドストレージの状態を確認する操作が必要になり厄介です。

対応策

このような場合は関数・メソッドを返り値を持つ設計にし、動作結果をテストできるようにしましょう。

from dataclasses import dataclass

@dataclass
class UploadResult:
    success: bool
    url: str | None

class FileUploader:
    def upload(self, file_content: str) -> UploadResult:
        try:
            # アップロード先のURL
            uploaded_url = f"https://..."
            return UploadResult(success=True, url=uploaded_url)
        except Exception as e:
            print(f"Failed to upload: {e}")
            return UploadResult(success=False, url=None)

返り値があることによって、ファイルのアップロードの成否を検証しやすくなります。
また返り値があることで該当クラスのFileUploaderだけでなく、FileUploaderクラスに依存している他のクラスでは返り値を変化させたモックを使うことで容易にテストを行うことができるようになります。

4. 時間に関する処理

時間に関する処理もテスタビリティを下げる要因となります。
これは時間に関する処理を記述する場合、冪等性のない処理になる可能性が高いからです。

例えば、ユーザーの誕生日の情報をもとに年齢を算出する場合を考えてみましょう。

from datetime import date
from dataclasses import dataclass

@dataclass
class User:
    name: str
    birth_date: date  # 誕生日

    def get_age(self) -> int:
        # 現在の日付を取得
        today = date.today()
        return today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))

get_ageメソッドは現在の日付を取得して、ユーザーの誕生日情報との差分から年齢を算出します。
現在の日付情報を取得するdate.today()の結果は実行するタイミングで変化するので、このままでは冪等性がなくテストが難しくなります。

対応策

テスティングライブラリ側の機能で時間を固定する機能が提供されている場合もありますが、可能であれば時間情報をパラメーターとして受けるようにしましょう。
時間情報をパラメータとして受け入れるようにすることでメソッドの冪等性を担保できます。

@dataclass
class User:
    name: str
    birth_date: date  # 誕生日

    def get_age(self, today: date) -> int:
        # 引数で受け取った日付情報をもとに年齢を算出
        return today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))

引数から時間情報を渡す設計にすることで、安定したテストを行うことができます。

実際の開発プロセスにおいて価値を引き出すために

ここまではプロダクションコードのテスタビリティを高めるために意識するポイントを紹介しました。
しかし実際の開発プロセスでは、ここまで紹介した個別の事例と対応策だけではテスタビリティの高いコードを整備し続けることは難しいでしょう。
ここからは、テスタビリティの高いコードを整備し続けるために、実際の開発プロセスで重要な要素について紹介します。

テスト戦略の共有

そもそもなぜテストをするのかやテストは何を担保するものなのかを含めてチームでテストに関する全体的な方針を立てることが重要です。
テスト戦略への意識が希薄だと形式的にテストを追加するだけになり、本質的な品質向上には繋がりません。

テスト戦略の共有では、テストレベルに合わせたテストのスコープを定めるのが良いです。
出発点としてはテストピラミッドをベースにすると良いでしょう。

テストピラミッドは「E2E テスト」「インテグレーションテスト」「ユニットテスト」の 3 つのテストレベルの関係性についてピラミッド図で表したものです。
ピラミッド上位のテストレベルほどテストの実行にコストを要するため、ユニットテストのテストケースを重要であることを示します。
ただし戦略を考えるに当たって重要なのは、やみくもにユニットテストを増やすのではありません。
ユニットテスト、インテグレーションテスト、E2E テストで何をカバーするのかを明確にし、どのようにしたら効果的なテストを実現できるかを示すことです。

またテストピラミッドは機能要件をテストするための概念であるため、非機能要件のテストについては直接的に考慮されていません。
非機能要件のテスト戦略については機能テストとは別で考える必要があります。
非機能要件のテストは個別の機能ではなくシステム全体に関わる特性を評価するものです。
そのためリソースや時間が限られる中では優先度を決めて、現実的に可能な範囲で実行することが重要になります。

パフォーマンステストなどの負荷テストではシステムが提供するユースケースやビジネスフローに基づいて、特に重要なシナリオから始めることが重要です。
高度な専門知識が必要となるセキュリティテストは外部の専門チームに依頼することも有効なテスト戦略の 1 つです。

テストコードの書き方についてのガイドライン

プロダクションコードの書き方についてはよく取り上げられますが、テストコードの書き方については意外と軽視される部分です。
しかしテストコードは開発において品質保証の基盤になるため、プロダクションコードと同様に整備すべきものです。

テストコードの書き方についてもチームに共有しておくことが重要です。
以下はテストコードの書き方についてのガイドラインの一例です。

  • 構造化されたテストコード
    「given」「when」「then」を意識してテストを記述をしましょう。
    • given: テストの前提条件となる準備部分
    • when: テスト対象の振る舞いの呼び出し
    • then: 実施結果の検証
  • 明瞭なテストケース名をつける
    テストケース名には日本語を使って何をテストしているかが一目でわかるようなテストケース名を付けましょう。
  • テストコードが書きにくいと思った場合のアクション
    テストコードが書きにくい場合、それはプロダクションコードに改善の余地があるサインです。テストが難しい箇所を見つけたらプロダクションコードを見直してテスタビリティを下げている要因を探しましょう。

以下はガイドに基づきながら書いた、先の例で取り上げたユーザーの誕生日から年齢を算出するメソッドのテストコード例です。

from datetime import date

@dataclass
class User:
    name: str
    birth_date: date  # 誕生日

    def get_age(self, today: date) -> int:
        # 引数で受け取った日付情報をもとに年齢を算出
        return today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))


# テストコード
def test_user_誕生日の算出ができること() -> None:
  # given
  birth_date = date(2000, 4, 4)
  user = User(name="テストユーザー", birth_date=birth_date)
  today = date(2024, 12, 15)
  expected_age = 24

  # when
  actual_age = user.get_age(today=today)

  # then
  assert expected_age == actual_age

またガイドラインを整備するだけでなく、テストコードの記述も含めてモブプロで取り組むのも良いアプローチです。
モブプロを活用してテストコードを記述することで、チーム全員のスキル向上や設計改善につなげることができます。
テストコードは単なる補助的な存在ではなく、システムの品質を支える基盤として、適切に整備することを目指しましょう。

テストケースの洗い出し

テスタビリティが高くなったコードを最大限に活用するには、テストケースの設計が不可欠です。
テストケース設計の際はテスト技法を押さえておくことで網羅性を高めながら効果的にテストケースの設計を進めることができます。

以下は基本的なものですが、個人的には効果が大きいと感じているテスト技法です。

  • 境界値分析
    境界値分析は入力値や条件の境界に着目して動作を確認するテスト技法です。
    主に入力値の上限/下限が決まっているフォームバリデーションのテスト項目の設計に有効です。
  • 同値分割
    同値分割は入力データや条件をグループ化し、代表的なケースをテストすることで効率的にケースをカバーするテスト技法です。
    データのパターンが多い場合に代表値を使って効率的な検証が求められるテスト項目の設計に有効です。
  • テストマトリクス(デシジョンテーブル
    テストマトリクスはデシジョンテーブルとも呼ばれ、複数の入力値や条件の組み合わせを整理しテスト項目を設計するテスト技法です。
    複数条件の組み合わせを網羅的に検証する際のテスト項目の設計に有効です。
  • ユーザーストーリーテスト(シナリオテスト)
    実際にシステムを使うユーザーのフローを再現しシステムの動作を確認するテスト技法です。
    E2E テストなどテストピラミッドの上位のテストレベルにおいて効果的なテスト項目を設計するにのに有効です。

これらのテスト技法を組み合わせて活用することで、テストレベルに応じたテストケースの設計をスムーズに進めることができます。

テストカバレッジの計測と指標の理解

テストの品質を評価するためにテストカバレッジの計測が用いられることが多いです。
テストカバレッジは、テストケースがテスト対象のプロダクションコード上でどの程度実行されたかを示す指標です。
しかし単にカバレッジといっても計測方法にはいくつかの種類があり、それぞれ異なる視点からコードの網羅性を評価します。
ここでは一般的に使用される C0、C1、C2 と呼ばれるカバレッジの計測方法について紹介します。

  • C0: ステートメントカバレッジ
    プログラム内のステートメント(命令)が少なくとも 1 回実行されたかを計測します。
  • C1: ブランチカバレッジ
    プログラム内の条件分岐の各分岐が少なくとも 1 回実行されたかを計測します。
  • C2: 条件カバレッジ
    プログラム内の条件式内の個々の条件が True/False の両方を少なくとも 1 回満たしたかを計測します。

テストカバレッジを導入する際には、まずは C1 のブランチカバレッジの指標を採用することがおすすめします。
C1 のブランチカバレッジは多くのテスティングライブラリでサポートされている計測が簡単です。
また分岐漏れに起因するバグを発見しやすい指標であり、C0 よりも効果的で、C2 よりも現実的なコストで導入が可能です。

またカバレッジを計測し運用するに当たっては、数値目標に依存しないことが重要です。
テストカバレッジの値を単なる数字目標として設定されると、見せかけのカバレッジ向上のために C0 の指標を採用するなどのハックが行われてしまうことがあります。

重要なのは指標を理解した上でカバレッジを計測し、計測時点からカバレッジを下げないように開発を進めることです。
計測開始からカバレッジが下がる場合、新機能を追加する際にテストが不足していたり修正やリファクタリングによって既存のテストが削除されていることを意味するからです。
カバレッジの不足部分から追加すべきテストケースの議論材料として活用しましょう。

まとめ

本記事では、テスタビリティを低くしてしまう事例をベースにテスタビリティを向上させるための方法を紹介しました。
そして実際の開発プロセスにおいて、それらの方法が価値を発揮するための要素を取り上げました。
チーム全体でこれらのポイントを意識することで、テスタビリティを意識した開発を進められるのではないでしょうか。

本記事で紹介した内容によって、より堅牢で信頼性の高い開発活動への一助となれば幸いです。


私たちは一緒に働いてくれる仲間を募集しています!

株式会社 電通総研 新卒採用サイト
電通総研グループ キャリア採用サイト:電通総研

執筆:@yamada.y
Shodoで執筆されました