EmotionTechテックブログ

株式会社エモーションテックのProduct Teamのメンバーが、日々の取り組みや技術的なことを発信していくブログです。

tracing::info! の呼び出しのテストに用いる道具立て

はじめに

こんにちは、バックエンドエンジニアのよしかわです。

今回の題材は Rust で tracing::info! などを呼び出すコードのテストです。Rust のプログラムで処理の進捗やエラーを出力するのに tracingtracing::info!tracing::error! を使うケースは珍しくないと思います。このとき全ての出力をテストする必要はないかもしれません。しかし重要な出力についてテストしたい場合もあるのではないでしょうか。以下ではそのような場合に使えるかもしれない道具立てをいくつかご紹介します。

これはエモーションテック Advent Calendar 2023 19日目の記事です。

その1: tracing-mock

1つめは tracing 本体のテストにも使われている tracing-mock です。下記の例にあるような細かいテストが可能なようです。

use tracing::subscriber::with_default;
use tracing_mock::{subscriber, expect, field};

#[tracing::instrument]
fn yak_shaving(number_of_yaks: u32) {
    tracing::info!(number_of_yaks, "preparing to shave yaks");

    let number_shaved = number_of_yaks; // shave_all
    tracing::info!(
        all_yaks_shaved = number_shaved == number_of_yaks,
        "yak shaving completed."
    );
}

let yak_count: u32 = 3;
let span = expect::span().named("yak_shaving");

let (subscriber, handle) = subscriber::mock()
    .new_span(
        span.clone()
            .with_field(expect::field("number_of_yaks").with_value(&yak_count).only()),
    )
    .enter(span.clone())
    .event(
        expect::event().with_fields(
            expect::field("number_of_yaks")
                .with_value(&yak_count)
                .and(field::msg("preparing to shave yaks"))
                .only(),
        ),
    )
    .event(
        expect::event().with_fields(
            expect::field("all_yaks_shaved")
                .with_value(&true)
                .and(field::msg("yak shaving completed."))
                .only(),
        ),
    )
    .exit(span.clone())
    .only()
    .run_with_handle();

with_default(subscriber, || {
    yak_shaving(yak_count);
});

handle.assert_finished();
https://github.com/tokio-rs/tracing/tree/tracing-subscriber-0.3.18/tracing-mock#examples

ただし tracing-mock は現在(2023年12月13日)のところ crates.io から取得できません。README の Usage にあるような方法で GitHub から直接取得する必要があります。tracing-mock だけでなく tracing についても GitHub からの取得になるので Cargo.toml のメンテナンスが煩雑になりそうなのは懸念点です。API の安定性も気になります。そのため今のところはエモーションテック社内で tracing-mock を使用していません。

その2: tracing_test

2つめはサードパーティーのクレートの一つである tracing_test *1です。下記の例のような簡単なテストには tracing-mock より tracing_test の方が向いているかもしれません。

#[tokio::test]
#[traced_test]
async fn test_logs_are_captured() {
    // Local log
    info!("This is being logged on the info level");

    // Log from a spawned task (which runs in a separate thread)
    tokio::spawn(async {
        warn!("This is being logged on the warn level from a spawned task");
    })
    .await
    .unwrap();

    // Ensure that certain strings are or aren't logged
    assert!(logs_contain("logged on the info level"));
    assert!(logs_contain("logged on the warn level"));
    assert!(!logs_contain("logged on the error level"));

    // Ensure that the string `logged` is logged exactly twice
    logs_assert(|lines: &[&str]| {
        match lines.iter().filter(|line| line.contains("logged")).count() {
            2 => Ok(()),
            n => Err(format!("Expected two matching logs, but found {}", n)),
        }
    });
}
https://docs.rs/tracing-test/0.2.4/tracing_test/#usage

注意点はいくつか暗黙的な要素があるところでしょうか。例えば logs_contain などの関数は #[traced_test] により裏で定義され、Subscriber の初期化処理も背後に隠されていて変更できません。とくに Subscriber については自動でグローバルなものが登録されます。このグローバルな Subscriber が既存のテストコードに一部干渉したこともありエモーションテックの社内では tracing_test を使用していません。

その3: MockMakeWriter

3つめは tracing-subscriber のテスト内部で定義されている MockMakeWriter です。方法1の tracing-mock と同じく tracing のリポジトリに含まれています。利用例は tracing のリポジトリ内を検索すると見つかります。tracing_test と異なりマクロの裏に隠される要素はありません。

fn assert_info_hello(subscriber: impl Into<Dispatch>, buf: MockMakeWriter, expected: &str) {
    let _default = set_default(&subscriber.into());
    tracing::info!("hello");
    let result = buf.get_string();

    assert_eq!(expected, result)
}
https://github.com/tokio-rs/tracing/blob/tracing-subscriber-0.3.18/tracing-subscriber/src/fmt/format/mod.rs#L1910-L1916

残念ながら MockMakeWriter は外部に公開されておらず tracing-mock のような方法での参照もできません。しかし実装は単純で長さも100行未満なので丸ごとの複製もできなくはないです。もし MockMakeWriter を複製して使うなら、追加で次のような Capture を定義し組み合わせて使うと便利かもしれません。

use std::sync::{Arc, Mutex};
use tracing::subscriber::{set_default, DefaultGuard};
use tracing::Subscriber;
use tracing_subscriber::fmt::{FormatEvent, FormatFields, Layer, MakeWriter};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::{registry, EnvFilter};

pub(crate) struct Capture {
    make_writer: MockMakeWriter,
    #[allow(dead_code)]
    guard: DefaultGuard,
}

impl Capture {
    pub fn start(subscriber: impl Subscriber + for<'a> LookupSpan<'a> + Send + Sync) -> Self {
        Self::start_with_fmt_layer(
            subscriber,
            Layer::new()
                .compact()
                .with_ansi(false)
                .with_target(false)
                .without_time(),
        )
    }

    pub fn start_with_fmt_layer<
        S: Subscriber + for<'a> LookupSpan<'a> + Send + Sync,
        N: for<'a> FormatFields<'a> + Send + Sync + 'static,
        E: FormatEvent<S, N> + Send + Sync + 'static,
        W,
    >(
        subscriber: S,
        layer: Layer<S, N, E, W>,
    ) -> Self {
        let buffer = Arc::new(Mutex::new(Vec::new()));
        let make_writer = MockMakeWriter::new(Arc::clone(&buffer));
        Self {
            make_writer: MockMakeWriter::new(Arc::clone(&buffer)),
            guard: set_default(subscriber.with(layer.with_writer(make_writer))),
        }
    }

    pub fn extract(&self) -> String {
        self.make_writer.get_string()
    }
}

「その2」の tracing_test の例を Capture を使う形へ書き換えると概ね*2下記のようになります。マクロを剥がした分だけ記述の柔軟性の点では有利かと思います。

#[tokio::test]
async fn test_logs_are_captured() {
    let capture = Capture::start(registry().with(EnvFilter::new("trace")));

    // Local log
    tracing::info!("This is being logged on the info level");

    // Log from a spawned task (which runs in a separate thread)
    tokio::spawn(async {
        tracing::warn!("This is being logged on the warn level from a spawned task");
    })
    .await
    .unwrap();

    let logs = capture.extract();

    // Ensure that certain strings are or aren't logged
    assert!(logs.contains("logged on the info level"));
    assert!(logs.contains("logged on the warn level"));
    assert!(!logs.contains("logged on the error level"));

    // Ensure that the string `logged` is logged exactly twice
    let n = logs
        .split('\n')
        .filter(|line| line.contains("logged"))
        .count();
    assert_eq!(n, 2, "Expected two matching logs, but found {n}");
}

エモーションテック社内では最近 Capture に近いコードの試用を始めました。まだ日が浅いためか簡単なテストでしか試していないためなのか、今のところ困った点は出ていません。

おわりに

サービス運用において信頼できるトレース情報は欠かせません。今回ご紹介した内容はいずれも一長一短ではありますが、トレース情報の信頼性を担保する上で参考になるところがもしあれば嬉しく思います。

エモーションテックでは顧客体験・従業員体験の改善をサポートし世の中の体験を変えるプロダクトを開発しており、その中で Rust や tracing も利用しております。もし弊社での開発に興味を持って頂けましたら、ぜひ採用ページからご応募をお願いいたします。

hrmos.co

*1:最近 tracing 本体の内部用途のために追加された tracing-test とは別物です

*2:フィルタ部分の再現はサボってしまいました