テスト駆動開発

テスト駆動開発の概要です。


テスト駆動開発(TDD)とは、プログラムの設計をコードとして実装していく際に、テストを先に書いて道しるべとするプログラミングのスタイルです。

コーディングの前に、1つ以上のテスト(特に単体テスト)を書きます。 テストは、プログラムの振る舞いのある一面を対象とします。それは1つの関数やメソッドに焦点を当てたものであることもあります。 テストを書くことは、プログラムの要件と全体的なアーキテクチャを、実装に即した設計へと変換する方法の1つです。 テストを実行します。コードがまだ実装されていないので、テストは失敗するはずです。 コードを実装し、もう一度テストを実行します。 テストが通れば、その振る舞いの実装は完了です。あるいは、まだ必要なテストが残っているかもしれません。 テストが通らなければ、コードをデバッグして、もう一度テストを実行します。 テストとコーディングのサイクルは、必要なテストがすべて通るまで繰り返します。そこまで来れば、その振る舞いの実装はいったん完了です。

リファクタリング

リファクタリングとは、設計を改善するためにコードを書き直すことです。 単にバグを直すためにコードを書き直すことではありません。 テストに通るようにコードを修正することを「リファクタリング」と呼ぶことがあります。 テストに通すためにコードを修正することが、設計の改善を_含む_ことはありますが、単なるデバッグがコードの_設計_を改善するとは限りません。したがって、必ずしも_リファクタリング_とは言えません。

次は、リファクタリングをせずにデバッグする例です。

# A function intended to return x added to y.
# x and y are bad parameter names, but we ignore that for now.
def add(x, y):
    # used multiply operator by mistake. It fails the tests.
    return x * y

# Function corrected. It passes the tests. It has been debugged, but not refactored.
def add(x, y):
    return x + y

次は、リファクタリングをしてからデバッグする例です。


# Function name and parameter names are modified to something more meaningful. This is refactoring.
def lot_inventory(old_cars, new_cars):
    # Introduced multiply operator by mistake. It fails the tests. This is why we test.
    return old_cars * new_cars

# Function corrected. It passes the tests. This is debugging.
def lot_inventory(old_cars, new_cars):
    return old_cars + new_cars

TDDとExercismのPython

ExercismのPythonトラックでは、演習でTDDの手法を取り入れています。 単体テストはあらかじめ書かれています。 学習者はテストを読んで、解答が通るために何が必要かをより詳しく理解できます。 学習者には、解答のスタブが用意されていることもあります。

WebエディターでExercismのテストが失敗したときの対処

Pythonの解答で1つ以上のテストが失敗すると、対応するタスクの背景は緑になりません。 最初に失敗したタスクの領域が展開され、そのヘッダーは次のような表示になります。

Task 1 Extract coordinates -

マイナス記号をクリックするとタスクが折りたたまれ、他のタスクを見られるようになりますが、ここではこのタスクのまま進めます。

その下には、展開されたテスト領域があり、次のように表示されます。

       Test 1                               ⌄
FAILED TisburyTreasure > get coordinate

ここでTisbury Treasureは演習を表し、get_coordinateは失敗した関数またはメソッドを表します。

Test 1はたいてい、テストの準備のためのコード部分を伴う一種のテンプレートです。 どのテストが失敗したかという具体的な情報は含まれていません。 下部には次のように書かれています。

One or more variations of this test failed. Details can be found under each [variant#].

⌄をクリックするとテストが折りたたまれます。

その下には、折りたたまれたテストがあり、次のように表示されます。

       Test 2                                                    >
FAILED TisburyTreasure > get coordinate [variation #1] (item=
       ("Scrimshaw Whale's Tooth", '2A'), result='2A')

表示のされ方は、右側のペインの幅によって変わります。 >をクリックするとテストが展開されます。 入力データと期待される結果のデータは、たいていコード部分に表示されます。 データはこのタスクのすべてのテストのものである場合もあります。 一番下のTest Failureセクションに、このテストが失敗した具体的な理由が示されます。 次のように表示されることがあります。

AssertionError: ['2A'] != '2A'

この場合、戻り値['2A']が期待される値'2A'と等しくないことを示しています。

get_coordinateのコードを見ると、次のように実装されています。

def get_coordinate(record):
    return [record[1]]

リストの角括弧([])を外して(例:return record[1])、もう一度テストを実行すると、タスク1のテストは通ります。

1つ以上のタスクがまだ失敗している場合は、すべてのテストが通るまで、上と同じ手順を各タスクで繰り返します。

期待されるデータと返されたデータが大きすぎて、Test Failureセクションにすべて収まらないことがあります。 次のように表示されることがあります。

AssertionError: '("Sc[67 chars]\')\n\n(\'Brass Spyglass\', \'Abandoned Lighth[952 chars]')\n' != '("Sc[67 chars]\')\n(\'Brass Spyglass\', \'Abandoned Lighthou[928 chars]')\n'
Diff is 970 characters long. Set self.maxDiff to None to see it.

それでも、問題点を判断するのに十分なデータが残っていることがあります。 上の例では、改行が2つ返されています(例:\n\n(\'Brass Spyglass)。期待されているのは1つだけです(例:\n(\'Brass Spyglass)。

すべてのテストが通ったら

おめでとうございます! すべてのテストが通りました。 次はどうしましょう? 解答はすぐに公開できます。 また、コードが動くようになった今、何らかの理由でリファクタリングしたい場合は、コードを修正してもう一度提出できます。 コードをもっとよくできると思うけれど、その方法がわからない場合は、その解答についてメンタリングを依頼できます。 メンターが対応できる場合は、解答への別のアプローチのアイデアを知らせてくれることがあります。 解答を公開するときにはコメントを許可できます。すると、他の学習者がコメントを投稿したり質問したりする機会を得るかもしれません。

パフォーマンスの最適化

「早すぎる最適化は諸悪の根源である」(Tony HoareとDonald Knuthの両方に帰される言葉です)という言葉がありますが、解答が動いているにもかかわらず、そのパフォーマンスを改善したくなる時が来ます。 そのような時の1つは、解答がいくつかのテストは通るものの、他のテストではタイムアウトする場合です。 コードの一部分がどれだけ時間をかけているかを正確に知ると、役に立ちます。 timeitモジュールを使うと、コードの実行時間を非常に小さな単位まで計測できます。 timeit関数は最大5つの引数を取ります:timeit.timeit(stmt='pass', setup='pass', timer=<default timer>, number=1000000, globals=None)。 stmt仮引数は、実際に実行して計測するコードを定義します。 number仮引数は、stmtのコードを何回実行するかを決めます。 setup仮引数は、stmtのコードを実行する準備のために一度だけ実行されるコードを定義します。 setupのコードの実行時間は、全体の時間に含まれます。 stmtのコードを実行する回数が増えるほど、1回あたりに占めるsetupの時間は小さくなります。 timer仮引数を使うと、デフォルトとは別のTimerを渡せます。 timer仮引数のデフォルトの引数はperf_counterで、ほとんどの場合はこれで十分です。 number仮引数のデフォルト値は1_000_000です。 globals仮引数は、コードを実行する名前空間を指定します。 globals仮引数のデフォルト値はNoneです。

次は、timeitを使って、ある文に英語のすべての母音が含まれているかを判定するのにかかる時間を調べる例です。


import timeit

# run one million times
loops = 1_000_000

# first positional argument is for stmt
# second positional argument is for setup
# third (named) argument is for number
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
                    """

VOWELS = "AEIOU"

def has_all_vowels(sentence):
    return all(letter in sentence.casefold() for letter in VOWELS)
""", number=loops) / loops)

このコードを100万回実行したところ、1回あたり平均4.965089999896008e-07秒かかりました(1回あたり約497ナノ秒です)。

次の例は、リスト内包表記からcasefoldの呼び出しを外すと時間が節約できるかどうかを調べるものです。


import timeit

loops = 1_000_000

print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
                    """

VOWELS = "AEIOU"

def has_all_vowels(sentence):
    sentence = sentence.casefold()
    return all(letter in sentence for letter in VOWELS)
""", number=loops) / loops)

このコードを100万回実行したところ、1回あたり平均4.923898000270128e-07秒かかりました(1回あたり約492ナノ秒です)。 つまり、リスト内包表記からcasefoldを外したことで、1回あたり約5ナノ秒、100万回では合計で約5ミリ秒節約できました。

cProfileもコードのプロファイリングに使えますが、ミリ秒単位までしか計測できないため、ここまで細かくはありません。