BFT名古屋 TECH BLOG

日々の業務で得た知識を所属するエンジニアたちがアウトプットしていきます。

【Python】【pytest】第二弾 カバレッジを上げるためにやったこと編

こんにちは!BFT名古屋支店の猫です。

先日初めてpytestを使用してみたので、学んだことを3記事に分けてまとめました。
今回は 第二弾 カバレッジを上げるためにやったこと編 として、カバレッジを100%に近づけるために実施した方法をご紹介します。
pytestをこれから使う/最近使い始めた方の参考になればと思います。

第一弾 導入~カバレッジ測定編 はこちら

カバレッジとは

カバレッジとは、網羅率を表す言葉です。
この記事では、C0カバレッジと呼ばれる命令網羅率(コード内の全ての処理のうちどれだけ実行したか)を指します。 service.shiftinc.jp

環境

  • Ubuntu 18.04.5 LTS (WSL1)
  • Python 3.8.10
  • pytest実行やカバレッジ測定に必要なライブラリ
    • pytest 6.2.5
    • coverage 5.5
    • pytest-cov 2.12.1
    • pytest-mock 3.6.1

テスト対象コード

例として、引数の2整数に対して四則演算した結果を返すプログラムを作りました。

●Arithmetic.py

import json
import os
from datetime import datetime as dt

from lib import ErrorClass as ec

### 現在日時を取得する関数
def get_date():
    return dt.now().strftime('%Y-%m-%d %H:%M:%S')

### 引数で指定された演算を行う関数
def calc(var1:int, var2:int, method:str):
    if method == 'add':
        return var1 + var2
    elif method == 'sub':
        return var1 - var2
    elif method == 'mult':
        return var1 * var2
    elif method == 'div':
        return var1 / var2

###
### メイン関数:引数で指定された2整数の四則演算の結果を出力する
###
def main(var1:int, var2:int):

    result = dict()
    result['date'] = get_date()
    try:
        if type(var1) != int or type(var2) != int:
            raise ec.TypeError

        result['sum'] = calc(var1, var2, 'add')
        result['diff'] = calc(var1, var2, 'sub')
        result['prod'] = calc(var1, var2, 'mult')
        result['quot'] = calc(var1, var2, 'div')

    except ec.TypeError as e:
        result['error_code'] = e.__class__.__name__
    
    except:
        result['error_code'] = 'MyException'

    # 結果書き込み処理
    if not os.path.isfile('result.json'):
        with open('result.json', 'w') as fp:
            json.dump(result,fp)

    return result

●ErrorClass.py

# エラークラスの定義
class TypeError(Exception):
    def error_code(self):
        pass

カバレッジ測定結果(Before)

第一弾 導入~カバレッジ測定編 で測定したカバレッジは 88% でした。

f:id:bftnagoya:20220118204319p:plain
カバレッジ測定結果(Before)

カバレッジを上げるためにしたこと

①引数の組み合わせを考えて実行する

まずは、引数の組み合わせを考えてテストを作成します。
仕様書・コードとにらめっこして、どの引数なら狙ったコードを通るのかを検討します。
DBの値を取得して制御に使用する場合は、DBとにらめっこもしながらテストケースを作成していきます。

カバレッジ測定結果(Before)では 42~43行目の例外をキャッチする部分が実行されていなかったので、以下のように例外が起こるテストを追加します。

# 正常な引数を渡し、例外が発生するテスト
def test_Exception_success():
    res = Arithmetic.main(2,0)        # 2つ目の引数が0のため除算で例外が起こる
    assert res['error_code'] == 'MyException'

②テストの前後処理をする

テストコードをたくさん書いていると、以下のような問題に遭遇します。

  • DB上の同じレコードを対象にパラメータを変えてテストしたいのに、1つ目のテストを実行するとDBの状態が変わってしまい2つ目以降のテストが正しく実行できない
  • 新規ファイルを作成するテストを様々なパラメータでテストしたいのに、1つ目のテストを実行するとファイルが作成されてしまい2つ目以降のテストが正しく実行できない

カバレッジ測定結果(Before)では「result.json」というファイルが既に存在しているために、47~48行目のファイル出力をする部分が実行されませんでした。

このような時には 前後処理を行うことで各々のテストを正しい状態で実行することができます。

以下の例では テスト実行前に「result.json」というファイルが存在しない状態にしてからテストを実行しています。
また テスト実行後にも「result.json」というファイルが存在しない状態にすることで、後続のテストに影響がでないようにしています。

  • 事前処理:「result.json」というファイルが既にある場合削除する
  • テスト実行
  • 事後処理:出力ファイル(result.json)をリネームする
# 前後処理を定義してテストを行う
@pytest.fixture(scope='session', autouse=True)
def my_fixture():
    # 事前処理:出力ファイルが既にある場合削除する
    fname_old = '/mnt/c/Users/****/project_root_dir/result.json'
    if os.path.isfile(fname_old):
        os.remove(fname_old)

    # 事後処理:出力ファイル(result.json)をリネームする
    yield
    if os.path.isfile(fname_old):
        fname_new = fname_old[:-5] + dt.now().strftime('%Y%m%d_%H%M%S') + '.json'
        os.rename(fname_old, fname_new)

# ここがテストの本体
def test_use_fixture(my_fixture):
    res = Arithmetic.main(4, 5)
    assert res['sum'] == 9

③mockを使用して返り値を制御する(静的)

①、②を駆使しても、到達するのがめっちゃめんどくさい難しいコードがあります。

  • 現在の日付や時刻によって分岐する処理
  • 接続エラーやファイルの読み込みエラー等の例外処理 等

このような時にはpytest-mockというライブラリを使用し、メソッドの返り値を制御することで狙った部分を実行することができます。

$ pip install pytest-mock

例えば、Arithmetic.pyのget_date()の返り値を「2022年1月1日 1時1分1秒」にして実行したい場合、以下のようなテストを追加します。

# メソッドの返り値を静的に制御するテスト①
def test_mock_static(mocker):
    mocker.patch('Arithmetic.get_date', return_value='2022-01-01 01:01:01')
    res = Arithmetic.main(1,1)
    assert res['date'] == '2022-01-01 01:01:01'

また、Arithmetic.pyのget_date()で例外を発生させてテストしたい場合、以下のようなテストを実行します。

# メソッドの返り値を静的に制御するテスト②
def test_mock_static_2(mocker):
    mocker.patch('Arithmetic.get_date', side_effect=Exception)
    with pytest.raises(Exception):     # 異常終了をテストする際はassertではなくpytest.raisesを用いる
        Arithmetic.main(1,1)

④mockを使用して返り値を制御する(動的)

複数回呼ばれ、それぞれ違う返り値を返すメソッドの返り値を 動的に制御したい場合があります。
そんな時には テスト関数内で関数を定義し、moker.patchのside_effectに定義した関数を指定することで実現できます。

以下の例ではArithmetic.pyのcalc()をモックしています。

# メソッドの返り値を動的に制御するテスト
def test_mock_dynamic(mocker):

    def mock_calc(var1:int, var2:int, method:str):
        if method == 'add':
            return 1
        elif method == 'sub':
            return 2
        elif method == 'mult':
            return 3
        elif method == 'div':
            return 4
    
    mocker.patch('Arithmetic.calc', side_effect=mock_calc)
    res = Arithmetic.main(0,0)
    assert res['sum'] == 1

モックは実際にはありえない返り値を返すこともできてしまうので、使い方には注意が必要です。

カバレッジ測定結果(After)

①~④のテストを追加して再度カバレッジを測定すると、無事に100%になりました。 (やったー!)

$ pytest -v --cov=Arithmetic
========================= test session starts =========================
platform linux -- Python 3.8.10, pytest-6.2.5, py-1.11.0, pluggy-1.0.0 -- /home/****/.pyenv/versions/3.8.10/envs/prac_venv/bin/python3.8
cachedir: .pytest_cache
rootdir: /mnt/c/Users/****/project_root_dir/testcode_dir
plugins: cov-3.0.0, mock-3.6.1
collected 7 items

testcode_dir/test_Arithmetic.py::test_success PASSED            [ 14%]
testcode_dir/test_Arithmetic.py::test_TypeError PASSED          [ 28%]
testcode_dir/test_Arithmetic.py::test_Exception_success PASSED  [ 42%]
testcode_dir/test_Arithmetic.py::test_use_fixture PASSED        [ 57%]
testcode_dir/test_Arithmetic.py::test_mock_static PASSED        [ 71%]
testcode_dir/test_Arithmetic.py::test_mock_static_2 PASSED      [ 85%]
testcode_dir/test_Arithmetic.py::test_mock_dynamic PASSED       [100%]

---------- coverage: platform linux, python 3.8.10-final-0 -----------
Name                     Stmts   Miss  Cover
--------------------------------------------
code_dir/Arithmetic.py      33      0   100%
--------------------------------------------
TOTAL                       33      0   100%


========================== 7 passed in 0.17s ==========================

f:id:bftnagoya:20220120170748p:plain
カバレッジ測定結果(After)

おわりに

カバレッジが上がらなくて苦戦しているときは、知恵の輪にチャレンジしている気分でした。( 辛いけどクリアできた時の爽快感がクセになる。)
どうしても踏めないコードがある時は、死にコード* になっていないかもチェックしてみてくださいね。

*死にコード ・・・ 以下の3行目のように、論理的に到達しえないパス。

if flg == 0:
    if flg != 0:
        print ("(flg == 0)&&(flg != 0) は常にFalseのため、この行は実行されない") 

第三弾 つまづきポイント編 もお楽しみに!

参考

pytestでテストケースの前後処理をする - Qiita

qiita.com

note.crohaco.net

obataka.com