小さいテスト駆動開発

Red -> Green -> Refactor のサイクルを 小さい単位で何度も 繰り返すテスト駆動開発

Uncle Bob流 テスト駆動開発

『Clean Craftsmanship』第2章 (Kindle版 pp.52,53)

  1. テストを書くまでは本番コードを書いてはならない。本番コードがないためにテストは失敗する。

  2. 失敗するテストやコンパイルできないテストを必要以上に書いてはならない。失敗を解決するには本番コードを書く。

  3. 失敗しているテストを解決する本番コードを必要以上に書いてはならない。テストがパスしたら、追加のテストを書く。

この3原則により、小さい単位で進む

4つ目はリファクタリング

ハンズオンの題材 FizzBuzz

  • 皆さんが日々取り組まれているプログラムからすると おもちゃ のような感じかもしれません

  • FizzBuzzであることは薄目で見ましょう! 「小さい」という視点で 抽出 した要素をふだんのプログラミングに活かせると考えています

環境構築

$ # examplesにいる前提です
$ cd tdd
.
├── fizzbuzz/
│   └── __init__.py
└── tests/
    ├── __init__.py
    └── test_fizzbuzz.py

環境構築できているか確認

tests/test_fizzbuzz.py
class TestFizzBuzz:
    def test_fail(self):
        assert False

pytest と叩きます。 テストが失敗したら環境構築できてます!

Tip

pytestのアサーション

注釈

Uncle Bobは何もしないテストで確認する

tests/test_fizzbuzz.py
class TestFizzBuzz:
    def test_nothing(self):
        ...

Tip

Pythonで何もしない

TODOリスト

FizzBuzzの仕様を整理

- [ ] 数をそのまま文字列に変換する
- [ ] 3の倍数のときは数の代わりに「Fizz」に変換する
- [ ] 5の倍数のときは数の代わりに「Buzz」に変換する
- [ ] 15の倍数のときは数の代わりに「FizzBuzz」に変換する

ref: TDD Boot Camp 2020 Online #1 基調講演/ライブコーディング (55:54)

TDDBCのライブコーディングからは以下が参考になります

  • TODOリストの作り方

  • 重要度の付け方(重要度が高く、テストしやすい 項目を実現する)

1サイクル

TODOリスト
- [ ] 数をそのまま文字列に変換する
  - [ ] 1を渡すと文字列1を返す
- [ ] 3の倍数のときは数の代わりに「Fizz」に変換する
- [ ] 5の倍数のときは数の代わりに「Buzz」に変換する
- [ ] 15の倍数のときは数の代わりに「FizzBuzz」に変換する

1を渡すと文字列1を返す

Q: どのようにやりますか?

警告

かつてのnikkieの場合

  • テストを全部書き切る

  • テストが落ちているので実装を書く

  • リファクタリングして次のケースへ

ptw とコマンドを叩き、ファイルの変更のたびにテストが流れるようにします。 Ctrl+C で抜けられます

Red🟥 ImportError

tests/test_fizzbuzz.py
from fizzbuzz import FizzBuzz

class TestFizzBuzz:
    def test_1を渡すと文字列1を返す(self):
        ...
    from fizzbuzz import FizzBuzz
E   ImportError: cannot import name 'FizzBuzz' from 'fizzbuzz' (/.../small-technical-2023/examples/tdd/fizzbuzz/__init__.py)

テストで使いたいクラスがまだないため、それをimportできないというエラー

3原則の1「本番コードがないためテスト失敗」となったので、2「失敗を解決するために本番コードを書く」へ進む

Green🟩 ImportError解決

fizzbuzz/__init__.py
class FizzBuzz:
    ...

Tip

__init__.py

  • __init__.py が置かれたディレクトリは パッケージ となる

  • パッケージ pkgA__init__.pyFoo クラスは from pkgA import Foo とimportできる

    • pkgAfoo.pyFoo クラスは from pkgA.foo import Foo

原則3「テストがパスしたら、追加のテストを書く」より、本番コードには何もしないクラスを書くだけ

Red🟥 AttributeError

importできるようになったので、アサーションを書く

tests/test_fizzbuzz.py
from fizzbuzz import FizzBuzz

class TestFizzBuzz:
    def test_1を渡すと文字列1を返す(self):
        fizz_buzz = FizzBuzz()
        assert "1" == fizz_buzz.convert(1)
    def test_1を渡すと文字列1を返す(self):
        fizz_buzz = FizzBuzz()
>       assert "1" == fizz_buzz.convert(1)
E       AttributeError: 'FizzBuzz' object has no attribute 'convert'
  • インスタンス化はできている

  • convert メソッドの呼び出しで AttributeError 送出

3原則の1「本番コードがないためテスト失敗」となったので、2「失敗を解決するために本番コードを書く」へ

AttributeError解決

実装して、convertメソッドを定義

実装
class FizzBuzz:
    def convert(self, n: int) -> str:
        return ""

🟥 AssertionError

    def test_1を渡すと文字列1を返す(self):
        fizz_buzz = FizzBuzz()
>       assert "1" == fizz_buzz.convert(1)
E       AssertionError: assert '1' == ''
E         + 1

まだ 1「本番コードがないためテスト失敗」の状態

Green🟩 仮実装

実装
class FizzBuzz:
    def convert(self, n: int) -> str:
        return "1"

通った。リファクタリングは特になし

テストがパスしたので次のテストへ

仮実装で テストのテスト をした。 落ちるべきときに落ち、通るべきときに通る(TDDBCより)

  • 実装が "1" を返すときにテストが通る

  • 実装が "1" を返さない(最初は空文字列)ならばテストは通らない

2サイクル

キーワード:三角測量

  • 仮実装した1例がある

  • 実装を 一般化するために2例目を追加

  • 2例のテストを通すよう実装を一般化

TODOリスト
- [ ] 数をそのまま文字列に変換する
  - [x] 1を渡すと文字列1を返す
  - [ ] 2を渡すと文字列2を返す
- [ ] 3の倍数のときは数の代わりに「Fizz」に変換する
- [ ] 5の倍数のときは数の代わりに「Buzz」に変換する
- [ ] 15の倍数のときは数の代わりに「FizzBuzz」に変換する

Red🟥 2のケース

tests/test_fizzbuzz.py
class TestFizzBuzz:
    def test_2を渡すと文字列2を返す(self):
        fizz_buzz = FizzBuzz()
        assert "2" == fizz_buzz.convert(2)

2件のうち1件通り、1件落ちる 🟩🟥

Green🟩 2のケース

2件通すように実装を修正

fizzbuzz/__init__.py
class FizzBuzz:
    def convert(self, n: int) -> str:
        return str(n)

通った🙌

Refactor 分かりやすい変数名に

引数 nnumber に変えて分かりやすくする

VSCodeの Rename Symbol を利用

fizzbuzz/__init__.py
class FizzBuzz:
    def convert(self, number: int) -> str:
        return str(number)

3サイクル

TODOリスト
- [ ] 数をそのまま文字列に変換する
  - [x] 1を渡すと文字列1を返す
  - [x] 2を渡すと文字列2を返す
- [ ] 3の倍数のときは数の代わりに「Fizz」に変換する
  - [ ] 3を渡すと文字列Fizzを返す
- [ ] 5の倍数のときは数の代わりに「Buzz」に変換する
- [ ] 15の倍数のときは数の代わりに「FizzBuzz」に変換する

Red🟥 3のケース

tests/test_fizzbuzz.py
class TestFizzBuzz:
    def test_3を渡すと文字列Fizzを返す(self):
        fizz_buzz = FizzBuzz()
        assert "Fizz" == fizz_buzz.convert(3)

2件通り1件落ちる 🟩🟩🟥

Green🟩 3のケース(仮実装)

fizzbuzz/__init__.py
class FizzBuzz:
    def convert(self, number: int) -> str:
        if number == 3:
            return "Fizz"
        return str(number)

テスト3件通る🙌

Refactor テストコードの重複除去

テストコードにはインスタンス化の重複がある。 pytestの フィクスチャ を使って インスタンス化を1箇所に まとめる

VS Codeの Extract method が使える。 ここではクラスに抽出(モジュールレベルで抽出しても動きます)

tests/test_fizzbuzz.py
import pytest


class TestFizzBuzz:
    @pytest.fixture
    def fizz_buzz(self) -> FizzBuzz:
        return FizzBuzz()

定義したフィクスチャを各テスト(メソッド)で利用

tests/test_fizzbuzz.py
class TestFizzBuzz:
    def test_3を渡すと文字列Fizzを返す(self, fizz_buzz):
        assert "Fizz" == fizz_buzz.convert(3)

倍数のときへと一般化

これまでのサイクルのおかげで自信が少しはあるので、三角測量を経ずに一般化

fizzbuzz/__init__.py
class FizzBuzz:
    def convert(self, number: int) -> str:
        if number % 3 == 0:
            return "Fizz"
        return str(number)

4サイクル

TODOリスト
- [ ] 数をそのまま文字列に変換する
  - [x] 1を渡すと文字列1を返す
  - [x] 2を渡すと文字列2を返す
- [ ] 3の倍数のときは数の代わりに「Fizz」に変換する
  - [x] 3を渡すと文字列Fizzを返す
- [ ] 5の倍数のときは数の代わりに「Buzz」に変換する
  - [ ] 5を渡すと文字列Buzzを返す
- [ ] 15の倍数のときは数の代わりに「FizzBuzz」に変換する

Red🟥 5のケース

tests/test_fizzbuzz.py
class TestFizzBuzz:
    def test_5を渡すと文字列Buzzを返す(self, fizz_buzz):
        assert "Buzz" == fizz_buzz.convert(5)

3件通り1件落ちる 🟩🟩🟩🟥

Green🟩 5のケース(明白な実装)

明白な実装(ここまで開発してきてテストコードには 自信 がある)

fizzbuzz/__init__.py
class FizzBuzz:
    def convert(self, number: int) -> str:
        if number % 3 == 0:
            return "Fizz"
        if number % 5 == 0:
            return "Buzz"
        return str(number)

4件通る🙌

5サイクル

TODOリスト
- [ ] 数をそのまま文字列に変換する
  - [x] 1を渡すと文字列1を返す
  - [x] 2を渡すと文字列2を返す
- [ ] 3の倍数のときは数の代わりに「Fizz」に変換する
  - [x] 3を渡すと文字列Fizzを返す
- [ ] 5の倍数のときは数の代わりに「Buzz」に変換する
  - [x] 5を渡すと文字列Buzzを返す
- [ ] 15の倍数のときは数の代わりに「FizzBuzz」に変換する
  - [ ] 15を渡すと文字列FizzBuzzを返す

Red🟥 15のケース

tests/test_fizzbuzz.py
class TestFizzBuzz:
    def test_15を渡すと文字列FizzBuzzを返す(self, fizz_buzz):
        assert "FizzBuzz" == fizz_buzz.convert(15)

3件通り1件落ちる 🟩🟩🟩🟩🟥

Green🟩 15のケース

実装
class FizzBuzz:
    def convert(self, number: int) -> str:
        if number % 3 == 0 and number % 5 == 0:
            return "FizzBuzz"
        if number % 3 == 0:
            return "Fizz"
        if number % 5 == 0:
            return "Buzz"
        return str(number)

5件通る🙌🙌

TODOリスト
- [ ] 数をそのまま文字列に変換する
  - [x] 1を渡すと文字列1を返す
  - [x] 2を渡すと文字列2を返す
- [ ] 3の倍数のときは数の代わりに「Fizz」に変換する
  - [x] 3を渡すと文字列Fizzを返す
- [ ] 5の倍数のときは数の代わりに「Buzz」に変換する
  - [x] 5を渡すと文字列Buzzを返す
- [ ] 15の倍数のときは数の代わりに「FizzBuzz」に変換する
  - [x] 15を渡すと文字列FizzBuzzを返す

考察

ケント・ベックの定義(2つのルール 『テスト駆動開発』Kindle の位置No.33-34)

  • 自動化されたテストが失敗したときのみ、新しいコードを書く。

  • 重複を除去する。

Red -> Green -> Refactor

Uncle Bobの3原則でTDDを進めると、 strict になった

Before(小さいテスト駆動開発を知る前)

  • 1のケースのテストコードを一気に全部書く(Red🟥)

  • 1のケースのテストを通す実装を全部書く(Green🟩)

  • リファクタリング

  • 2のケースのテストコードへ

1つのテストケースに対してRedとGreenは1つずつ。

小さいテスト駆動開発

これに対して、ここで見た小さいテスト駆動開発では、RedとGreenを細かく行き来する

  • 1のケースのテストコードをエラーが出るまで書く(Red🟥)

  • 上記のエラーを解決する実装を書く(Green🟩)

  • リファクタリング

  • 1のケースのテストコードの続きをエラーが出るまで書く(Red🟥)

  • 上記のエラーを解決する実装を書く(Green🟩)

  • リファクタリング

  • 1のケースのテストコードの続きをエラーが出るまで書く(Red🟥)

  • 上記のエラーを解決する実装を書く(Green🟩)

  • リファクタリング

  • 2のケースのテストコードへ

テストも実装も 少しずつ できあがっていく!

この先

動作するドキュメントにする

TDD Boot Campでは、t-wadaさんはこのあと、テストコードを 動作するドキュメント にしていく

  • TODOリスト(仕様)のネスト構造をテストコードに反映

    • テストコードで クラスがネスト する

  • テストを 最小限 にしておく

    • 三角測量で作ったテストは片方 消す (書いた人だけが三角測量で進んだから似たテストがあると分かっている)

モックを使ったTDDの例

参考文献

関連アウトプット