【小ネタ】pytest-bddを使ってみた

はじめに

今回は pytest-bdd ついて紹介します。
名前の通り BDD(Behavior Driven Development, テスト駆動開発)を pytest 上で行うためのツールです。

テスト駆動開発というと、少し取っ掛かりにくいイメージが先行しがちですが「テストの可読性を上げたり、テストファイルを詳細なドキュメントの代わりする」といった運用ができそうで面白かったので、そのあたりを紹介したいと思います。

pytest-bddとは

一言で言うと「pytestのプラグインとして動作するBDDフレームワーク」 です。

PythonのBDDツールとしては Behave が有名ですが、pytest-bdd はその名の通り pytest の上で動くのが最大の特徴です。

もし既にpytestを利用している環境であれば、xdistによる並列化やcovでのカバレッジ計測なども含めて既存のpytestコマンドで実行できるのが最大のメリットです。

また、これから環境を作成する場合でも、Behaveと比べてpytestの強力なフィクスチャを利用できるというメリットがあります。

前提知識

とりあえず、実装を見てもらうのが一番わかりやすいと思うのですが、その前に最低限の前提知識を書いておきます。pytest-bddでは Gherkin(ガーキン)記法 と呼ばれる記法を利用します。

Gherkin 記法

Gherkin 記法は、自然言語に近い形式でテストケースを書くための記法です。
.feature という拡張子のファイルを使い、テストの対象(What)と実装(How)を分離することで、エンジニア以外でも読める仕様書としても機能します。

テストファイル(.feature)には以下の要素を含みます

  • Feature:機能の概要
  • Scenario:具体的なテストシナリオ
  • Given:前提条件(初期状態)
  • When:操作・アクション
  • Then:期待される結果

テストファイル(.feature)には具体的なプログラムコードを紐づけません。
紐づけを分割管理することで、テストファイルは非エンジニアでも読める仕様書となります。

pytest

Python標準の unittest よりも簡潔に書ける、デファクトスタンダードなテストランナーです。
assert 文だけでテストが書ける手軽さと、前述の「フィクスチャ」によるセットアップ/ティアダウンの管理が便利です。
また、pytest-xdistによる並列化やpytest-covでのカバレッジ計測など、様々なプラグインを利用可能なのも非常に大きなメリットです。

実践

それでは、実際に書いて試してみます。
今回のディレクトリ構造は以下のようになっています。必要に応じて解説します。

root/
   ├── src/ 
   │  └── cli.py # アプリケーション本体 
   ├── tests/ 
   │  ├── init.py 
   │  ├── conftest.py # テスト共通設定 
   │  ├── features/ 
   │  │  └── pywc.feature # 仕様書 (Gherkin) 
   │  └── step_defs/ 
   │     ├── init.py 
   │     └── test_pywc.py # テスト実装 
   ├── pytest.ini 
   └── requirements.txt

題材

今回はテキストファイルの行数や単語数をカウントするLinuxコマンド wc の簡易版(pywc)を作成します。以下の「仕様」をBDDで定義し、実装していきます。

仕様

・コマンドライン引数でファイルパスを受け取る
・オプションなしの場合、行数を表示する
・–words オプションをつけると、単語数を表示する

環境構築

python3 -m venv venv
source venv/bin/activate
pip install pytest pytest-bdd

仕様書(Feature)を書く

tests/features/pywc.feature に、Gherkin記法 で仕様を書きます。
Given(前提)、When(操作)、Then(結果)の3段構成で記述します。

Feature: テキストカウント機能

  Scenario: デフォルトでは行数をカウントする
    Given "lines.txt" という名前で、中身が "One\nTwo\nThree" のファイルがある
    When コマンド "python src/cli.py lines.txt" を実行する
    Then 標準出力に "3" が表示される

  Scenario: オプション指定で単語数をカウントする
    Given "words.txt" という名前で、中身が "A B C D E" のファイルがある
    When コマンド "python src/cli.py --words words.txt" を実行する
    Then 標準出力に "5" が表示される

エンジニアでなくとも、誰でも読めると思います。

テストコード(Steps)の実装

次に、作成した仕様書をPythonコードに対応させます。
最初に見たときはAIでいい感じにテストしてくるのかなと思っていましたが、結構力技です
今回はCLIツールをテストするので、subprocess を使って実際にコマンドを叩いて確認します。

import subprocess
import os
from pathlib import Path
from pytest_bdd import scenarios, given, when, then, parsers
import pytest

# Featureファイルを読み込む
scenarios('../features/pywc.feature')

@pytest.fixture
def context():
    """ステップ間でデータを受け渡すための辞書"""
    return {}

@given(parsers.parse('"{filename}" という名前で、中身が "{content}" のファイルがある'))
def create_simple_file(filename, content, tmp_path):
    p = tmp_path / filename
    p.write_text(content.replace('\\n', '\n'), encoding='utf-8')
    os.chdir(tmp_path)

@when(parsers.parse('コマンド "{command}" を実行する'))
def run_command(command, context):
    project_root = Path(__file__).parents[2]
    
    cmd_parts = command.split()
    if cmd_parts[1] == "src/cli.py":
        cmd_parts[1] = os.path.join(project_root, "src", "cli.py")
    
    result = subprocess.run(
        cmd_parts,
        capture_output=True,
        text=True
    )
    context['result'] = result

@then(parsers.parse('標準出力に "{expected}" が表示される'))
def verify_output(expected, context):
    stdout = context['result'].stdout.strip()
    assert expected in stdout, f"Expected '{expected}' in '{stdout}'"

@given, @when, @thenで.featuresに記述したテストに対応する関数を作成します。

tmp_path は pytest が標準で提供している組み込みフィクスチャです。これを引数に書くと自動的に「テスト用の一時ディレクトリ」を作成し、関数に渡してくれます。テストが終われば自動的に削除されます。

context はコード内で自分で定義したフィクスチャ です。
@pytest.fixture でデコレーションした関数名 (context) を、他のステップ関数の引数として書くと、pytest がその関数の戻り値を自動的に渡してくれます。
BDDではステップが関数として分断されているため、ローカル変数が共有できません。そのため、このように変数となるフィクスチャを用意してデータを渡します。

アプリケーションの実装

テストの仕様を満たすようなアプリケーションを実装します

import argparse
import sys
import os

def main():
    parser = argparse.ArgumentParser(description="pywc")
    parser.add_argument("file", help="Input file path")
    parser.add_argument("--words", action="store_true", help="Count words instead of lines")
    
    args = parser.parse_args()
    
    if not os.path.exists(args.file):
        print(f"Error: {args.file} not found", file=sys.stderr)
        sys.exit(1)

    with open(args.file, 'r', encoding='utf-8') as f:
        content = f.read()

    if args.words:
        count = len(content.split())
    else:
        count = len(content.splitlines())

    print(count)

if __name__ == "__main__":
    main()

テスト実行

pytestコマンドで、正しくテストが通るか確認します。

(venv)$ pytest -v
================================================= test session starts ==================================================
platform linux -- Python 3.11.2, pytest-9.0.1, pluggy-1.6.0 -- /home/mix64/pytest-bdd/venv/bin/python3
cachedir: .pytest_cache
rootdir: /home/mix64/pytest-bdd
plugins: bdd-8.1.0
collected 2 items

tests/step_defs/test_pywc.py::test_デフォルトでは行数をカウントする PASSED                                       [ 50%]
tests/step_defs/test_pywc.py::test_オプション指定で単語数をカウントする PASSED                                   [100%]

================================================== 2 passed in 0.04s ===================================================

.featureファイルに書いた「日本語」通りにアプリケーションが動作していることが確認できました。

まとめ

今回はpytest-bddを触ってみるということで、簡単なアプリケーションのテストを実装してみました。

今回のような CLIツールのブラックボックステスト や、Web APIのE2Eテスト のように、「入力と出力が明確に正解を持つ」対象のテストには最適だと思います。

また、.featureファイルは任意の自然言語で書くことができ、誰が読んでもわかりやすい仕様書として機能します。当然.featureファイルとPythonのStep定義ファイルの2つを管理する必要があるため、単純な単体テストに比べるとコード量は増えますが、長期で運用する場合は機能変更時に仕様書も更新されるのが強要されるため、管理コストの改善につながると思います。

よくある例としては、リリース時は仕様書とコードが一致してたのに、運用と改修を続けていくとコードばかり変更されて仕様書は放置、いざ大規模改修しようとしたら全然仕様書とコードが違う、みたいなケースを避けられます。

そのため「テストコードが読みにくい」「仕様書がメンテされない」などといった悩みがあるなら、これが解決策の一つになるのではないかなと思います。

シェアする

  • このエントリーをはてなブックマークに追加

フォローする