はじめに
この記事の内容は『単体テストの考え方/使い方』(Vladimir Khorikov 著)を自分なりに集約したものです。
重要な部分を見直しやすいよう細かい部分は飛ばして書いているので、詳細な説明や補足が気になる方は書籍を手に取ってご確認いただけますと幸いです。
目次
-
テスト・ダブルの利用
1-1. テスト・ダブルの分類 -
単体テストの手法
2-1. 出力値ベース・テスト
2-2. 状態ベース・テスト
2-2. コミュニケーション・ベース・テスト
2-2. 単体テスト手法の比較 -
単体テスト手法の変換
2-1. 出力値ベース・テストと関数型アーキテクチャ
2-2. 出力値ベース・テストへの移行
テスト・ダブルの利用
テスト・ダブルとは、テスト対象システムとその協力者オブジェクトの依存関係を置換するためのテストコードの総称です。
テスト・ダブルはプロダクションコードには含まれずテストにのみ使用されます。
テスト・ダブルの分類
テスト・ダブルはモックとスタブに大別でき、最終的には次の5種類に分けれます。
-
モック:テスト対象システムから外部への出力を模倣するテスト・ダブル
- モック ... フレームワークの力を借りて作るモック
- スパイ ... 手書きで作るモック
※ モックはテストにおいて「模倣」+「検証」の役割を担います
-
スタブ ... 外部からテスト対象システムへの入力を模倣するテスト・ダブル
- スタブ ... 状況に応じて異なる値を入力する
- ダミー ... nullや文字列などのハードコーディングした値を入力する
- フェイク ... まだ存在しない依存を置き換える
※ スタブはテストにおいて「模倣」のみの役割を担います
補足
モックは模倣+検証の対象になりますが、スタブは検証の対象にしてはいけません。なぜなら、スタブはテスト対象システムが最終的に生み出す結果ではなく、その結果を生み出すための過程(実装の一部)だからです。繰り返しになりますが、価値のあるテストは次の特徴をすべて備えていなければなりません。
- 退行からの保護
- リファクタリングの耐性
- 迅速なフィードバック
- 保守のしやすさ
上記の特徴のうち、スタブを検証対象にしてはいけない理由は「リファクタリングの耐性」に関係しています。
前回までの記事でリファクタリングの耐性を損なう原因はテストが振る舞いではなく実装を検証することにあると説明しました。スタブを検証対象にするということはまさにこのリファクタリングの耐性を損なう行為に該当するので、スタブはいかなる場合も検証対象にしてはなりません。スタブがモックの役割を持つ場合を除き、スタブの入力を検証するのはアンチパターンです。
<モックを使ったテストのサンプル>
from unittest.mock import Mock def test_purchase_fails_when_not_enough_inventory(): store = Mock(Store) # モックを作成 store.has_enough_inventory.return_value = False # ※1 sut = Customer() success = sut.purchase(store, "shampoo", 15) assert success is False assert store.remove_inventory.assert_not_called() is None # ※2
※1 稀にモックはスタブの役割を兼ねることがあります
※2 モックがスタブの役割も持つ場合、スタブの機能(テスト対象システムへの入力)は検証しません
単体テストの手法
単体テストの手法には次のものがあります。
- 出力値ベース・テスト(戻り値を確認するテスト)
- 状態ベース・テスト(状態を確認するテスト)
- コミュニケーション・ベース・テスト(オブジェクト間のやり取りを確認するテスト)
これら単体テストの手法は1つのテスト・ケースに対して1つだけ適用させることも、3つすべてに適用させることもあります。
出力値ベース・テスト
出力値ベース・テストとは、テスト対象オブジェクトに入力値を渡して返ってきた結果を検証するテストです。
出力値ベース・テストが適用できるのは入出力の前後でテスト対象システムと協力者オブジェクトの状態が変わらない場合だけです。
出力値ベース・テストは3つのテスト手法の中で最も費用対効果が高いテストケースを作成できます。
<出力値ベース・テストのサンプル>
def calculate_discount(product_list):
discount = len(product_list) * 0.01
return min(discount, 0.2)
def test_discount_of_two_products():
product_list = ["hand wash", "shampoo"]
discount = calculate_discount(product_list)
assert discount == 0.02
状態ベース・テスト
状態ベース・テストとは検証したい処理が終わった後にテスト対象システムや協力者オブジェクト、その他の依存関係の状態を検証するテストです。
<状態ベース・テストのサンプル>
class Order:
def __init__(self, item_list = []) -> None:
self.__item_list = []
def add_item(self, item):
self.__item_list.append(item)
def get_item_list(self):
return self.__item_list
def test__adding_an_item_to_an_order():
sut = Order()
sut.add_item("hand wash")
assert 1 == len(sut.get_item_list())
assert sut.get_item_list()[0] == "hand wash"
コミュニケーション・ベース・テスト
コミュニケーション・ベース・テストとは、モックを使ってテスト対象システムとその協力者オブジェクトの間のコミュニケーションを検証するテストです。
<コミュニケーション・ベース・テストのサンプル>
class EmailGateway:
def send_email(self, recipient, subject, body):
pass
class Controller:
def __init__(self, email_gateway) -> None:
self.__email_gateway = email_gateway
def greet_user(self, recipient, subject, body):
self.__email_gateway.send_email(recipient, subject, body)
from unittest.mock import Mock
def test__sending_a_greetings_email():
recipient = "someone@sample.com"
subject = "test"
body = "hello"
email_gateway_mock = Mock(spec=EmailGateway)
sut = Controller(email_gateway_mock)
sut.greet_user(recipient, subject, body)
email_gateway_mock.send_email.assert_called_once_with(recipient, subject, body)
単体テスト手法の比較
単体テストの手法として紹介した3つの検証方法は価値あるテストの4本柱との関係を評価すると次のよになります。
価値あるテストの4本柱 | 出力値ベース | 状態ベース | コミュニケーション・ベース |
---|---|---|---|
退行からの保護 | ◎ | ◎ | 〇 |
リファクタリングの耐性 | ◎ | 〇 | △ |
迅速なフィードバック | ◎ | ◎ | 〇 |
保守のしやすさ | ◎ | 〇 | △ |
補足:退行からの保護との関係
退行からの保護がどれくらい備わっているかはプロダクションコードが持つ次の特性に影響を受けます。
- コード量
- コードの複雑さ
- ドメインにおける重要さ
上記の特性はプロダクションコード自体が持つ特性なので、テスト手法の選択が退行からの保護に影響を与えることはほとんどありません。
例外として、コミュニケーション・ベース・テストは多用しすぎると何もかもがモックに置き換わった浅いテストになってしまい、退行からの保護で目指すべき「より多くのプロダクションコードを検証する」という部分に影響を与えることがありますが、この問題はコミュニケーション・ベース・テストそのものが招くものではなく、開発者が過度にモックを使うことで生じる特殊な問題です。
補足:リファクタリングの耐性の関係
テストがプロダクションコードの実装に結び付しまうと、リファクタリングを実施したときにテスト対象システムが実際には正しい振る舞いをしていても実装の違いを検知して不要な警告が流れる場合があります。これは偽陽性(嘘の警告)と呼ばれる性質で、リファクタリングの耐性とはこの偽陽性を抑える性質を指すものでした。
単体テスト手法との関係で言うと、この偽陽性を最も抑えられるのは出力値ベース・テストです。出力値ベース・テストはテスト対象システムの出力だけに注目するため、テスト対象システム自体が実装の詳細でない限り、テストが実装に結び付くことはありません。そのため、出力値ベース・テストは原則リファクタリングの耐性には影響を与えないと考えていいでしょう。
状態ベース・テストはテスト対象システムなどの状態を確認することがあるので、出力値ベース・テストと比べるとテストがプロダクションコードと結び付きやすく、偽陽性をが発生しやすいテスト手法です。
また、コミュニケーション・ベース・テストは単体テストの3手法のうち最も偽陽性に対し脆弱です。コミュニケーション・ベース・テストのほとんどはテスト・ダブルを使って行いますが、そのテスト・ダブルが純粋なスタブだった場合、スタブを検証するテストは実装と結び付くことになり必ず壊れます。
単体テストを実践して行く中では状態ベース・テストやコミュニケーション・ベース・テストを選択しなければならない場合もありますが、そのような場合は適切なカプセル化を施すなどのプロダクションコードのリファクタリングも検討しましょう。
補足:迅速なフィードバックとの関係
テストコードがプロセス外依存を使わない限り、つまりテストが単体テストの領域に留まっている限り、単体テストの手法がテストの実行速度に影響を与えることはほとんどありません。
例外として、コミュニケーション・ベース・テストはモックを構築する負荷がかかるため若干速度が落ちることがありますが、テスト・ケースを何万件も用意しない限りその差は無視できるレベルです。
補足:保守のしやすさとの関係
価値のあるテストは保守しやすいものである必要があり、保守のしやすは次の2点から評価できます。- テストケースのコード量
- プロセス外依存の数
各単体テストの手法を保守のしやすさで考えた場合、最も保守がしやすいのは出力値ベース・テストです。
出力値ベース・テストはテスト対象システムに渡す入力値の準備と出力値の取得、出力値の検証の3つだけを行えばいいので、普通そのテストケースは単純で短いコードになります。また、出力値ベース・テストはテスト対象システムや協力者オブジェクトの状態が変わらないことを前提にしているため、プロセス外依存の数は0と考えていいです。
続いて2番目に保守がしやすいのは状態ベース・テストです。
まず、状態ベース・テストはプロセス外依存を使う可能性があり、プロセス外依存の扱いに保守の工数を取られます。また、状態ベース・テストは出力値ベース・テストと同様の3ステップ(テスト対象システムに渡す入力値の準備と実行、状態の検証)で構成できますが、一般的に出力値の検証より状態の検証の方が確認フェーズのコードが長くなります。
def test__adding_a_comment_to_an_article():
sut = Article()
author = "John Doe"
content = "hoge huga"
sut.add_comment(author, content)
assert sut.get_comment_count() == 1
assert sut.get_comment_list()[0].author == author
assert sut.get_comment_list()[0].content == content
📝ヘルパーの利用
確認フェーズのテストコードはヘルパー関数を使うことで軽量化できます。しかし、そうするとヘルパー関数を作って保守するコストもかかることになるので、やはり保守のしやすさは出力値ベース・テストより下がってしまいます。
def test__adding_a_comment_to_an_article():
sut = Article()
author = "John Doe"
content = "hoge huga"
sut.add_comment(author, content)
assert sut.check_comment(1, author, content)
📝値オブジェクトの利用
確認フェーズのテストコードは確認対象を値オブジェクトにまとめることで軽量化することができます。しかし、この方法は確認対象を値オブジェクトに変換できる場合にしか適用できません。確認対象を値オブジェクトに変換できるようにプロダクションコードを改変することは「プロダクションコードの汚染」と呼ばれ、単体テストのアンチパターンとして知られています。
class Comment:
def __init__(self, author, content):
self.author = author
self.content = content
def __eq__(self, other):
if isinstance(other, Comment):
return self.author == other.author and self.content == other.content
return False
def test__adding_a_comment_to_an_article():
sut = Article()
comment = Comment("John Doe", "hoge huga")
sut.add_comment(comment)
assert sut.get_comment_count() == 1
assert sut.get_comment_list()[0] == comment
最後にコミュニケーション・ベース・テストの保守のしやすさについてですが、これは他の2つの単体テスト手法より劣ることになります。なぜなら、コミュニケーション・ベース・テストはテスト・ダブルを用意してテスト対象システムとのコミュニケーションを確認できるようにしなくてはならないからです。テスト・ダブルを用意するにはテストケースに多くのコードを記述する必要がありますし、モックの連鎖が発生すると更に保守が難しくなる場合があります。
用語📝モックの連鎖
モックの連鎖とは、モックやスタブのなかで他のモックやスタブの呼び出しが連鎖することです。モックの連鎖は依存先のオブジェクトがさらに別のオブジェクトに依存していることで発生するのですが、このような連鎖は避けようがないのでテスト・ダブルの利用が避けられないコミュニケーション・ベース・テストはどうしても保守コストが高くなってしまいます。単体テスト手法の変換
3つの単体テストの手法の中で最も価値あるテストを作成しやすいのは出力値ベース・テストだと分かりました。そして、そう考えると単体テストは出来る限り出力値ベース・テストで進めていきたいところです。そこでこの記事では最後に単体テスト手法を出力値ベース・テストに変換できる場合と変換する方法を紹介して行きます。
出力値ベース・テストと関数型アーキテクチャ
関数型アーキテクチャとは、数学的関数や純粋関数と呼ばれる副作用のない関数を中心に構築されたアーキテクチャで、この「副作用がない」という性質は出力値ベース・テストの実施要件である「テスト対象システムと協力者オブジェクトの状態が変わらないこと」に合致します。したがって、もしテスト対象システムを関数型アーキテクチャに移行させることができれば、そのシステムは出力値ベース・テストで検証できるようになります。
用語📝関数型アーキテクチャ
関数型アーキテクチャは、「決定を下すコード(関数的核または不変核)」と「決定に基づき副作用を発生させるコード(可変核)」の2つで構成しようとする関数型プログラミングにより構築されたアーキテクチャです。
関数型プログラミングにおける関数的核または不変核(以下「数学的関数」)は、数学の関数のように最初の入力と最後の出力だけ考えればいいように作られており、関数を呼び出してもシステムに影響を与えることはありませんし、実行結果はシステムの影響を受けることもありません。
数学的関数はそのような特性を実現するため、次のような隠れた入出力を排除しています。
-
副作用>
他のオブジェクトやディスク上のファイルを更新する処理など、シグネチャには表現されていない隠れた出力 -
依存>
システム時計やDBのデータ、プライベートな可変フィールドなど、シグネチャには表現されない隠れた入力 -
例外>
例外はシグネチャには表現できない隠れた出力であり、呼び出し元への隠れた入力でもあります📝純粋な関数かを見分けるには
ある関数が純粋な数学的関数(関数的核または不変核)なのか否かを判断するテクニックとして、その関数を呼び出している部分を実際の値に置き換え、プログラムの振る舞いが変わるかを検証するという方法があります。# 数学的関数の例 def increment(x): return x + 1; y = increment(4) # 5 で置換できる # 数学的関数ではない例 x = 0 def increment() x += 1 y = increment() # 値では置換できない
ちなみに、このように振る舞いを変えることなく関数の呼び出し部分を値に置き換えられる能力は参照透過性と呼ばれています。
しかしながら、実用的なアプリケーションを作るには隠れた入出力を捨てることはできません。そこで、関数型プログラミングでは隠れた入出力を可変核と呼ばれる関数に託し、数学的関数を「決定を下すコード」と位置付けます。
関数型アーキテクチャが目指すものは副作用などの隠れた入出力を完全に除くことではなく、ビジネスロジックを扱うコードと副作用を起こすコードを分離することです。そして、関数型アーキテクチャはこれを満たすために隠れた入出力をビジネスオペレーションの最初と最後にまとめています。
- 可変核は関数的核に渡すすべての入力を集める
- 関数的核は入力値をもとに決定を下す
- 可変核は決定に基づいて副作用を発生させる
出力値ベース・テストへの移行
単体テスト手法を出力値ベース・テストに移行するには次は次の通りです
- プロセス外依存を抜き出す
- 関数型アーキテクチャに移行する
- コントローラーを追加する
以下、ファイルシステムを扱った事例をもとに単体テスト手法を移行させる手順を見て行きましょう。
事例:
訪問者の名前と訪問時間をテキストファイルに記録するシステムを考えます。このシステムでは1ファイルの訪問記録数に上限を設けており、訪問記録数が一定の件数を超え場合は下のように新たなインデックス番号を付けたファイルを作成して記録を続行します。
audit_01.txt
Peter; 2024-04-06 16:30:00
Jane; 2024-04-06 16:40:
Jack; 2024-04-06 17:00:00
audit_01.txt
Mary; 2024-04-06 17:30:00
また、テスト計画当初のプロダクションコードは以下のようになっています。
sample.py(テスト計画当初のプロダクションコード)
import os
class AuditManager:
def __init__(self, limit, path) -> None:
self.__max_entiries_per_file = limit
self.__work_directory = path
def add_record(self, visitor_name, timestamp):
file_name_list = os.listdir(self.__work_directory)
file_name_list = sorted(file_name_list)
new_record = f"{visitor_name}; {timestamp}\n"
if len(file_name_list) == 0:
file_path = f"{self.__work_directory}/audit_1.txt"
with open(file_path, "a") as f:
f.write(new_record)
return
file_path = f"{self.__work_directory}/audit_{len(file_name_list)}.txt"
with open(file_path, "r") as f:
content = f.read()
record_count = len(content.strip().split("\n"))
if record_count < self.__max_entiries_per_file:
with open(file_path, "a") as f:
f.write(new_record)
else:
file_path = f"{self.__work_directory}/audit_{len(file_name_list) + 1}.txt"
with open(file_path, "a") as f:
f.write(new_record)
-
プロセス外依存を抜き出す
テスト計画当初のsample.pyはプロダクションコード内にプロセス外依存が入り込んでおり、迅速なフィードバックを得るのが困難な状態です。まずはプロセス外依存を別クラスや関数として抜き出し、モックが利用できる状態を作りましょう。
sample.py(プロセス外依存を抜き出したプロダクションコード)
import os class AuditFileIO: def __init__(self, directory) -> None: self.__work_directory = directory def get_file_list(self): file_name_list = os.listdir(self.__work_directory) file_name_list = sorted(file_name_list) return file_name_list def get_file_content(self, file_name): with open(f"{self.__work_directory}/{file_name}", "r") as f: return f.read() def add_record(self, file_name, record): with open(f"{self.__work_directory}/{file_name}", "a") as f: f.write(record) class AuditManager: def __init__(self, limit, io) -> None: self.__max_entiries_per_file = limit self.__file_io = io def add_record(self, visitor_name, timestamp): file_name_list = self.__file_io.get_file_list() new_record = f"{visitor_name}; {timestamp}\n" if len(file_name_list) == 0: file_name = f"/audit_1.txt" self.__file_io.add_record(file_name, new_record) file_name = f"audit_{len(file_name_list)}.txt" content = self.__file_io.get_file_content(file_name) record_count = content.strip().split("\n") if record_count < self.__max_entiries_per_file: self.__file_io.add_record(file_name, new_record) else: file_name = f"audit_{len(file_name_list) + 1}.txt" self.__file_io.add_record(file_name, new_record)
-
関数型アーキテクチャに移行する
隠れた入出力を可変核(Persister)にまとめ、AuditManagerを関数的核に移行させていきます
sample.py(関数型アーキテクチャに移行されたプロダクションコード)
import os class AuditFileIO: def __init__(self, directory) -> None: self.__work_directory = directory def get_file_list(self): file_name_list = os.listdir(self.__work_directory) file_name_list = sorted(file_name_list) return file_name_list def get_file_content(self, file_name): with open(f"{self.__work_directory}/{file_name}", "r") as f: return f.read() def add_record(self, file_name, record): with open(f"{self.__work_directory}/{file_name}", "a") as f: f.write(record) class FileContent: """AuditManagerへの入力を表現するクラス """ def __init__(self, file_name, content) -> None: self.file_name = file_name self.content = content class FileUpdateOrder: """AuditManagerの出力(判断)を表現するクラス """ def __init__(self, file_name, new_record) -> None: self.file_name = file_name self.new_record = new_record class Persister: """AuditManagerの入出力を処理するクラス """ def __init__(self, work_directory) -> None: self.__file_io = AuditFileIO(work_directory) def read_directory(self): file_content_list = [] for file in self.__file_io.get_file_list(): file_content = FileContent(file, self.__file_io.get_file_content(file)) file_content_list.append(file_content) return file_content_list def apply_update(self, update_order): self.__file_io.add_record(update_order.file_name, update_order.new_record) class AuditManager: def __init__(self, limit) -> None: self.__max_entiries_per_file = limit def add_record( self, visitor_name, timestamp, file_content_list ): new_record = f"{visitor_name}; {timestamp}\n" if len(file_content_list) == 0: file_name = f"audit_1.txt" return FileUpdateOrder(file_name, new_record) current_file_content = file_content_list[-1].content current_file_record_count = current_file_content.strip().split("\n") if current_file_record_count < self.__max_entiries_per_file: file_name = f"audit_{len(file_content_list)}.txt" return FileUpdateOrder(file_name, new_record) else: file_name = f"audit_{len(file_content_list) + 1}.txt" return FileUpdateOrder(file_name, new_record)
◎ ここでテスト対象システム(AuditManager)は入力(FileContent)を与えて出力(FileUpdate)を検証するだけでテスト可能になりました
-
コントローラーを追加する
関数型アーキテクチャへの移行により、テスト対象システムは出力値ベース・テストでテストできるようになりましたが、関数型アーキテクチャは関数的核と可変核が完全に分離されているためそのままでは動きません。
プロダクションコードを関数型アーキテクチャに移行させた場合は最後に関数的核と可変核の連携を指揮するコントローラーを定義しましょう。
sample.py(コントローラー部分)
(中略) class AuditController: def __init__(self): work_directory = "../work/" max_entries_per_file = 3 self.__persister = Persister(work_directory) self.__audit_manager = AuditManager(max_entries_per_file) def add_record(self, visitor_name, timestamp): # AuditManagerへの入力をまとめる file_content_list = self.__persister.read_directory() # AuditMangerの出力を取得する file_update = self.__audit_manager.add_record(visitor_name, timestamp, file_content_list) # AuditMangerの出力に基づき副作用を起こす self.__persister.apply_update(file_update)