以下の内容はhttps://nealle-dev.hatenablog.com/entry/2025/06/18/110031より取得しました。


構造化ログのユニットテストでassertLogsが効かない理由とStringIOを使った解決策

こんにちは。 Nintendo Switch 2 の抽選予選に無事コマを進め続けている、SREチームの高 (@nogtk) です。早く当選してこの大会からドロップアウトしたいものです。

今回は、構造化ログのユニットテストを書いたときのプチハマりと、それを解決した方法についてご紹介したいと思います。

なぜログのテストコードを書いたか

前回のブログで、Django アプリケーションが出力するログを構造化したお話をご紹介致しました。

nealle-dev.hatenablog.com

ログを構造化するにあたって、出力されるログのエントリを定義しそれに沿ってログが出力されるような実装を追加しています。その中には Datadog APM への連携するためのトレース・スパン情報の付加したり、structlog の contextvars というモジュールを使ってコンテキストをログエントリに含めたりなど、徐々に込み入った実装が増えてきました。 基本的には関連するライブラリが提供している方法に準拠して実装を行っているだけではありますが、とはいえ出力結果が期待値通りであることは確認したいところです。

加えて、ログ出力がデグレを起こしていることを人間が発見するのは非常に難しいです。人間の目はJSONを良い感じにパースしてくれませんし、各エントリの差分やメッセージフォーマットの変化に気づくのは容易ではありません。

そこで、構造化ログの出力をテストするユニットテストを記述してみることにしました。

前提

今回のテストコードは Python の unittest を使って記述しています。 また構造化ロギングするにあたってのロガーの設定は Python の DictConfig 形式を利用していて、formatter / handler を以下のように設定しています。

{
    'formatters': {
      'json': {
            '()': 'structlog.stdlib.ProcessorFormatter',
            "processor": structlog.processors.JSONRenderer(ensure_ascii=False),
        },
    },
    'handlers': {
        'json': {
            'class': 'logging.StreamHandler',
            'formatter': 'json',
        },
    },
    'loggers': {
        'struct_log_logger': {
            'handlers': ['json'],
            'level': 'INFO',
            'propagate': True,
        },
    },
}

assertLogs を使ったテストコード

今回は実際のログ出力をテスト対象にしたいので、例えば以下のようにモックを使ったログ呼び出しに対する引数のチェックでは不十分です。 呼び出されたことではなく、呼び出された結果、意図するフォーマットでログが出力されていることを担保したいためです。

def test_logger_info_called_with_hoge(self):
    # Arrange
    _logger = mock.Mock()

    # Act
    _logger.info("hoge")

    # Assert
    _logger.info.assert_called_once_with("hoge")

ログ出力まで含めてテストコードを書くには、unittest の assertLogs を使うのが王道でしょう。 以下のように、with 文を使用してコンテキストマネージャーとして assertLogs を実行し、ログの出力を捕捉します。assertLogs は指定されたロガーのログメッセージをキャプチャし、cm (context manager) オブジェクトを通じてアクセス可能にします。

def test_non_struct_log_info(self):
    # Act
    logger = logging.getLogger('non_struct_log_logger')
    with self.assertLogs('non_struct_log_logger', level='INFO') as cm:
        logger.info('hoge')

    # Assert
    self.assertIn('hoge', cm.output[0])

これに倣って、構造化ログについても assertLogs を使って検査してみます。 以下のようなテストコードですね。パッと見よさげです。

def test_struct_logger_json_output(self):
    # Act    
    logger = logging.getLogger('struct_log_logger')
    with self.assertLogs('struct_log_logger', level='INFO') as cm:
        logger.info('hoge')

    # Arrange
    log_data = json.loads(cm.output[0])
    self.assertEqual(log_data['message'], 'hoge')
    self.assertEqual(log_data['level'], 'info')
    self.assertEqual(log_data['logger'], 'struct_log_logger')

しかし、このテストコードを実行すると json.loads の部分で JSON パースに失敗します。 補足している cm.output[0] を出力させると、

INFO:struct_log_logger:hoge

という出力が得られ、構造化ログが出力されていません。

当然、同じロガーを使って python shell からログを発行すると構造化されたログが出力されます。

>>> logger = logging.getLogger('struct_log_logger')
>>> logger.info("hoge")
{"level": "info", "logger": "struct_log_logger", "timestamp": "2025-06-14T09:47:03.021630+09:00", "message": "hoge"}

assertLogs はログをどう捕捉しているのか

assertLogs の実装を確認しに行きます。

https://github.com/python/cpython/blob/56eabea056ae1da49b55e0f794c9957008f8d20a/Lib/unittest/_log.py#L28

この _AssertLogsContext クラスで補足をしています。 コンテキストマネージャーのエントリで呼ばれる __enter__ メソッドの実装を一部抜粋します。

def __enter__(self):
    formatter = logging.Formatter(self.LOGGING_FORMAT)
    handler = _CapturingHandler()
    handler.setLevel(self.level)
    handler.setFormatter(formatter)
    ...
    logger.handlers = [handler]
    ...
    return handler.watcher

ログの formatter および handler を上書きしていますね。 おさらいですが、構造化ロギングの設定は、formatter / handler を JSON 出力するように変更することで実現しています。 それらの設定が assertLogs によって上書きされてしまったことで、結果構造化されたログを出力することができず、テストに失敗していました。

解決策: ログストリームを StringIO に差し替える

assertLogs が使えないことがわかったので、別のアプローチとして今回はログストリームを StringIO に差し替えてテストを行うようにしました。

setUp で構造化ロギング用の handler の stream を StringIO に差し替えます。

def setUp(self):
    # ログ出力をテストできるように出力先を StringIO に変更
    logging_config = settings.LOGGING.copy()
    self.log_output = StringIO()
    logging_config['handlers']['json']['stream'] = self.log_output
    # ログ設定を上書き
    logging.config.dictConfig(logging_config)

このように差し替えることで当初の想定通り、JSON として出力されたログエントリに対する検査が記述できるようになりました。

def test_basic_log_format(self):
    # Act
    logging.getLogger('struct_log_logger').info("test message")

    # JSON パース
    log_output = self.log_output.getvalue().strip()
    log_data = [json.loads(line) for line in log_output.split('\n')]

    # Assert
    self.assertEqual(log_data.get('level'), 'info')
    self.assertEqual(log_data.get('logger'), 'struct_log_logger')
    self.assertEqual(log_data.get('message'), 'test message')

他の例として、冒頭で紹介した structlog の contextvars を使ったログエントリの追加についても同様にテストを記述することが出来ます。いい感じです。

def test_contextvars(self):
    # Arrange
    structlog.contextvars.bind_contextvars(
        additional_key='additional_value',
    )

    # Act
    self.logger.info("test message")

    # JSON パース
    log_output = self.log_output.getvalue().strip()
    log_data = [json.loads(line) for line in log_output.split('\n')]

    # Assert
    self.assertEqual(log_data.get('additional_key'), 'additional_value')

終わりに

今回は構造化ログのテストで遭遇した問題と解決策について紹介しました。 assertLogs の動作を理解するために Python の標準ライブラリのソースコードを読み、単にAPIを使うだけでは気づけない制約を理解することが出来ました。

かなりニッチな内容だったかと思いますが、未来の誰かの一助になれば幸いです!




以上の内容はhttps://nealle-dev.hatenablog.com/entry/2025/06/18/110031より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14