段階的なプロパティベースドテスト
以下の文章はこちらの記事を翻訳したものです。
多くの人が通常のユニットテストの書き方には慣れている一方、プロパティベースドテストに初めて取り組む際には、少々不安を感じることがあるかもしれません。この記事では、二人の一般的なプログラマーがどのようにして標準的なPythonユニットテストからスタートし、段階的にプロパティベーステストに移行していったかを紹介します。このプロセスを通じて、彼らは多くのメリットを実感することができました。
背景
私は以前、gitのインターフェースに似せたコマンドラインツールtlr
の開発に携わっていました。このツールにはリポジトリがあり、その中でブランチを作成したり切り替えたりする機能が備わっていました。
-
ブランチリストの表示:
$ tlr branch foo * master
-
既存のブランチへの切り替え:
$ tlr checkout foo * foo master
-
新しいブランチの作成と切り替え:
$ tlr checkout -b new-branch $ tlr branch foo master * new-branch
開発の初期段階で、私と同僚はバグを発見しました。checkout -b
コマンドで新しいブランチを作成した際に、自動的にそのブランチに切り替わらないという問題でした。具体的には以下のような挙動を示していました:
$ tlr checkout -b new-branch
$ tlr branch
foo
* master
new-branch
この状況では、新しく作成されたブランチ(new-branch
)ではなく、以前のアクティブブランチ(master
)が引き続きアクティブな状態でした。
バグを修正する前に、私たちはまずテストを書くことにしました。Hypothesisを使い始める絶好の機会だと考えたからです。
基本的なテストの実装
私の同僚はHypothesisにあまり詳しくなかったので、まずは標準的なPythonユニットテストからスタートしました。
def test_checkout_new_branch(self):
"""Checking out a new branch makes it the current active branch."""
tmpdir = FilePath(self.mktemp())
tmpdir.makedirs()
repo = Repository.initialize(tmpdir.path)
repo.checkout("new-branch", create=True)
self.assertEqual("new-branch", repo.get_active_branch())
このテストで最初に気づいたのは、"new-branch"
という文字列が実際にはテストの本質には関係がないということです。この文字列は単にバグが存在するコードを実行するために選ばれた値に過ぎませんでした。理論上、テストは任意の有効なブランチ名で成功するはずです。
Hypothesisを導入する前にも、ブランチ名をテストのパラメータとして扱うように変更することで、任意のブランチ名でテストが通過すべきであることをより明確にしました。
def test_checkout_new_branch(self, branch_name="new-branch"):
tmpdir = FilePath(self.mktemp())
tmpdir.makedirs()
repo = Repository.initialize(tmpdir.path)
repo.checkout(branch_name, create=True)
self.assertEqual(branch_name, repo.get_active_branch())
私たちは決して手動でbranch_name
パラメータを提供しませんでしたが、この変更により、ブランチ名に関係なくテストが通過するべきであることがより明確になりました。
(簡潔化のため、残りのコード例からはdocstringを省略します)
Hypothesisの導入
パラメータが設定された後、次のステップとしてHypothesisを用いてパラメータを動的に提供しました。まず、Hypothesisの主要な機能をインポートしました。
from hypothesis import given, strategies as st
その上で、テストに簡単な変更を加えてHypothesisを活用しました。
@given(branch_name=st.just("new-branch"))
def test_checkout_new_branch(self, branch_name):
tmpdir = FilePath(self.mktemp())
tmpdir.makedirs()
repo = Repository.initialize(tmpdir.path)
repo.checkout(branch_name, create=True)
self.assertEqual(branch_name, repo.get_active_branch())
ここでは、ブランチ名をデフォルトの引数値として提供する代わりに、Hypothesisのjust("new-branch")
ストラテジーを使用しています。この方法では、常に同じ"new-branch"
が生成されるため、実質的には以前のテストと変わりません。
しかし、実際にテストしたいのは、任意の有効なブランチ名で機能するかどうかでした。任意の有効なブランチ名を生成する方法をまだ知らなかったため、私たちは次のような手法を取りました。
def valid_branch_names():
"""Hypothesis strategy to generate arbitrary valid branch names."""
# TODO: Improve this strategy.
return st.just("new-branch")
@given(branch_name=valid_branch_names())
def test_checkout_new_branch(self, branch_name):
tmpdir = FilePath(self.mktemp())
tmpdir.makedirs()
repo = Repository.initialize(tmpdir.path)
repo.checkout(branch_name, create=True)
self.assertEqual(branch_name, repo.get_active_branch())
この段階で止まっても、既存のテストに比べると改善されていました。Hypothesisを使用したテストは、従来のテストよりも追加の力を持たないものの、テストの意図が明確になり、valid_branch_names()
ストラテジーを将来のテストに再利用できるようになりました。
ストラテジーの拡張
Hypothesisにデータ生成を任せると、そのバグ発見の能力を本格的に活用できます。最初に試みたのは以下のような方法でした。
def valid_branch_names():
return st.text()
しかし、これは失敗に終わりました。ブランチ名はディスク上のシンボリックリンクとして実装されていたため、有効なブランチ名は、テストが実行されているファイルシステム上で有効なファイル名でなければなりませんでした。これは、空の名前や"."
、".."
、非常に長い名前、スラッシュが含まれる名前など多くの制限があることを意味していました。(実際、ものすごく複雑です。)
Hypothesisは私たちに明確に示してくれました:私たちも、実際に有効なブランチ名が何であるべきかを知りませんでした。どのインターフェースもこれを文書化しておらず、バリデータもなく、表示や表示方法についての明確なアイデアもありませんでした。私たちは、人々が適切で普通の名前を選ぶだろうと仮定していただけでした。
これは、適切な関数を呼び出すことで、実世界のエンドユーザーテストの利点を突然得たようなものでした。これは一方では素晴らしいことでしたが、一方では困ったことでした。なぜなら、すぐにこのバグを修正する意図はなかったからです。
結局、私たちは妥協し、期待するような良い、普通の、賢明なブランチ名をシミュレートするために、比較的保守的な戦略を採用しました。
from string import ascii_lowercase
VALID_BRANCH_CHARS = ascii_lowercase + "_-."
def valid_branch_names():
# TODO: Handle unicode / weird branch names by rejecting them early, raising nice errors
# TODO: How do we handle case-insensitive file systems?
return st.text(alphabet=VALID_BRANCH_CHARS, min_size=1, max_size=112)
理想的ではありませんが、単に"new-branch"
とハードコーディングするよりはるかに広範囲で、意図の明確なコミュニケーションだと言えるでしょう。
エッジケースの追加
私たちが開発した戦略では、master
のような特定のブランチ名が生成される可能性が低いにもかかわらず、実際には有効なブランチ名でした。もしテストをそのままにしておけば、ごく稀に"master"
が生成され、テストが失敗する可能性がありました。
このような偶然に頼るのではなく、valid_branch_names
戦略にmaster
を積極的に含めることにしました。
def valid_branch_names():
return st.text(alphabet=letters, min_size=1, max_size=112).map(
lambda t: t.lower()
) | st.just("master")
この変更により、master
ブランチが既に存在するためにテストが失敗するという問題が発生しました。これを解決するために、Hypothesisのassume
関数を使用しました。
from hypothesis import assume
@given(branch_name=valid_branch_names())
def test_checkout_new_branch(self, branch_name):
assume(branch_name != "master")
tmpdir = FilePath(self.mktemp())
tmpdir.makedirs()
repo = Repository.initialize(tmpdir.path)
repo.checkout(branch_name, create=True)
self.assertEqual(branch_name, repo.get_active_branch())
master
を有効なブランチ名に含めつつ、実際にはその使用を避ける理由は、他のテストがこのブランチ名を適切に扱えるかどうかを判断するためです。将来のテスト作成者は、master
ブランチをどのように扱うべきかを慎重に考慮する必要があります。これはHypothesisの大きな利点の一つであり、厳格なデザイン批評家のような役割を果たします。
今後の展望
ここでの作業は一旦終了しましたが、テストの発展はまだ可能でした。テストは特定のブランチに限らず、任意のリポジトリに対しても適用可能であるべきです。これまでは便宜上、空のリポジトリを使用していましたが、次のステップとしては、さまざまな内容やコミット履歴、既存のブランチを持つリポジトリを生成するrepositories()
関数の開発が考えられます。そのようなテストは以下のようになるでしょう。
@given(repo=repositories(), branch_name=valid_branch_names())
def test_checkout_new_branch(self, repo, branch_name):
"""
Checking out a new branch results in it being the current active
branch.
"""
assume(branch_name not in repo.get_branches())
repo.checkout(branch_name, create=True)
self.assertEqual(branch_name, repo.get_active_branch())
このアプローチは、コンピュータサイエンスの直接的な問題ではなく、コードが持つ「プロパティ」に焦点を当てています。存在しないブランチを作成し、切り替えることで、新しいアクティブブランチが新しく作成されたブランチになるという性質です。
私たちは、PythonとHypothesisの機能を段階的に利用することで、ソフトウェアの特性についてより深く考え、強力なテストを作成することができました。途中で多くのバグを発見し、抑制し、すべての将来のテストをより強力にする基盤を築きました。これは確かな勝利と言えるでしょう。