テスト駆動開発をやってみよう

2025-04-02

みなさんこんにちは!jackのえんぴつです.春,春ですよ! というわけで,新入生向けの記事を書いていきます🥳 …と,思っていたのですが,新入生向けのネタがなかったので,この前読んだテスト駆動開発(Kent著, 和田訳)をもとに記事を書こうと思います.全然初学者向けじゃなくてゴメン😭 この記事はプログラミング未経験の人にはピンと来ない内容です.去年はプログラミング未経験の人向けに記事を書いたので,「プログラミングってやったことないけど,興味はある!」という方はぜひ,”はじめてのプログラミング”をお読みください🙇 はじめてのプログラミング(前編:さいしょのいっぽ編) はじめてのプログラミング(後編:やってみよう編) (ちなみに,”はじめてのプログラミング” は 春のブログ祭り2024で執筆した記事です.他にも面白い記事がたくさんあるので読んでみてね😉)

し!か!し! 「テスト駆動開発 (TDD) 」は,「玄人でなければできない」というものではありません!!! 著者は彼の当時12歳の娘にプログラミングを教えるとき,はじめからTDDを教えたといいます.ある程度プログラムを書くことに慣れ,「もう少し複雑なものを作ってみようかな」と考えている方であれば,絶対に役に立つ内容だと思います. むしろ,経験が浅いほうが今までの習慣がない分すんなり身につくかも?

テスト駆動開発 (TDD) とは?

「テスト駆動開発」という言葉を聞いて最初に思い浮かぶのはどのような手法でしょうか. 「テストを先に書くプロジェクトの進め方」? 実は,ちょっと違います. テスト駆動開発は,開発者個人の手技であり,単にテストを先に書くだけ,ではありません. TDDの本質は,「テスト,実装,リファクタリングの3フェーズのサイクル」そして「インクリメンタルであること」にあります.

まずテストを書く

TDDの実践者は,何らかの機能をコーディングするとき,まずテストから書き始めます. おっと,最初にすべてのテストを書くという意味ではありませんよ. まずは,実現しようとする機能に必要なテストのみを1つ書きます. 例えば,「ある日付がある期間に入っているか判定する」という機能を実現したいとしましょう.そのときは,「contains」というメソッドを作って・・・ ではなくて,「testDateRangeContainsDate」というテストを一つ書きます (命名はここでは問題ではありません.とにかくテストを書くのです). テスティングフレームワークが使えるならそれに越したことはありませんが,それは本質的ではありません.”実際の動作”と”期待する結果”があるのなら,それはテストたり得ます.

from product_code import *

def testDateRangeContainsDate():
	date = Date(2000,1,2)
	dateRange = DateRange(Date(2000,1,1),Date(2000,1,31))
	assert dateRange.contains(date) is True
	
testDateRangeContainsDate()

テストが一つじゃ足りない?テストの書き方がわからない? それは機能が大きすぎます. その機能の実現に必要な,より小さなサブ機能はないか考えましょう.そして,より小さな機能に対してテストを書きます. さあ,テストを書いたら実行してみましょう…

Traceback (most recent call last):
  File "/mnt/secondaryStorage/tdd-article/test.py", line 1, in <module>
    import product_code
ModuleNotFoundError: No module named 'product_code'

ええ,通るはずがありません.product_code.pyというファイルすら用意していませんから.でも,それでいいのです.まずはテストを書き,その失敗を見届けます.

次に実装する

テストの失敗を見届けたら,次は実装です.次のようにproduct_code.pyを実装してみましょう.

class Date:
    def __init__(self, year, month, day):
        pass

class DateRange:
    def __init__(self, start_date, end_date):
        pass
    def contains(self, date):
        return True

あなたは,こう思ったはずです. 「こんなもの実装ではない」と. しかし,それでいいのです.実装の段階では,どんな罪を犯しても構いませんとにかくテストを通すことを考えます. (ちなみに,例の方法は,書籍では”空実装”と紹介されるものです.) この段階でテストを実行すれば,めでたくエラーが表示されません.つまり,テストが通りました

リファクタリングで後片付けする

テストが通ったら,次はリファクタリングです.犯した罪をそのままにはできません.贖罪を果たさなければ.まず,True がどこから来たのか考えます.そう,TrueDate(2000,1,2)Date(2000,1,1)Date(2000,1,31)の間にある,という意味でした.そのように書き換えましょう.

class Date:
    def __init__(self, year, month, day):
        pass

class DateRange:
    def __init__(self, start_date, end_date):
        pass
    def contains(self, date):
        return (
            Date(2000,1,1) < Date(2000,1,2) 
            and Date(2000,1,2) < Date(2000, 1,31)
        )

さて,ここで,Dateには”前後関係を比較可能である”という振る舞いが必要なことに気づきます.では,Dateに比較機能の実装を・・・,いえ違います,Dateの比較機能のテストを書きましょう.

テストを書く(2)

変更を差し戻して,テストを追加してみます.

# class Date:
#     def __init__(self, year, month, day):
#         pass

# class DateRange:
#     def __init__(self, start_date, end_date):
#         pass
#     def contains(self, date):
#         return (
#             Date(2000,1,1) < Date(2000,1,2) 
#             and Date(2000,1,2) < Date(2000, 1,31)
#         )
from product_code import *

def testDateComparable():
    date1 = Date(2000,1,1)
    date2 = Date(2000,1,2)
    assert (date1 < date2) is True
    assert (date2 > date1) is True

testDateComparable()

# def testDateRangeContainsDate():
# 	date = Date(2000,1,2)
# 	dateRange = DateRange(Date(2000,1,1),Date(2000,1,31))
# 	assert dateRange.contains(date) is True

# testDateRangeContainsDate()

実装する&リファクタリング (2)

次のようにしてテストを通しました.

class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day
    def __lt__(self, other):
        if self.year < other.year:
            return True
        elif self.year > other.year:
            return False
        else:
            if self.month < other.month:
                return True
            elif self.month > other.month:
                return False
            else:
                return self.day < other.day

# class DateRange:
#     def __init__(self, start_date, end_date):
#         pass
#     def contains(self, date):
#         return (
#             Date(2000,1,1) < Date(2000,1,2) 
#             and Date(2000,1,2) < Date(2000, 1,31)
#         )

しかしどうも不格好ですね.すこし調べると,次のように書けることがわかりました.

class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day
    def __lt__(self, other):
        return (
            self.year, self.month, self.day
        ) < (
            other.year, other.month, other.day
        )

# class DateRange:
#     def __init__(self, start_date, end_date):
#         pass
#     def contains(self, date):
#         return (
#             Date(2000,1,1) < Date(2000,1,2) 
#             and Date(2000,1,2) < Date(2000, 1,31)
#         )

いい具合にリファクタリングできました.リファクタリングのあとはテストを実行して通ることを確認してくださいね😉

戻ってきました

はれてDateに比較の振る舞いを作ることができました.では,最初に取り組んでいたテストを再び有効化してみましょう.ちょっと端折って実装までしてしまいましょう.(コメントアウトを外すだけですが.)

from product_code import *

def testDateComparable():
    date1 = Date(2000,1,1)
    date2 = Date(2000,1,2)
    assert (date1 < date2) is True
    assert (date2 > date1) is True

testDateComparable()

def testDateRangeContainsDate():
	  date = Date(2000,1,2)
	  dateRange = DateRange(Date(2000,1,1),Date(2000,1,31))
	  assert dateRange.contains(date) is True

testDateRangeContainsDate()
class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day
    def __lt__(self, other):
        return (
            self.year, self.month, self.day
        ) < (
            other.year, other.month, other.day
        )

class DateRange:
    def __init__(self, start_date, end_date):
        pass
    def contains(self, date):
        return (
            Date(2000,1,1) < Date(2000,1,2) 
            and Date(2000,1,2) < Date(2000, 1,31)
        )

やった,テストが通ります.ではリファクタリングを再開しましょう.

リファクタリングの続き

さて,重複は変数で置き換えるべきです.置き換えてみましょう.

class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day
    def __lt__(self, other):
        return (
            self.year, self.month, self.day
        ) < (
            other.year, other.month, other.day
        )

class DateRange:
    def __init__(self, start_date, end_date):
        pass
    def contains(self, date):
        return (
            Date(2000,1,1) < date 
            and date < Date(2000, 1,31)
        )

うん,テストは通る.ところで,ほかのDateインスタンスもstart_dateとend_dateで受け取っていましたね.ここの重複も取り除きます.

class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day
    def __lt__(self, other):
        return (
            self.year, self.month, self.day
        ) < (
            other.year, other.month, other.day
        )

class DateRange:
    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
    def contains(self, date):
        return self.start_date < date and date < self.end_date

やった,テストが通りました!

繰り返そう

きっと,聡明な皆さんなら,上記のコードに問題があることには気づいているでしょう. 何をどのように実装するべきかもわかっているはずです.しかし,ちょっと待ってください. その実装を導く”テスト”はどのようなものでしょうか?

最後に

いかがだったでしょうか.上記の例は少し大げさでしたが,TDDの感覚を掴んでいただけたなら幸いです. 私自信,TDDを実践しだしたばかりで,TDDを自分のものにできた,とはとても言えません. しかも,記事中ではフィーチャーできなかったTDDの大事な要素もあります.(たとえば,「自信」とか) なので,これでTDDに興味が湧いたよ!という方は書籍テスト駆動開発(Kent著, 和田訳) を手にとっていただきたいです. 種本にさせていただいた,テスト駆動開発(Kent著, 和田訳) は,TDDを分かりやすい実例付きで説明した素晴らしい書籍です(そして著者のKent氏はテスト駆動開発の祖です).文体も軽く,読みやすい! おすすめの読み方は,(翻訳者の和田氏が執筆された)付録Cにあるとおり,書籍の内容に従って ”写経” してみることです.特に,第二部は写経の効果が大きいです (というか,この記事の筆者は第二部だけ写経してあとは普通に読みました).読んでいるだけだと大変混乱しますが,実際に手を動かしながら読むと,案外すんなり理解できます.第二部の写経にかかった時間はわずか3時間程度.スナック感覚で取り組むことができると思います. この記事を〆る前に少し注意ですが,テスト駆動開発は万人のためのモノ,万物のためのモノというわけではありません.まどろっこしいのが肌に合わないという人もいますし,UIの開発なんかは実際に見ながら開発したほうが早いです. それでも,一度でいいのでこの方法を試してみてほしい,自信を持って開発を勧めていく感覚を体験してほしい,そう思っています.

(補足) そのリファクタリング,ほんとにリファクタリング?

この記事で,”リファクタリング”と繰り返し表現してきましたが,TDDに馴染みのない人はこの言葉の使い方に違和感を覚えるかもしれません. 「リファクタリング,と言う割には振る舞いを大きく変え過ぎじゃない?」 というのは当然の感想です. 例えばここ,

class DateRange:
    def __init__(self, start_date, end_date):
        pass
    def contains(self, date):
        return (
            Date(2000,1,1) < date # Date(2000,1,2) -> dateに置き換えた
            and date < Date(2000, 1,31) # Date(2000,1,2) -> dateに置き換えた
        )

どう考えても,containsの振る舞いは同じではありませんよね.

しかし,これはテストに着目することによって,”振る舞いは同じである”ということができます.

(再掲)

def testDateRangeContainsDate():
	  date = Date(2000,1,2)
	  dateRange = DateRange(Date(2000,1,1),Date(2000,1,31))
	  assert dateRange.contains(date) is True

testDateRangeContainsDate()

そもそも,TDDにおけるテストとは理想の”振る舞い”を記述するものです. テストを書き換えていないのですから,テストが規定する”振る舞い”は変わっていません. そういう意味で,リファクタリングである,と言えるわけですね. 詳しくは書籍の第31章を参照してください.