単体テストを学ぼう!② -テスト駆動開発で単体テストをしてみる-

f:id:monozukuri-bu:20191127115032j:plain

こんにちは、ふじもんです。

前回の記事では開発におけるテスト、単体テストの種類、テスト駆動開発について触れました。
では、今回はいざ実践!
テスト駆動開発で実際に単体テストを実施していきます!!

テスト駆動開発の流れ

テスト駆動開発は、以下の手順を繰り返し行い、テストコードとプロダクションコードを作成していきます。

1. レッド     :まず最初に、実装要件に基づいたテストコードを書く  
2. グリーン    :テストが成功するプロダクションコードを書く  
3. リファクタリング:テストが成功する状態のコードを綺麗にしていく

テスト駆動開発テストファーストの開発手法なので、
まずはどんなテストを行うか決め、テストコードを書いてから実装していくんですね。

システムの仕様の決定

まずはどんなシステムを作るかを決めないと、開発も何もありません。
じゃあ何作ろっかなー、んーーーー。

そろそろ健康診断受けなきゃだし、健康に付随してBMIを計算するシステムとかにしてみよう。

1. 身長(cm)と体重(kg)をキーボードから入力する。  
2. 入力された身長(cm)と体重(kg)からBMIを計算する。  
3. 計算結果を出力する。

こんな感じのシンプルなシステムを作ってみます。

いざテストコード記述

よし、穴だらけかもしれないけど仕様は決まった!
いよいよテスト駆動開発とやらをやってみるぞ!!

今回はPythonで開発を行い、テストはPythonの標準モジュールであるunittestで行っていこうと思います。
ディレクトリ構成は以下のようにしました。

UnitTest
├ bmi_calculator (プロダクションコード格納場所)
│ └ BmiCalculator.py
│
└ test (テストコード格納場所)
  └ bmi_test.py

テスト駆動開発ではまず実装要件に基づいたテストコードを書くのが最初でしたね。
最初のテストコードは、こんな感じで書いてみます。

# bmi_test.py

import sys
import pathlib
sys.path.append('..')
current_dir = pathlib.Path(__file__).resolve().parent
sys.path.append(str(current_dir) + '/../')
from unittest import TestCase
from bmi_calculator.BmiCalculator import BmiCalculator

class TestBmiCalculator(TestCase):
    pass

3~7行目でプロダクションコードのパスを追加し、プロダクションコードのモジュールをインポートできるようにします。
そして11~12行目の実際のテスト部分ですが、とりあえず何もしない状態。
これで一度テストコードを実行してみます。

bash-3.2$ python -m unittest bmi_test.py
E
======================================================================
ERROR: bmi_test (unittest.loader._FailedTest)
----------------------------------------------------------------------
ImportError: Failed to import test module: bmi_test
Traceback (most recent call last):
  File "/Users/XXXX/anaconda3/lib/python3.6/unittest/loader.py", line 153, in loadTestsFromName
    module = __import__(module_name)
  File "/Users/XXXX/work/UnitTest/test/bmi_test.py", line 4, in <module>
    from bmi_calculator.BmiCalculator import BmiCalculator
ImportError: cannot import name 'BmiCalculator'


----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (errors=1)

BmiCalculatorモジュールがないと怒られましたが、まだ作ってないので怒られて当然ですね。
これがテスト駆動開発のレッドの状態です。

では、これをグリーンにすべくテストコードが通るプロダクションコードを書きます。
とりあえずエラー内容に従い、モジュールを作成します。

# BmiCalculator.py

class BmiCalculator:
    pass

とりあえず作っただけ。
いざ実行。

bash-3.2$ python -m unittest bmi_test.py

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

OKでました!!これがグリーンの状態です。
こんな感じでひたすらテストコードとプロダクションコードを書いていくのが、テスト駆動開発のやり方のようです。

単体テストをしてみる

引き続きゴリゴリ書いていきましょう。
実装したいのは以下の3点でした。

1. 身長(cm)と体重(kg)をキーボードから入力する。  
2. 入力された身長(cm)と体重(kg)からBMIを計算する。  
3. 計算結果を出力する。

ではまず1. 身長(cm)と体重(kg)をキーボードから入力する。のテストコードを記述していきます。
ブラックボックステストは、システムの中身に関係なくインプットに対するアウトプットを確認するテストでしたね。

じゃあどのインプットに対し、どのアウトプットを正とするのか。
それを決めるためにもうちょっと仕様を詳しく決めます。

1. 身長(cm)と体重(kg)をキーボードから入力する。
・身長、体重共に有効な値の範囲は 0 < x < 1000 とし、Trueを返す
・0以下、もしくは1000以上の数値が入力された場合はFalseを返す
・入力がない場合はFalseを返す
・数値に変換できない文字列が入力された場合はFalseを返す

有効値が入力された場合はTrueを返し、無効値が入力された場合はFalseを返すようにします。
さらに、この条件のテストを行うことにより、ホワイトボックステストの分岐網羅・条件網羅もテスト可能です。

では、この仕様に合ったテストコードを書いていきます。
また、身長・体重ともに同じ仕様のため、今回は身長部分の実装のみを記載しています。

# bmi_test.py

import sys
import pathlib
sys.path.append('..')
current_dir = pathlib.Path(__file__).resolve().parent
sys.path.append(str(current_dir) + '/../')
from unittest import TestCase
from bmi_calculator.BmiCalculator import BmiCalculator

class TestBmiCalculator(TestCase):
    def test_set_height_01(self):
        bmiCalc = BmiCalculator()
        self.assertTrue(bmiCalc.set_height("60"))   # 正常値
        
    def test_set_height_02(self):
        bmiCalc = BmiCalculator()
        self.assertTrue(bmiCalc.set_height("999.9"))   # 正常値

    def test_set_height_03(self):
        bmiCalc = BmiCalculator()
        self.assertFalse(bmiCalc.set_height("1000.0"))   # 無効値

    def test_set_height_04(self):
        bmiCalc = BmiCalculator()
        self.assertTrue(bmiCalc.set_height("0.1"))   # 正常値

    def test_set_height_05(self):
        bmiCalc = BmiCalculator()
        self.assertFalse(bmiCalc.set_height("0"))   # 無効値

    def test_set_height_06(self):
        bmiCalc = BmiCalculator()
        self.assertFalse(bmiCalc.set_height("-1"))   # 無効値
    
    def test_set_height_07(self):
        bmiCalc = BmiCalculator()
        self.assertFalse(bmiCalc.set_height(""))   # 無効値
    
    def test_set_height_08(self):
        bmiCalc = BmiCalculator()
        self.assertFalse(bmiCalc.set_height("abc"))   # 無効値

test_set_height_01で正常値を確認、
test_set_height_02test_set_height_04で境界値分析、
test_set_height_05test_set_height_08で無効値を確認しています。
また、正常値と無効値を複数入力することで同値分割の確認もできます。
先にテストの条件を書いておくと、頭の中が整理されて何をすべきかわかって良いですね。

さてさて、テスト駆動開発の手順に沿ってテストコードを実行。

bash-3.2$ python -m unittest bmi_test.py
…
(省略)

======================================================================
ERROR: test_set_height_08 (bmi_test.TestBmiCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/XXXX/work/UnitTest/test/bmi_test.py", line 42, in test_set_height_08
    self.assertFalse(bmiCalc.set_height("abc"))   # 無効値
AttributeError: 'BmiCalculator' object has no attribute 'set_height'

----------------------------------------------------------------------
Ran 8 tests in 0.001s

FAILED (errors=8)

うん、想定通り。
BmiCalculatorの中にset_heightがないと怒られました。
だって、まだ作ってn(以下略)

では、テストが通るようにset_heightを作ります。
とりあえず数値かどうか判別して無効値を除外したい。

# BmiCalculator.py

import re

class BmiCalculator:
    def __init__(self):
        pass

    def set_height(self, h):
        if re.match(r"[\d]+[..]?[\d]?", h):
            h = float(h)
            return True
        else:
            return False

こんな感じでどうだ!
上記のコードではif re.match(r"^[\d]+[..]?[\d]?$", h)で小数点第一位までの数字かどうかを判別しています。
正規表現ムズカシーでもベンリー

いざテストコード実行!

bash-3.2$ python -m unittest bmi_test.py
..F.F...
======================================================================
FAIL: test_set_height_03 (bmi_test.TestBmiCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/XXXX/work/UnitTest/test/bmi_test.py", line 22, in test_set_height_03
    self.assertFalse(bmiCalc.set_height("1000.0"))   # 無効値
AssertionError: True is not false

======================================================================
FAIL: test_set_height_05 (bmi_test.TestBmiCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/XXXX/work/UnitTest/test/bmi_test.py", line 30, in test_set_height_05
    self.assertFalse(bmiCalc.set_height("0"))   # 無効値
AssertionError: True is not false

----------------------------------------------------------------------
Ran 8 tests in 0.002s

FAILED (failures=2)

test_set_height_03test_set_height_05のテストが失敗しています。
0を入力した時と1000を入力した時に無効値であるときの返り値であるFalseが返ってきてないとのこと。

正規表現で数値以外の無効値が判別できただけで満足しきってました。あぶないあぶない。
追加しましょう。

# BmiCalculator.py
import re

class BmiCalculator:
    def __init__(self):
        pass

    def set_height(self, h):
        if re.match(r"^[\d]+[..]?[\d]?$", h):
            h = float(h)
            self.height = h
            return True
            if 0 < h < 1000:
                self.height = h
                return True
            else:
                return False
        else:
            return False

if 0 < h < 1000の条件を追加し、有効値を絞りました。

さて、set_heightを修正したところでもう一度テストコードbmi_test.pyを実行してみましょう。
どきどきどきどき。

bash-3.2$ python -m unittest bmi_test.py
........
----------------------------------------------------------------------
Ran 8 tests in 0.001s

OK

オールグリーン!発進準備整いました!

リファクタリングしてみよう

グリーンとなったところで、リファクタリングをしてコードを読みやすくします。
今のプロダクションコードはこんな感じです。

# BmiCalculator.py

import re

class BmiCalculator:
    def __init__(self):
        pass

    def set_height(self, h):
        if re.match(r"^[\d]+[..]?[\d]?$", h):
            h = float(h)
            self.height = h
            if 0 < h < 1000:
                self.height = h
                return True
            else:
                return False
        else:
            return False

これをリファクタリングしていきます。
ざっとコードを見返して、もうちょっと綺麗にできそうと思ったところは以下2点。
・match関数の第一引数に条件を直書きしていて雑多に見える。
・条件分岐のネストが深い。

上記2点を考慮し、ソースをリファクタリングしたものが以下になります。

# BmiCalculator.py

import re

class BmiCalculator:
    INPUT_PATTERN = r"^[\d]+[..]?[\d]?$"

    def __init__(self):
        pass

    def set_height(self, h):
        if not re.match(self.INPUT_PATTERN, h):
            return False
        
        h = float(h)
        if  not 0 < h < 1000:
            return False

        self.height = h
        return True

ちょっとすっきり。見やすくなりました。
でもソースコード変えちゃったので、動くか心配…。
というところで、もう一度テストコードを実行してみましょう!

bash-3.2$ python -m unittest bmi_test.py
........
----------------------------------------------------------------------
Ran 8 tests in 0.001s

OK

圧倒的安心感。

まとめ

今回の記事では以下のことを学習しました。
テスト駆動開発で開発をし、ブラックボックステストを行う。
テスト駆動開発の流れは、1.レッド→2.グリーン→3.リファクタリングの順に行う。
・まず何のテストを行うかを整理することで、プロダクションコードでの実装に抜け漏れがなくなる。
単体テストを行い確実にシステムの仕様通りのコードであることを確認。
ソースコードに変更を加えても、テストコードを実行することにより動作の保証ができる。

テスト項目を整理し、それに合わせて実装、テストをすることにより、自分のコードが正しく動く自信が持てますね。
こういうことをちゃんと普段から意識するようにしなければ。
逆にちゃんと意識するようになれば、もうあんな不安な日々は過ごさなくて良いと!

次回は引き続き残りの部分を実装し、システムを完成させます!

お知らせ

株式会社スカイウイルでは、2021年度の新卒採用を行います。
私たちは、独自の教育研修制度によって、学習したことを自信をもって社内外に発信できる。
そんなエンジニア集団です。

弊社採用サイトやマイナビ2021にて情報を公開しておりますので、
興味のある方は是非一度、会社説明会へお越しください!

・採用サイト
 株式会社スカイウイル 採用サイト

マイナビ2021
 (株)スカイウイルのインターンシップ・会社概要 | マイナビ2021