🚥

「レガシーコードからの脱却」のテストコードをPythonで書いてみる

2024/01/25に公開

はじめに

この記事は、「レガシーコードからの脱却ーソフトウェアの寿命を延ばし価値を高める9つのプラクティス」の「11章 プラクティス7 テストでふるまいを明示する」のテストコードをPythonで書いた内容を紹介しています。

https://amzn.to/3ObTV2v

この書籍では、レガシーコード(=バグを多く含み、壊れやすく拡張が難しいコード)を避けるためのプラクティスが9つ紹介されています。各プラクティスに対して、日常生活における例を挙げてその実践が重要となる背景が説明されており、とても腹落ちしやすい内容でした。

理論的な説明が多い本書ですが、その中でも11章の「プラクティス7 テストでふるまいを明示する」ではテスト駆動開発の実践例がJavaのコードを用いて非常に詳しく説明されていました。
基本的なユニットテストの書き方ではありますが、実践的なコツも数多く紹介されてました。なので、自分が普段よく使っているPythonで書き直してみることにしました。

テストの流れ

まずはこれから書くユニットテストの流れを整理しておきます。レッド、グリーン、リファクタという3つのフェーズがあります。

  • 最初にテストを書く。テスト対象のコードがまだ存在しない状態で、テストは実行もできない。
  • レッド:テスト対象のメソッドのスタブ(固定値を返すダミー)を作り、テストを実行可能な状態にする。テストは失敗する。
  • グリーン:テストの中身を書き、テストが成功することを確認する。
  • リファクタ:必要に応じてコードの品質を向上させる。

ここからは、上記の流れにしたがってサンプルコードのPersonクラスをPythonで作成します。

テストを書く

テストの実行環境は以下のとおりです。

  • Python 3.11.1, pytest 7.2.1
  • VS Code 1.84

書籍ではEclipseでコードを自動生成する機能が紹介されていましたが、VS CodeとPythonの組み合わせでは同様の機能を見つけられませんでした。

ハッピーパスのテストを書く

Personクラスは名前(name)と年齢(age)を持つシンプルなクラスです。
まずはハッピーパス(正常系の基本的なケース)のテストを書きます。

test_person.py
from Person import Person

person_name: str = "Bob"
person_age: int = 21


def test_create_person_with_name_and_age():
    person = Person()
    assert person.name == person_name
    assert person.age == person_age

ここでは、テストにマジックナンバーを直接書かずに、意味のある名前のついた変数を使っていることがポイントです。
書籍では「値をハードコードする代わりに意図がはっきりした名前のついた変数を使うテクニックは、テストを仕様として機能させる上でいちばん価値のあるものの1つだ。」と書かれています。

マジックナンバーを直接書かないことはプログラミングにおける基本中の基本です。しかしテストを書くときにはつい横着をしてしまいがちなので、注意したいところです。

この時点ではPersonクラスがないので、テストは実行エラーになります。

コードをスタブアウトする

次にPersonクラスのスタブを作り、テストを実行可能にします。

以下のように、名前と年齢のインスタンス変数を持つPersonクラスを作成します。変数にはダミーの固定値を入れておきます。

Person.py
class Person:
    def __init__(self, name: str, age: int):
        self.name = None
        self.age = 0

これでテストは実行できるようになりますが、実行すると失敗してレッドの表示になります。

❯ pytest
============================= test session starts ==============================
platform darwin -- Python 3.11.1, pytest-7.2.1, pluggy-1.0.0
rootdir: /Users/~/dev/github.com/tanny-pm/person-example
plugins: anyio-3.6.2
collected 1 item

test_person.py F                                                         [100%]

=================================== FAILURES ===================================
_____________________ test_create_person_with_name_and_age _____________________

    def test_create_person_with_name_and_age():
        person = Person(person_name, person_age)
>       assert person.name == person_name
E       AssertionError: assert None == 'Bob'
E        +  where None = <Person.Person object at 0x103f4bb50>.name

test_person.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_person.py::test_create_person_with_name_and_age - AssertionError: assert None == 'Bob'
============================== 1 failed in 0.02s ===============================

ここでテストが失敗したことで、テスト自体は正しく書けていることを確認できました。テストが失敗することを確認するのはとても重要です。

ふるまいを実装する

次に、Personクラスの中身を実装します。

Person.py
class Person:
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

このコードはテストに成功し、グリーンの表示になります。このように、レッドとグリーンを繰り返しながら、要求仕様を実現します。

❯ pytest
============================= test session starts ==============================
platform darwin -- Python 3.11.1, pytest-7.2.1, pluggy-1.0.0
rootdir: /Users/~/dev/github.com/tanny-pm/person-example
plugins: anyio-3.6.2
collected 1 item

test_person.py .                                                         [100%]

============================== 1 passed in 0.00s ===============================

制約を導入する

ここからはPersonクラスを改良します。まずは年齢に制約を設けます。MINIMUM_AGEMAXIMUM_AGEの2つの定数を定義します。

まず、定数を確認するコードを書きます。これは冗長ではないかと思いましたが、誰かが不用意にコード側の定数値を変更したときに、気づけるようにする効果があるようです。「ふるまいの変化を引き起こす可能性のあるものはすべてテストする」という考え方です。

test_person.py
def test_constants():
    assert Person.MINIMUM_AGE == 1
    assert Person.MAXIMUM_AGE == 200

定数を実装してテストを成功させます。

Person.py
class Person:
    MINIMUM_AGE = 1
    MAXIMUM_AGE = 200

    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

制約のふるまいを実装する

次に、年齢が条件を満たしていることを確認するテストを書きます。ここでは値が範囲外の時に例外をスローするテストを書きました。

test_person.py
def test_constructor_throw_exception_when_age_below_minimum():
    with pytest.raises(AgeBelowMinimumException):
        Person(person_name, Person.MINIMUM_AGE - 1)


def test_constructor_throw_exception_when_age_above_maximum():
    with pytest.raises(AgeAboveMaximumException):
        Person(person_name, Person.MAXIMUM_AGE + 1)

この状態だとテストが実行できないため、例外を定義します。これでテストを実行できるようになり、テストは予想通り失敗します。

AgeBelowMinimumException
class AgeBelowMinimumException(Exception):
    pass
AgeAboveMaximumException.py
class AgeAboveMaximumException(Exception):
    pass

最後に、Personクラスのふるまいを実装して完成です。

Person.py
from AgeAboveMaximumException import AgeAboveMaximumException
from AgeBelowMinimumException import AgeBelowMinimumException


class Person:
    MINIMUM_AGE = 1
    MAXIMUM_AGE = 200

    def __init__(self, name: str, age: int):
        if age < self.MINIMUM_AGE:
            raise AgeBelowMinimumException()
        if age > self.MAXIMUM_AGE:
            raise AgeAboveMaximumException()
        self.age = age
        self.name = name

最終的には4つのテストが成功するようになりました!

~/dev/github.com/tanny-pm/person-example v1.0.0~3
❯ pytest
============================= test session starts ==============================
platform darwin -- Python 3.11.1, pytest-7.2.1, pluggy-1.0.0
rootdir: /Users/~/dev/github.com/tanny-pm/person-example
plugins: anyio-3.6.2
collected 4 items

test_person.py ....                                                      [100%]

============================== 4 passed in 0.01s ===============================

なお、年齢が最大値または最小値と等しい場合のテストは書いていません。それは最初に書いたハッピーパスのテストでカバーされているからです。

完成したコード

完成したテストコードとクラスは以下のようになります。(例外クラスのコードは省略)

テストコードがプログラムの仕様を漏れなく説明していることがポイントですね。

test_person.py
import pytest

from AgeAboveMaximumException import AgeAboveMaximumException
from AgeBelowMinimumException import AgeBelowMinimumException
from Person import Person

person_name: str = "Bob"
person_age: int = 21


def test_create_person_with_name_and_age():
    person = Person(person_name, person_age)
    assert person.name == person_name
    assert person.age == person_age


def test_constants():
    assert Person.MINIMUM_AGE == 1
    assert Person.MAXIMUM_AGE == 200


def test_constructor_throw_exception_when_age_below_minimum():
    with pytest.raises(AgeBelowMinimumException):
        Person(person_name, Person.MINIMUM_AGE - 1)


def test_constructor_throw_exception_when_age_above_maximum():
    with pytest.raises(AgeAboveMaximumException):
        Person(person_name, Person.MAXIMUM_AGE + 1)
Person.py
from AgeAboveMaximumException import AgeAboveMaximumException
from AgeBelowMinimumException import AgeBelowMinimumException


class Person:
    MINIMUM_AGE = 1
    MAXIMUM_AGE = 200

    def __init__(self, name: str, age: int):
        if age < self.MINIMUM_AGE:
            raise AgeBelowMinimumException()
        if age > self.MAXIMUM_AGE:
            raise AgeAboveMaximumException()
        self.age = age
        self.name = name

まとめ

今回はPersonクラスの実装を例として、テストコードの書き方を学びました。実践でとくに役立ちそうな学びをまとめておきます。

  • テストする値をハードコードせずに、意図のある名前の変数を使う。
  • テスト対象のメソッドをスタブアウトしてテストを実行し、テストが正しく失敗することを示す。
  • テストは一意にする。各テストは1つの理由だけで失敗する。
  • テストには実装ではなくふるまいに基づいた名前をつける。ふるまいに着目することで、コードがテストしやすくなる。

私はこれまで、テストを書くのは冗長で面倒くさいと思っていました。しかしながら、今回のようにレッド、グリーン、リファクタの手順を何度も繰り返すことで、スムーズにクラスを作り上げることができました。テストを1つずつクリアしていくことで、着実な達成感も得られます。

今後はちょっとしたスクリプトを書くときもテストを書くことを意識して、その効果を実感したいと思いました。

GitHubで編集を提案

Discussion