レッスン11 — Stage と ExEx のテスト — fixture chain・インプロセスノード・golden state
問い
Stage トレイト・ExEx API・NodeBuilder SDK を歩いてきた。Reth と Reth を拡張するアプリは、これらの動作をどう検証しているか? dev で「動いて見える」コンポーネントは本番で何千人ものユーザの状態を静かに壊する。Reth 自身の CI がそれを防ぐパターンは?
原理(最小モデル)
- 2 テスト層が必要. Stage/ExEx ユニットテスト(trait 実装単体、純 Rust + fixture)+ NodeBuilder 統合テスト(ノード全体、インプロセス起動)。
- Stage ユニットテスト = 一時 DB + 事前状態 + execute + assert.
TestStageDB+create_test_provider_factoryがephemeral MDBX 提供。 - ExEx ユニットテスト = ハーネス + 合成通知.
test_exex_context()がフルノード起動なしで ExEx 駆動可能。 - NodeBuilder 統合テスト = インプロセス起動. カスタムコンポーネント間相互作用を検証、~1 秒/テスト。
- Fixture-chain テスト = canned data 再生. 既知正常ノードからキャプチャ + golden state-root と完全一致 assert。
- Reth 自身の CI が両層使う. stage コードの全変更が state-root 不一致として検出 → mainnet 前にコンセンサスバグ捕獲。
- Building tier との接続. Read a Real Production Indexer — tidx の test gate がこの §2 ExEx ユニットテストパターンをアプリケーション層で適用。
具体例
2 テスト層:
| 層 | 何をテストするか | 何を起動するか |
|---|---|---|
| Stage / ExEx ユニットテスト | trait 実装単体に、用意したチェーンイベントを与える | 何も起動しない — 純 Rust + fixture |
| NodeBuilder 統合テスト | 自前コンポーネントを差したノード全体 | Reth ノードをインプロセスで起動 |
Stage ユニットテスト:
use reth_provider::test_utils::create_test_provider_factory;
use reth_stages::test_utils::{TestRunnerError, TestStageDB};
#[tokio::test]
async fn execute_advances_checkpoint() {
let db = TestStageDB::default();
seed_blocks(&db, 0..=10).await; // fixture ヘルパ
let mut stage = MyStage::default();
let input = ExecInput { target: Some(10), checkpoint: None };
let output = stage.execute(&db.factory.provider_rw().unwrap(), input).await.unwrap();
assert_eq!(output.checkpoint.block_number, 10);
assert!(output.done);
assert_my_derived_state(&db, 10).await;
}
#[tokio::test]
async fn unwind_rolls_back_to_checkpoint() {
let db = TestStageDB::default();
seed_blocks(&db, 0..=10).await;
let mut stage = MyStage::default();
// 10 まで前進
stage.execute(&db.factory.provider_rw().unwrap(),
ExecInput { target: Some(10), checkpoint: None }).await.unwrap();
// 5 まで unwind
let output = stage.unwind(&db.factory.provider_rw().unwrap(),
UnwindInput { unwind_to: 5, checkpoint: ..., bad_block: None }).await.unwrap();
assert_eq!(output.checkpoint.block_number, 5);
assert_my_derived_state(&db, 5).await;
}
要のツール: create_test_provider_factory と TestStageDB(reth_provider::test_utils および reth_stages::test_utils)。テストごとに ephemeral MDBX DB = 共有状態なし + cleanup 定型コードなし + stale fixture なし。
ExEx ユニットテスト(ハーネス):
use reth_exex_test_utils::{test_exex_context, PollOnce};
use reth_exex::{ExExEvent, ExExNotification};
#[tokio::test]
async fn handles_committed_then_reverted() {
let (ctx, mut handle) = test_exex_context().await.unwrap();
let exex = my_exex(ctx);
tokio::spawn(exex);
// ブロック N..N+5 をカバーする committed-chain 通知を送る
handle.send_notification_chain_committed(committed_chain(N..=N+5)).await.unwrap();
handle.assert_event_finished_height(N+5).await;
// N+3..N+5 を reorg
handle.send_notification_chain_reverted(reverted_chain(N+3..=N+5)).await.unwrap();
handle.assert_event_finished_height(N+2).await;
// 自前の導出状態を検証
assert_my_state_at_height(N+2).await;
}
ハーネスなしの場合: フルノード起動 + 本物チェーンデータ再生 + イベント待ち = テストサイクル数分。ハーネスあり: 各通知が単一関数呼び出し = テスト時間が数秒に。
NodeBuilder 統合テスト:
use reth_node_builder::NodeBuilder;
use reth_node_ethereum::EthereumNode;
use reth_tasks::TokioTaskExecutor;
#[tokio::test]
async fn custom_pool_builder_filters_blob_txs() {
let node = NodeBuilder::new(test_node_config())
.testing_node(TokioTaskExecutor::default())
.with_types::<EthereumNode>()
.with_components(EthereumNode::components().pool(MyPoolBuilder))
.with_add_ons(EthereumNode::add_ons())
.launch()
.await
.unwrap();
// pool の API 経由で tx を提出
let pool = node.pool();
let blob_tx = test_blob_tx();
let result = pool.add_external_transaction(blob_tx).await;
assert!(matches!(result, Err(PoolError::BlobsExcluded)));
}
パターン: NodeBuilder::new(...).testing_node(...) がカスタムコンポーネント付きでノードをインプロセス起動 → ハンドル(node.pool()、node.provider()、node.network())露出 → 直接駆動。ユニットテストより遅い(~1 秒/テスト)が、コンポーネント間挙動には不可欠。
Fixture-chain テスト:
#[tokio::test]
async fn full_sync_to_pinned_block_matches_golden_state() {
let chain_fixture = load_fixture("tests/fixtures/sepolia_blocks_0_to_1000.rlp").await;
let db = TestStageDB::default();
// fixture を通して全 stage を駆動
for stage in default_stages_for_test() {
run_stage_to_completion(&mut stage, &db, chain_fixture.range()).await;
}
// ブロック 1000 時点の derived state-root を既知の正答と比較
let derived = db.factory.provider().header(1000).unwrap().state_root;
assert_eq!(derived, GOLDEN_SEPOLIA_STATE_ROOT_AT_1000);
}
これが Reth が史実データに対して sync 正しさを検証する方法。fixture は既知正常ノードからの 1 回キャプチャ、CI が push のたびに再生。任意 stage の回帰が state-root 不一致として現れる = コンセンサスバグで mainnet 前に捕獲。
失敗例(誤解)
「ユニットテストだけで十分」— 間違い。コンポーネント間相互作用(カスタム pool ビルダーがカスタム payload validator から read)はユニットテストでは見えない。統合テスト必須。
「ExEx は手で ExExNotification 値を構築すれば良い」— 間違い。ハーネスがイベント / 完了シグナルの 裏チャンネルを所有 → テストが poll なしで同期可能。手作りだと race condition + flaky test。
「Fixture-chain は重すぎて CI で回せない」— 間違い。Reth 自身が push のたびに再生。コンセンサスクリティカルバグを mainnet 前に捕獲する唯一の方法。
ステップで組み立てる
Step 1: 2 テスト層を即答
ユニット(trait 実装単体)+ 統合(ノード全体)。
Step 2: Stage ユニットテストパターン
TestStageDB + seed_blocks + execute + assert + unwind + assert。
Step 3: ExEx ユニットテストハーネス
test_exex_context() + send_notification_* + assert_event_finished_height。
Step 4: NodeBuilder 統合テスト
testing_node() でインプロセス起動 + ハンドル露出 + 直接駆動。
Step 5: Fixture-chain テスト
既知正常ノードからキャプチャ + 全 stage 駆動 + golden state-root 比較。
Step 6: Building tier との接続
tidx の test gate = §2 のアプリケーション層適用。
答え合わせ
reth-exex-test-utilsを別 crate で同梱する理由: ハーネスがイベント / 完了シグナルの 裏チャンネルを所有 → テストが poll なしで同期可能。手作り通知だと race condition で flaky test、ハーネスで決定的テストに。同期がテストの決定性を保証。- Fixture-chain テストが Reth の CI に必須な理由: 任意 stage の回帰が state-root 不一致 として現れる = コンセンサスクリティカルバグを mainnet 前に捕獲。fixture は既知正常ノードから 1 回キャプチャ、CI が push のたびに再生 → state 一致なら全 stage 正しい。
- 2 層テストの補完関係: ユニット = trait 実装単体の正しさ(速い、純 Rust)+ 統合 = コンポーネント間相互作用(~1 秒、ノード組み立て)。どちらかを省くと特定クラスのバグが通り抜ける。
Expert への接続
Reth コンポーネント層のテスト規律。Systems 層では Expert tier の 2 レッスンがこれを発展させる:
- Differential fuzzing と execution-spec-tests — 複数実装にわたるコンセンサス正しさのテスト
- Systems-code auditing — Reth patch を監査人の目で読む
合格基準
- 2 テスト層と各々が何をテストするかを言える。
TestStageDB+create_test_provider_factoryのパターンを書ける。test_exex_context()のハーネスパターンを言える。testing_node()での統合テスト構造を即答できる。- Fixture-chain テストの golden state-root 検証を 1 文で説明できる。
まとめ(3行)
- 2 テスト層(ユニット = trait 単体 / 統合 = ノード全体)+ Fixture-chain テスト = Reth 自身の CI 規律、両方が補完関係。
TestStageDB+test_exex_context()+testing_node()で ephemeral / 決定的 / 高速テスト、手作り通知だと flaky test 化。- Fixture-chain テスト + golden state-root = コンセンサスクリティカルバグを mainnet 前に捕獲、tidx の test gate がこの §2 ExEx ユニットテストパターンをアプリケーション層で適用。