はじめに
初歩的な内容かもしれませんがテストコードを書く中で学んだことをまとめました。
テストコードでテストが通るだけのコードを書くことは簡単ですが、
テストコードも保守されていくので、保守しやすいように書く必要があります。
また、バグを検出しやすいようなテストコードを書くことも大事です。
pythonの有名なテストライブラリpytestを使って例を出しながら心がけると良さそうなことをまとめてみました。
テストコードの必要性
そもそものテストコードの必要性についてです。
テストコードがあるメリットは大きく「安心感を得られる」、「仕様が理解しやすい」の大きく二つがあると思います。
まず、一つ目の「安心感を得られる」についてです。
一部機能の挙動の変更やクラス名の変更などを行なったとします。
変更を行った後、変更箇所に依存している箇所も一緒に修正し、動作確認を行いリリースします。
このとき修正が漏れている箇所があると、リリース後にエラーが発生してしまいます。
エラーで落ちるのはまだ良い方で、処理が意図しない挙動をしていても気づけないのは問題です。
テストコードがあると、意図しない挙動をしていた場合にエラーで落ちるので気づくことができます。
そのためテストが通っているとリリース前に安心することができます。
また、システムに変更を加える際の精神的ハードルが下がるため大きめの変更も加えやすくなります。
次に二つ目の「仕様が理解しやすい」についてです。
テストコードではテストデータの入力値とアウトプットの値が明記されているので関数がどのような挙動なのかが理解しやすいです。
ロバストなコード
ロバストネス(robustness)の日本語訳を調べると「堅牢性」「頑強性」などがでてきます。
ソフトウェア開発においてロバストなシステムとは、長年にわたってシステムが故障せずに稼働し、
変更が加えられても脆弱にならない強いシステムを意味します。
ソフトウェアは常に成長を続け、ユーザインタフェースの変更や、機能の追加や削除などが行われ変化していきます。
コードが絶えず変化していくのでバグへの耐性がある強固なものにしなければなりません。
ロバストなコードを書くためには、クリーンで保守性の高いコードを書く必要があります。
これはテストコードにおいても同様です。
アプリケーションのコードが変更されるたびにテストコードも変更しなければならないため、
理解や保守がしやすいテストコードを書くことが大切です。
保守しやすくロバストなテストコードの書き方
保守しやすくロバストなテストコードは以下のような条件を持っています。
- 適切な粒度でまとまっている
- 変数や関数に適切な名前がついている
- 関数が短くて単純
- テストするべきものの単位で分かれており、一つのテストコードが単純
- よく使うようなものは共通化されている
- ベタ書きベースでアプリケーションのコードに依存しすぎないように書かれている
1~3は様々な本でよくみると思うので割愛して4つ目からそれぞれ説明していきます。
テストするべきものの単位で分かれており、一つのテストコードが単純
アサーションは一つの関数に一つが望ましいです。
理由は、一つの関数にいくつもアサーションがあると、テスト実行時にどこエラーが発生したかがすぐに分からないためです。
テスト対象の関数にアサーションしたいものが複数ある場合、その単位で分かりやすい名前の関数を作成し分けた方が良いです。
以下は、ユーザー情報を扱う簡単なクラスとそのテストの例です。
class User:
def __init__(self, username: str, email: str) -> None:
self.username = username
self.email = email
def get_full_info(self) -> str:
return f"Username: {self.username}, Email: {self.email}"
from user import User
def test_get_full_info_empty_username():
user = User(username: "", email: "jane@example.com")
result = user.get_full_info()
assert result == "Username: , Email: jane@example.com"
def test_get_full_info_empty_email():
user = User(username: "alice", email: "")
result = user.get_full_info()
assert result == "Username: alice, Email: "
get_full_info関数が以下のように変更されたとします。
def get_full_info(self) -> str:
ret_value = f"Username: {self.username}"
if self.email != "":
ret_value += f", Email: {self.email}"
return ret_value
emailが空の時は返却値にemailの情報を含めないようになったのでテストコードのtest_get_full_info_empty_emailがエラーで落ちるようになります。
アサーションしたい単位で分かりやすい名前の関数をわけるとどこでエラーが起きたかがパッと見てすぐに分かります。
よく使うようなものは共通化されている
テストコードを書く際によく使うようなClassや変数は共通化してまとまっていると
コード全体の見通しがよくなります。
例えば、pytestのfixtureを使うと上手に使うとクリーンで保守しやすいコードを書くことができます。
fixtureはテストの前処理(DBをセットアップする、モックを作成するなど)を行うためのpytestの機能です。
先ほどの例のuser.pyのUserクラスのフィールドが増えた場合について考えてみます。
class User:
def __init__(self, username: str, nickname: str,
age: int, sex: str, email: str,
company: str, position: str) -> None:
self.username = username
self.nickname = nickname
self.age = age
self.sex = sex
self.email = email
self.company = company
self.position = position
def get_full_info(self) -> str:
return f"Username: {self.username}, Nickname: {self.nickname}, Age: {self.age}, Sex: {self.sex}, Email: {self.email}, Company: {self.company}, Position: {self.position}"
Userクラスのインスタンスを生成するための引数が増えたため、テストの関数で毎回以下のように書くのは面倒で
見通しも悪くなりそうです。
def test_xxx() -> None:
user = User(username: "Johnson",
nickname: "John",
age: 30,
sex: "male",
email: "johnson@example.com",
company: "fuga holdings",
position: "president")
## 続く…
def test_yyy() -> None:
user = User(username: "Johnson",
nickname: "John",
age: 30,
sex: "male",
email: "johnson@example.com",
company: "fuga holdings",
position: "president")
## 続く…
fixtureを使うとスッキリします。
from user import User
import pytest
@pytest.fixture
def john_user() -> User:
user = User(username: "Johnson",
nickname: "John",
age: 30,
sex: "male",
email: "johnson@example.com",
company: "fuga holdings",
position: "president")
return user
def test_xxx(john_user: User) -> None:
assert john_user.get_full_info() == ....
関数が増えてもfixtureのjohn_userをそのまま引数に渡せば使えるので見渡しがよくなります。
また、クラスのフィールドが増えても修正箇所が減り運用負荷が下がります。
ベタ書きベースでアプリケーションの実装コードに依存しすぎないように書かれている
テストコードと検証ロジックが実装コードに依存していると、実装コードのバグをテストコード側で検出できない恐れがあります。
例えば、以下のようなClothクラスがあるとします。
class Cloth:
def __init__(self, name, price) -> None:
self._name = name
self._price = price
@property
def name(self) -> str:
return self._name
@property
def price(self) -> int:
return self._price * -1 # 負の値を返すようなバグ
def half_price(self) -> int:
return self.price / 2
上記のようにpriceが負の値を返すようになっていた場合、
実装コードに依存した以下のようなテストコードだとテストは成功してしまい、バグに気づくことはできません。
なぜなら、cloth.half_price()もcloth.priceも負の値だからです。
from cloth import Cloth
def test_half_price() -> None:
cloth = Cloth("シャツ", 10000)
assert cloth.half_price() == cloth.price / 2
以下のように期待値をベタ書きするとバグに気づくことができます。
from cloth import Cloth
def test_half_price() -> None:
cloth = Cloth("シャツ", 10000)
assert cloth.half_price() == 5000
ベタ書きすると仕様の変更が入った際にテストコードが壊れやすくなり、メンテナンスが大変になるデメリットはありますが、
バグを検出する方が大事なので、基本的にはベタ書きした方が良いです。
その他
可読性を向上するには各テストが同じ基本パターンに従うようにすると良いです。
よくあるテストパターンとしてAAAパターンがあります。
AAAはArrange-Act-Assert(準備、実行、検証)の頭文字をとったものです。
第一ブロックは事前準備の設定やテストを実行するためのデータを準備し、第二ブロックはテスト対象の実行、第三ブロックは事後条件の検証をします。
最後に
テストコードは一度書いてしまうとなかなかテストコードのリファクタリングをする機会はないと思います。
最初から保守しやすくバグも見つけやすいように意識しながら書くと、後々テストコードをみることでの仕様の理解やアプリケーションのコードのリファクタリングがしやすくなり一石二鳥です。
例に出したコードはとても単純で実際にはもっと複雑だとは思いますが、今回の内容は頭の片隅においておきたいと思います。