テストコードに入門してみる
投稿日: 2022年3月28日 更新日: 2022年3月28日
カテゴリ: UnitTest
概要
テストコードへ入門するときに知っておくと良かったな、と思った知識を整理してみます。
ゴール
入門レベルのテストコードを書くためにどのようなことを考えればよいのか、大まかにでもイメージできるようになることを目指します。
用語整理
- テストコード: 実装コードの動作を検証するためのコード
- テスティングフレームワーク: ライブラリの形で提供されることが多い、テストコードを一定の枠組みで記述するためのもの
- assertion: 表明・主張の意味を持つ assertionが正しいかをもって、テストコードの成否が決められる
目次
前置き: テストコードへ入門するには
テストコードを書きたいと思ったとき、「どうすれば普段書いているコードをテストできるのかまったくイメージできない」壁にぶち当たりました。
簡単なコードから始めて地道に積み上げていけば、なんとなく大枠も見えてきますが、手探りになるので大きく時間が掛かってしまいます。
そこで、本記事ではテストコードの最初の一歩を踏み出すのに知っておいた方が良かったな、と思うことを備忘録がてらまとめておきます。より具体的には、以下のことを整理してみます。
- どんな実装コードであればテストコードが書きやすくなるのか
- テストコードの基本構造はどのようなものか
- 実装コードの動きを確かめるのに重要なassertionとは何か
それぞれ詳しく見ていきましょう。
テストしやすいコードとは
まずはテストコードの前段階として、実装コードをどのように書けばテストしやすくなるのか考えることにします。意識しておいた方が良いことはたくさんありますが、ここでは基本として、次の2つを挙げておきます。
- テストしたい処理は関数で書いておき、出力として欲しいもの・出力を得るのに必要な入力(引数)を中心に考える
- 処理の固まりは、やりたいことの説明が一文でおさまる程度の大きさに
それぞれをもう少し掘り下げてみます。
テストコードの基本は関数で
まずはテストコードの基本単位を押さえておきます。テストコードと聞いただけでは何をやるのかイメージしづらいですが、やっていることの多くは想定した入力から期待した出力が得られるか
確かめることです。
これだけではなんだが難しそうなので、実際に関数とテストコードの例を見てみましょう。
# 関数定義
# 引数は年齢として妥当か、出力のbool値で判定
def is_valid_age(age: int) -> bool:
# 年齢は0歳以上のみ許容
if age < 0:
return False
return True
# 関数定義
# テストコードのイメージ
def test_is_valid_age():
# 変数代入
# 期待している出力
expected = True
# 年齢
age = 24
# 想定した入力から得られる出力
actual = is_valid_age(age)
# 出力が期待結果と等しいか
assert actual == expected
# assert文は以下のようなイメージ
if actual == expected:
print('TEST PASSED!!')
return
raise Exception('TEST FAILED...')
構造やassert文は後ほど触れていくので、今はテストコードではコメントにあるようなものを書いていくんだな、ということを頭に置いておいてください。
こうして眺めてみると、なんだか単純なことをしているように見えます。ですが、テストコードのことを意識せずに書かれたコードは、処理が複雑になってくると、すぐにこのような構造が崩れてしまいます。
例えば、関数の中で巨大なクラスのインスタンスをつくっていたり、関数の外側のオブジェクトを書き換えていたりといった具合です。
関数の入出力でコードが完結していないと、入力から期待した出力が得られる
条件にさまざまな前提が加わってしまい、テストコードが書きにくく不安定になってしまいます。
そうならないように、小さな処理のうちから、やりたいことを入出力で完結する関数で表現できないか考えることが重要となります。
入力と出力のみに依存
少し話が難しくなってきたので、別の例も見てみましょう。今度は、生年月日から年齢を計算してみます。
import datetime
# 関数定義
# 誕生日・現在日付を入力に、年齢を出力
def calculate_age(birthday: str, current_date: datetime.date) -> int:
# 年齢を算出
age: int = 年齢算出処理()
return age
# 関数定義
# テストしづらい例
def bad_calculate_age(birthday: str) -> int:
# 誕生日・現在日付から年齢を算出
current = datetime.datetime.now()
age: int = 年齢算出処理()
return age
# 関数定義
# テストコードのイメージ
def test_calculate_age():
# 期待している出力
expected = 30
# 2000年1月1日を基準日とする
current_date = datetime.date(2000, 1, 1)
# 想定した入力から得られる出力
actual = calculate_age('1970-01-01', current_date)
# 出力が期待結果と等しいか
assert actual == expected
注目すべきは、年齢を計算する関数が基準となる日付をどのように参照しているか、というところです。
テストしやすい例では、年齢を算出する処理で必要なものをすべて引数で定義しています。一方、いまいちな例では、関数の内側で基準となる日付を固定しています。
この違いは、テストコードに大きく表れます。基準となる日付を引数で受け取るようにしておけば、どれだけ現在の日付が変わっても、必ず入力から同じ出力を得ることができます。
一方、関数の中で現在日付を参照していると例えば、1月1日が誕生日の人の年齢を計算する処理を12月31日・1月1日それぞれに実行すると、結果が異なってしまいます。
つまり、テストコードの期待結果を固定することができず、日によってテストが通ったり通らなかったりする不安定なものになってしまいます。これではテストコードの信頼性が薄れ、実装コードの動作を担保したいという目的から遠ざかってしまいます。
このように、関数の入力・出力だけで完結する処理を書くようにしていけば、少しずつテストが書きやすくなっていくはずです。
処理の大きさ
クラスや関数など、なんらかの処理の固まりをつくるときは、なるべく処理が大きくなり過ぎないように意識することが重要です。ここで、テストコードの視点で考える処理の大きさは、やろうとしていることの数と対応しています。
例として、○×ゲームを表現するクラスを考えてみます。シンプルなゲームということで1つのクラスで書いてみると、以下のコードのようなイメージになります。
# クラス定義
class TicTacToeGame:
""" ○×ゲームを実行することを責務に持つ """
# メソッド定義
# ○×ゲームを表現
def game(self):
# 盤面を表示
# ユーザの入力を受付
# 勝敗判定
# 結果を表示
# 盤面を表示
# ユーザの入力を受付
...
このクラスは、○×ゲームを実行する
ことが処理の大きさと対応しています。こう書くと処理も小さくまとまっているように思えます。
ですが、○×ゲームで具体的にやりたいことを書き出してみると、
- 盤面を(一例として)文字列で表現
- 盤面を描画
- ユーザの入力を受け付ける
- ユーザの入力から盤面を更新
- 更新された盤面をもとに勝敗判定
などなど、たくさん出てきそうです。
このままテストコードを書こうとすると、勝敗判定の挙動を確かめようと思っていたのに盤面更新処理に問題があってうまくいかない、といったことが起こり得ます。
すなわち、テストコードがうまくいかなかったとき、テストしたい処理に問題があったのか、それ以前/以後で問題があったのか余計な原因の切り分けが必要となります。
ここで意識しておきたいのは、処理が大きすぎるとテストコードが書きづらくなるということです。
○×ゲームというシンプルなものでも、「初期描画・ユーザの入力を盤面へ反映・縦がそろってゲーム終了・ゲーム続行」など、さまざまな状態が考えられます。
これをメインの関数の出力結果ですべて表現しようと思うと、実装コードもテストコードも複雑になってしまうことが容易に想像できるはずです。
処理を分けてみる
処理が大きすぎるなら小さくすればいいじゃない、ということで1つのサンプルとして勝敗判定処理を更にクラスで分けてみます。具体的なイメージは次のようなコードです。
# クラス定義
class Settlement:
""" 勝敗を表現することを責務に持つ """
def is_settled(self, board: Board) -> bool:
"""
勝敗判定
:param board: 盤面を表現するオブジェクト
:return: 決着 -> True, 未決着 -> False
"""
...
...
勝敗判定のためのクラスは、盤面を入力にbool値で勝敗が決まったかを返してくれると便利そうです。より具体的には、
○ ○ ○
× ○ ×
× × ○
このような盤面を表現するオブジェクトが入力に与えられると、勝敗結果としてTrue
を出力できるようなイメージです。
これは、関数の例で見てきた一定の入力から決まった出力が得られる関数
の形をしています。
やりたいことを小さく切り出していき、入出力のみに依存する関数の形をつくりだすことで、処理が大きくなり過ぎずかつテストしやすいものとすることができます。
補足: プライベートメソッドへ切り出すこととクラスへ分けることの境界
処理をテストしやすい関数の単位に切り出す、ということを考えると別のクラスへ分けなくてもプライベートメソッドにした方がよいのでは、と思うこともあります。
これにはさまざまな意見があるので、私がテストコードを書くときに意識していることを補足する程度にとどめておきます。
結論から言うと、プライベートメソッドが複雑になってテストコードを書きたくなったら、別のクラスへ切り出すべき合図だと思った方がシンプルに考えられます。
クラスがやりたいことを1つに絞ると、おのずとパブリックメソッドはなんらかの入力から、やりたいことを実現するための出力を得る形に近づいていきます。そうすることで、クラスを使う/テストコードで呼び出すときはパブリックメソッドだけを見てクラスのやりたいことを理解できるようになるはずです。
一方、プライベートメソッドの処理が増えてくると、クラスが複雑になり、クラスを理解するのにプライベートメソッドも読み込むことになってしまいます。
クラスを複雑にしたままプライベート・パブリックメソッドをテストするよりも、パブリックメソッドだけ確かめればよい単位のシンプルなクラスに保つ方が実装コードもテストコードも読みやすく書きやすくなるはずです。
補足: テストしやすく書くことを意識するメリット
さて、テストコードを書くときの話だったはずなのに、ずいぶんと実装コードの話が多くなってしまいました。こうして見てみると、テストコードを導入するために実装コードの書き方を意識するのはいささか面倒なように思えます。
ですが、テストしやすいコードを心がけることは、実装コードにおいてテスト以外にもうれしい効果があります。
最も大きなところは、コードの良し悪しにテストコードが書きやすくなっているか
という指標を持つことができる点です。
いいコード・ちょっとイマイチなコードを判断する基準にはさまざまなものがあります。とくに、入門したてぐらいの時期では自分の書いているコードが良さそうなのかよろしくないのか迷いやすくなってしまいます。
そんなときにテストコードの書きやすさを1つの大きな指標として持っておけると、極端に道を踏み外すこともないはずです。
テストコードの枠組み
ここでは、テストコードを具体的にどのように書くのか見ていきます。テストコードを書くときにサポートしてくれるライブラリ、いわゆるテスティングフレームワークはプログラミング言語ごとに色々な形で提供されています。
こうして見ると言語が変わる度に1から勉強が必要に思えてなんだか大変そうです。しかし、偉大な先人が編み出した枠組みに従うことで、言語によらず一定のパターンに従ってテストコードが書けるようになります。
それは、GIVEN-WHEN-THENスタイル
と呼ばれます。
※ GIVEN-WHEN-THENスタイルはあくまでもテストコードを書くときの指標の一例です。より自分に合っている書き方を見出せたら、どんどん採用しちゃいしましょう。
GIVEN-WHEN-THEN
では、GIVEN-WHEN-THENスタイルでは具体的にどのようにテストコードを書くのか、見てみましょう。まずはそれぞれで書きたいことを大まかに列挙しておきます。
- GIVEN: テストコードに必要な入力を準備する「前提」を記述
- WHEN: 確かめたい「振る舞い」を記述
- THEN: 「期待結果」を記述
続いて、実際にテストコードへ適用してみた例を眺めてみます。
# 関数定義
# 年齢として妥当か確かめる処理
# テストコードのイメージ
def test_is_valid_age():
# GIVEN
# 期待している出力
expected = True
# WHEN
# 想定した入力から得られる出力
actual = is_valid_age(24)
# THEN
# 出力が期待結果と等しいか
assert actual == expected
テスティングフレームワークやGIVEN-WHEN-THENスタイルに従うと、テストコードを書くときに一定の「型」を見出せるようになります。
このようにテストコードの構造がざっくりとでもイメージできていれば、テストコードをどのように組み立てていけばよいか見えてくるはずです。
復習のために、GIVEN-WHEN-THENスタイルに則ってテストコードを書くときの流れを確認しておきます。
ある入力から一定の出力を返却する関数をテストしたいとき、まずは出力を得るための入力を定義しておきます。あわせて、入力が○○なら出力は××となるはず、という期待値を定義しておきます。
これは、GIVEN
に相当します。
続いて、関数本体を呼び出して実際の(actual)出力を得ます。
これは、WHEN
に相当します。
そして、実際の出力と期待結果を比較することでテストコードの妥当性を評価します。
これは、THEN
に相当します。
assertionとは
最後に、テストコードで重要な概念として、assertion
に触れておきます。
assertionは、言明や表明といった意味を持ち、強く主張することを表しています。Pythonではassert文・PHPUnitやJUnitではassert関数など、多少形式は異なりますが、テストコードの検証の要となる処理はassertionと呼ばれます。
それでは、なぜ検証することが言明や表明を意味するassertionと呼ばれるのでしょうか。
assertionでは、bool値で評価されるなんらかの条件式が記述されます。よくある例では、関数を呼び出して得られた出力が期待結果と等しいか、といったものでしょうか。
これを言明(assertion)として表現すると、関数で出力されたものは、期待結果と等しいのである
のようになります。言明が正しければ、検証対象も期待通りに動作していると言えそうです。
まとめると、テストコードでは、複数の言明の正しさを証明することで、実装コードの(assertion対象の範囲内における)正しさを担保することを目指しています。もう少し砕けた感じで言うなら、実装したコードは、テストコードのassertionで確かめた通りに動いてくれるはず...!!といった感じでしょうか。
まとめ
少し長くなりましたが、本記事ではテストコードへ入門する上で知っておくとよかったなと思ったことをまとめてきました。
テストコードは実装コードと比べると書籍や参考例も少なく、迷子になりやすいものなので、少しでも何かの参考になれば幸いです。
記事はGitHubでも公開しています。間違い・よりよい書き方などございましたらIssueやPRを頂けるとうれしいです。
Author:
a-pompom: