
pytestでログ出力をテストする
はじめに
データ事業本部のkobayashiです。
Pythonでアプリケーションを開発していると、ログ出力が正しく行われているかをテストしたい場面があります。しかし、標準的な方法ではログのテストは意外と面倒で、caplog
フィクスチャを使ったり、モックを作成したりと、コードが複雑になりがちです。
今回は、ログのテストを簡単に記述できるlogassert
というpytestプラグインを試してみました。
logassertとは
logassertは、Pythonのユニットテストでログの検証を簡単に行うためのライブラリです。pytestとunittestの両方に対応しており、直感的な構文でログメッセージの存在や内容を確認できます。
主な特徴とs亭は以下のようなものがあります。
- pytestのフィクスチャとして
logs
を提供 - 文字列の部分一致、完全一致、正規表現でのマッチングをサポート
- ログレベルごとの検証が可能
- 構造化ログ(structlog)にも対応
- わかりやすいエラーメッセージ
logassertを使ってみる
では早速logassertを使ってpytestでテストを実行してみます。
環境
今回使用した環境は以下の通りです。
Python 3.11.5
pytest 7.4.3
logassert 8.4
インストール
pipで簡単にインストールできるので以下のコマンドを実行します。
$ pip install logassert
基本的な使い方
まず、ログを出力する簡単な関数を用意します。
import logging
logger = logging.getLogger(__name__)
def calculate_discount(price: float, discount_rate: float) -> float:
"""商品の割引価格を計算する"""
logger.debug(f"価格計算開始: 元価格={price}, 割引率={discount_rate}")
if price < 0:
logger.error(f"不正な価格が指定されました: {price}")
raise ValueError("価格は0以上である必要があります")
if not 0 <= discount_rate <= 1:
logger.warning(f"割引率が範囲外です: {discount_rate}")
discount_rate = max(0, min(1, discount_rate))
logger.info(f"割引率を調整しました: {discount_rate}")
discounted_price = price * (1 - discount_rate)
logger.info(f"割引価格を計算しました: {discounted_price}円")
return discounted_price
if __name__ == "__main__":
print(calculate_discount(1000, 0.25))
print(calculate_discount(1000, 0.75))
商品価格と割引率を渡すと戻り値で割引後の価格を返す単純な関数です。
$ python main.py
750.0
250.0
この関数のログ出力をテストしてみます。
記述は簡単でlogs
をテスト関数の引数として渡すだけで実現可能です。
実際のログ内容をテストするにはテスト関数内でlogs.{ログレベル}
にメッセージが格納されているのでin
でチェックをします。
import pytest
from main import calculate_discount
def test_normal_calculation(logs):
"""正常な計算時のログを検証"""
result = calculate_discount(1000, 0.2)
# debugログの確認
assert "価格計算開始: 元価格=1000, 割引率=0.2" in logs.debug
# infoログの確認
assert "割引価格を計算しました: 800.0円" in logs.info
# 複数のログレベルをまとめて確認
assert "価格計算開始" in logs.any_level
def test_invalid_price(logs):
"""不正な価格の場合のエラーログを検証"""
with pytest.raises(ValueError):
calculate_discount(-100, 0.2)
# errorログの確認
assert "不正な価格が指定されました: -100" in logs.error
def test_out_of_range_discount(logs):
"""範囲外の割引率の場合の警告ログを検証"""
result = calculate_discount(1000, 1.5)
# warningログの確認
assert "割引率が範囲外です: 1.5" in logs.warning
# infoログで調整されたことを確認
assert "割引率を調整しました: 1" in logs.info
$ pytest -v
...
test_main.py::test_normal_calculation PASSED [ 33%]
test_main.py::test_invalid_price PASSED [ 66%]
test_main.py::test_out_of_range_discount PASSED [100%]
このように非常に簡単にテストできます。
高度な使い方
完全一致での検証
次に部分一致ではなく、完全一致で検証したい場合はExact
を使います。
from logassert import Exact
def test_exact_match(logs):
"""ログメッセージの完全一致を検証"""
calculate_discount(1000, 0.2)
# 完全一致での検証
assert Exact("割引価格を計算しました: 800.0円") in logs.info
def test_exact_match_エラー(logs):
# 以下は部分的にしか一致しないので失敗する
assert Exact("800.0円") in logs.info # AssertionError
$ pytest -v
...
test_main.py::test_normal_calculation PASSED [ 20%]
test_main.py::test_invalid_price PASSED [ 40%]
test_main.py::test_out_of_range_discount PASSED [ 60%]
test_main.py::test_exact_match PASSED [ 80%]
test_main.py::test_exact_match_エラー FAILED [100%]
Exact
を使うことでログで出力されるメッセージを完全一致で検証することができます。
複数の文字列を含むログの検証
Multiple
を使うと、複数の文字列を含むログを検証できます。
from logassert import Multiple
def test_multiple_strings(logs):
"""複数の文字列を含むログを検証"""
calculate_discount(1000, 0.2)
# "元価格"と"1000"の両方を含むログがあることを確認
assert Multiple("元価格", "1000") in logs.debug
ログが出力されていないことの検証
NOTHING
を使うと、特定のレベルでログが出力されていないことを確認できます。
from logassert import NOTHING
def test_no_warnings(logs):
"""警告が出ないケースを検証"""
# 正常な割引率なので警告は出ない
calculate_discount(1000, 0.3)
# warningログが出ていないことを確認
assert NOTHING in logs.warning
構造化ログ(structlog)のサポート
logassertは構造化ログライブラリであるstructlogにも対応しています。
import structlog
logger = structlog.get_logger()
def process_order(order_id: str, amount: float):
"""注文を処理する"""
logger.info("order_processing_started", order_id=order_id, amount=amount)
# 何か処理...
logger.info(
"order_processed",
order_id=order_id,
amount=amount,
status="completed",
processing_time=0.523,
)
if __name__ == "__main__":
print(process_order("item-a", 10))
print(process_order("item-b", 50))
構造化ログはStruct
やCompleteStruct
で検証できます。
from logassert import Struct, CompleteStruct
from main_structlog import process_order
def test_structured_log(logs):
"""構造化ログの検証"""
process_order("ORD-12345", 5000)
# 部分的な構造の一致を確認
assert Struct("order_processing_started", order_id="ORD-12345") in logs.info
# 複数のフィールドを確認
assert (
Struct("order_processed", order_id="ORD-12345", status="completed") in logs.info
)
# 完全な構造の一致を確認(全フィールドが一致する必要がある)
assert (
CompleteStruct(
"order_processed",
order_id="ORD-12345",
amount=5000,
status="completed",
processing_time=0.523,
)
in logs.info
)
Struct
はメッセージとその他のフィールドを含む構造があることをチェックします。一方CompleteStruct
は指定されたメッセージとフィールドが全て一致することを確認できます。
テスト実行
実際にテストを実行してみます。
$ pytest -v
============================= test session starts ==============================
collected 3 items
test_main.py::test_normal_calculation PASSED [ 33%]
test_main.py::test_invalid_price PASSED [ 66%]
test_main.py::test_out_of_range_discount PASSED [100%]
============================== 3 passed in 0.05s ===============================
ログの検証が失敗した場合は、わかりやすいエラーメッセージが表示されます。
$ pytest test_main.py::test_exact_match -v
________________________ test_exact_match_エラー _________________________
logs = <logassert.logassert.FixtureLogChecker object at 0x105207410>
def test_exact_match_エラー(logs):
# 以下は部分的にしか一致しないので失敗する
> assert Exact("800.0円") in logs.info # AssertionError
E AssertionError: assert for Exact('800.0円') in INFO failed; no logged lines at all!
test_main.py:54: AssertionError
まとめ
logassertを使うことで、ログのテストを直感的で読みやすいコードで記述できることがわかりました。特に以下の点が便利だと感じました。
in
演算子を使った自然な記法でログを検証できる- 部分一致、完全一致、正規表現など、様々な検証方法が用意されている
- エラーメッセージがわかりやすく、デバッグしやすい
- structlogなどの構造化ログにも対応している
ログ出力はアプリケーションの動作を理解する上で重要な要素です。logassertを使えば、ログの出力も含めて包括的なテストが書けるようになり、より信頼性の高いアプリケーションを開発できるでしょう。
最後まで読んで頂いてありがとうございました。