単体テストを学ぼう!③ -テストしたモジュールを1つのシステムにしてみる-

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

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

健康診断を受けてきました。
腹囲を測ったら去年よりも5cm大きくなってました。

・・・まじか。

さて、前回の記事に引き続き今回もテスト駆動開発で未実装の部分を実装します。
そして、今回はそれらを1つのシステムとして完成させます!

前回のおさらい

前回はどんなシステムを作るか仕様を決め、それに沿ってテスト駆動開発という手法で単体テストを行いながら実装を行なっていました。
そして、前回は1の部分までを実装済みです。

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

 2. 入力された身長(cm)と体重(kg)からBMIを計算する。

今回は復習も兼ねて2の部分を実装していこうと思います。

BMI計算部分の実装

復習ということでさくさくっと実装していきます!
前回と同じく、テストコードを書いていきます。
2の工程では計算が正しくできていることをテストします。
今回も上記の「入力された身長(cm)と体重(kg)からBMIを計算する」というだけではテストコードを書くのに不十分と気づいたため、仕様を少し詳細に決定しました。

 2. 入力された身長(cm)と体重(kg)からBMIを計算する。
   ・BMIは、一般的な定義に基づいて算出する。(体重(kg) / 身長(m)^2)  
   ・計算したBMIは小数点第二位で四捨五入し、小数点第一位までを返す。  

…早くこういう仕様を最初から決められるようになりたい。

また、入力値は前回まででテスト済みのため、今回のテストでは観点は以下としています。
・取りうる最小の値での計算結果は正しいか
・取りうる最大の値での計算結果は正しいか
・取りうる値の代表値での計算結果は正しいか
・メソッドが呼ばれなかった際の処理は正しいか

この観点でテストコードを書くと以下のようになります。

# 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_calc_01(self):
        bmiCalc = BmiCalculator()
        bmiCalc.set_height("0.1")  # 取りうる値の最小値を入力
        bmiCalc.set_weight("0.1")
        self.assertEqual(bmiCalc.calc(), 100000)

    def test_calc_02(self):
        bmiCalc = BmiCalculator()
        bmiCalc.set_height("999.9")  # 取りうる値の最小値を入力
        bmiCalc.set_weight("999.9")
        self.assertEqual(bmiCalc.calc(), 10.0)

    def test_calc_03(self):
        bmiCalc = BmiCalculator()
        bmiCalc.set_height("170")  # 一般的な日本人男性の身長と体重
        bmiCalc.set_weight("60")
        self.assertEqual(bmiCalc.calc(), 20.8)

    def test_calc_04(self):
        bmiCalc = BmiCalculator()
        bmiCalc.set_height("170")  # set_weightメソッドが呼ばれなかった場合
        self.assertIsNone(bmiCalc.calc())

    def test_calc_05(self):
        bmiCalc = BmiCalculator()
        bmiCalc.set_weight("60")  # set_heightメソッドが呼ばれなかった場合
        self.assertIsNone(bmiCalc.calc())

    def test_calc_06(self):
        bmiCalc = BmiCalculator()  # set_weightメソッド、set_heightメソッドが呼ばれなかった場合
        self.assertIsNone(bmiCalc.calc())

それぞれのBMIを計算して気づいたのですが、身長と体重が同じでも身長が高ければ高いほど痩せ気味で低ければ低いほど太り気味になるんですね。
計算式見れば当たり前と思う方もいるかもですが、私はここで初めて気づきました。
ではプロダクションコードで実装する前に、テストコードを実行します。

bash-3.2$ python -m unittest bmi_test.py
EEEEEE
…python
(省略)
======================================================================
ERROR: test_calc_06 (bmi_test.TestBmiCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/XXXX/work/UnitTest/test/bmi_test.py", line 106, in test_calc_06
    self.assertIsNone(bmiCalc.calc())
AttributeError: 'BmiCalculator' object has no attribute 'calc'

----------------------------------------------------------------------
Ran 6 tests in 0.001s

FAILED (errors=6)

レッドであることを確認できました。
では、テストを通るようにBMI計算部分を実装していきます。

# BmiCalculator.py
import re
import math

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

    def __init__(self):
        self.height = None
        self.weight = None
        self.bmi = None

    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


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

        self.weight = w
        return True
    
    def calc(self):
        if(self.height is None or self.weight is None):
            return None
        
        return round(self.weight / (self.height / 100) ** 2, 1)

新たにcalc関数を追加し、heightweightに値が入っていない場合は何も返さず、値が設定されていた時のみBMIを計算して返します。
また、__init__関数で、明示的にそれぞれの初期値をNoneに設定しておきます。
そしてテストコードを実行。

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

OK

エラーなし!失敗なし!
これで全ての機能のテストと実行が完了しましたやったーーーーー!!!

システムを完成させよう

と、ここまでやったら満足しがちな私ですが、
これだとそれぞれの機能のモジュールを作っただけになってしまいます。

せっかくテストして動作確認を行なったコードが書けたのだから、
システムとして動かせるようにしたい。
最後に作成したモジュールを繋げるmain.pyを書いて、ちゃんとシステムとして完成させましょう!

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

if __name__ == '__main__':
    
    # BmiCalculatorの生成
    bmi = BmiCalculator()

    # isHeightSuccessの初期値設定
    isHeightSuccess = False

    # isHeightSuccessがTrueになる(有効値が入力される)まで繰り返す
    while(not isHeightSuccess):
        print('Input height(cm) = ')
        height = input()
        isHeightSuccess = bmi.set_height(height)
    
    # isWeightSuccessの初期値設定
    isWeightSuccess = False

    # isWeightSuccessがTrueになる(有効値が入力される)まで繰り返す
    while(not isWeightSuccess):
        print('Input weight(kg) = ')
        weight = input()
        isWeightSuccess = bmi.set_weight(weight)
    
    # BMIの計算
    bmi_value = bmi.calc()

    # BMIが正しく計算されなかった場合はメッセージを表示して終了
    if(bmi_value is None):
        print("Please input height and weight")
        exit()
    
    # 入力された身長・体重と、計算されたBMIを表示
    print('height(cm): {}  weight(kg): {}'.format(height, weight))
    print('BMI: {}'.format(bmi_value))

こんな感じでmain.pyを書いてみました。
身長・体重ともに、有効値を入力したらTrue, 無効値を入力したらFalseを返す仕様にしていたため、有効値が入力されるまで入力メソッドをループさせます。
また、もし今後仕様変更があった場合を考慮し、BMIが正しく計算されなかった場合の処理を入れています。
これが動けば、どんなに単純なものでも立派なシステムよ…!
期待と不安の入り混じった気持ちで実行。

bash-3.2$ python main.py
Input height(cm) = 
157.8
Input weight(kg) = 
53.8
height(cm): 157.8  weight(kg): 53.8
BMI: 21.6

動いたアアアアアアアアアア!!!!!!!!!!!!!!

最後に

テストのテの字も知らずスクリプトを組んでは不安になっていた私ですが、今回テストのことを勉強して、その不安の払拭方法を知ることができました。
テスト…偉大だ…。

また、テスト駆動開発なるテストファーストな手法で行うと、今どんなシステムを作ろうとしているのか、機能は何が必要なのかを明確化でき、開発途中で迷子にならずに最後までシステムが構築できたかと思います。
何よりテストをすることにより不安が払拭され心理的負担が減るのが本当に大きいです。

今回例に出したシステムは非常に単純なシステムだったので、どんな観点でテストを行うかが考えやすかったのですが、
現場に出るともっと大規模なシステムや複雑な処理を要するプログラムだったりして、
 ・何が必要なのか
 ・どのような要件なのか
が迷子になりがちだったりします。

そのため、常に念頭に置きながらスクリプトを組むと、
おのずとテストの観点もわかり自分のスクリプトに安心できるのではないかなあと思いました。

お知らせ

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

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

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

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