Apache Iggyのコネクター変更を複数の専門視点から厳しくレビューし、指摘ごとに独立した検証を行って、修正が必要な問題を整理します。
- コネクターのPRをマージ前に確認したいとき
- ブランチの変更を専門視点でレビューしたいとき
- 指摘の妥当性を独立して検証したいとき
Apache Iggyのコネクター向けに、ユニットテストや実サービスを使う統合テストを設計・作成し、命名規則や状態復元などのテスト慣例に沿って確認します。
Write tests for Apache Iggy connectors - sinks, sources, transforms, SDK, runtime. Covers BDD unit-test naming, the canonical source state round-trip tests, and the real-infra integration test layout under `core/integration/tests/connectors/` with `testcontainers-modules` + `#[iggy_harness]`. Load when writing or reviewing tests in `core/connectors/` or `core/integration/tests/connectors/`. Use for connector test authoring. NOT for runtime internals or non-connector test patterns.
実行スコア 未検証
インストール方法を見るApache Iggyのコネクターに合わせたユニットテストと統合テストの作成・レビューを支援します。設定値やペイロード変換などのユニットテストでは、BDD形式の命名や既存テストの構成を使います。ソースでは状態の復元、未設定・不正な状態への対応、シリアライズとデシリアライズを確認します。外部サービスと連携するコネクターでは、Docker上の実サービスを使った統合テストの配置、フィクスチャ、#[iggy_harness] の使い方を扱います。テストが実際に不具合を検出できるか、固定時間の待機ではなく条件を確認する方法なども説明します。
コネクターのテストを新しく書くときや、既存テストの構成を確認するときに役立ちます。ソースの再起動後に状態が保たれるか、外部バックエンドとの連携が動くかを確かめたい場合にも使えます。SDKや変換処理のテストを追加する際にも、ユニットテストと統合テストの役割を整理できます。
統合テストにはDockerが必要です。初回実行時に公開レジストリからイメージを取得します。
この紹介文は、公開されている SKILL.md をもとに AI(OpenAI Codex)が作成しました。正確な仕様は下の原文を確認してください。
インストールする前に、エージェントに与えられる指示の中身を確認できます。
Universal connector rules live in connectors-overview. Repo-wide testing rules (
#[iggy_harness], BDD naming, harness layout) live in AGENTS.md. This skill covers connector-specific test conventions.
testcontainers-modules (mocks miss migration bugs).core/integration/tests/connectors/<backend>/, not in <plugin>/src/lib.rs.test_foo / does_bar - we use BDD given_X_when_Y_should_Z.There are two distinct test layers. Use both - they catch different bugs.
| Layer | Location | What it covers | When required |
|---|---|---|---|
| Unit tests | #[cfg(test)] mod tests at EOF of <plugin>/src/lib.rs | Config parsing, defaults, fallbacks, payload conversion, query/document building, state ser/de | Every plugin |
| Integration tests | core/integration/tests/connectors/<name>/ (uses real backend via Docker) | End-to-end: runtime + plugin + real Postgres/ES/Iceberg/Mongo/etc. + Iggy server | Every plugin that integrates with an external system |
given_when_shouldMatch the convention used in the codebase (sources/random_source/src/lib.rs::tests, sinks/postgres_sink/src/lib.rs::tests):
#[test]
fn given_persisted_state_should_restore_messages_produced() { ... }
#[test]
fn given_no_state_should_start_fresh() { ... }
#[test]
fn given_invalid_duration_should_fall_back_to_default() { ... }
#[test]
fn given_payload_json_should_serialize_correctly() { ... }
You may omit one part when the test is small (given_X_should_Y instead of full given_X_when_Y_should_Z), but stay consistent within a file. Imperative test_foo / does_bar names are not the convention here.
A test whose subject is a type rather than a scenario has no "given" to name, and drops the prefix: it reads <subject>_should_<property>. The canonical state round trip below is state_should_be_serializable_and_deserializable in all three shipped sources (random_source, postgres_source, http_source). Leave those as they are rather than bending them into the scenario form.
A passing test says nothing until you have watched it fail. Break the behaviour it names, run the suite, and put the code back:
# commit first: this reverts with `git checkout --`, which discards
# every uncommitted change in the file, not only the mutation
git status --porcelain -- <file> # must be empty
<edit the guard out>
cargo nextest run -p <crate> # expect a failure naming that test
git checkout -- <file>
Two failures this catches, both seen in review of http_source:
&[] as the static config, so there was nothing to resurrect and the assertion held
whatever restore did.Metrics::encode rebuilds from the live instances on every scrape, so it passed with the
guard deleted. Delete such a test rather than keep it; a test that cannot fail is worse than none,
because it reads as coverage.Watch for an arm hidden behind a catch-all: StatusClass::from ends in _ => ServerError, so the
redirect arm could be removed with every existing assertion still green.
test_config() helperEvery plugin's tests start with a small helper returning a tuned-down version of the production config. New tests extend it. don't construct from scratch each time.
fn test_config() -> RandomSourceConfig {
RandomSourceConfig {
interval: Some("100ms".to_string()),
max_count: Some(100),
messages_range: Some((5, 10)),
payload_size: Some(50),
}
}
The trait methods are async. For unit tests, build a runtime locally - don't add #[tokio::test] in src/lib.rs (it pulls test macros into prod build):
#[test]
fn given_X_should_Y() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
// await calls here
});
}
Every source must have these. Copy from sources/random_source/src/lib.rs::tests:
#[test]
fn given_persisted_state_should_restore_<field>() {
let state = State { /* fields */ };
let serialized = rmp_serde::to_vec(&state).expect("Failed to serialize state");
let connector_state = ConnectorState(serialized);
let source = MySource::new(1, test_config(), Some(connector_state));
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
let restored = source.state.lock().await;
assert_eq!(restored.<field>, /* expected */);
});
}
#[test]
fn given_no_state_should_start_fresh() {
let source = MySource::new(1, test_config(), None);
/* assert default state */
}
#[test]
fn given_invalid_state_should_start_fresh() {
let invalid = ConnectorState(b"not valid msgpack".to_vec());
let source = MySource::new(1, test_config(), Some(invalid));
/* assert default state */
}
#[test]
fn state_should_be_serializable_and_deserializable() {
let original = State { /* fields */ };
let bytes = rmp_serde::to_vec(&original).unwrap();
let restored: State = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(original.<field>, restored.<field>);
}
State is the only thing that survives a plugin restart. Silent corruption here means lost data on the next deploy.
The third test has two correct shapes, and which one applies is a design decision rather than a style choice.
Starting fresh is right when the state is a cursor. The worst case is re-reading from the beginning, which the at-least-once contract already permits, so discarding an undecodable cursor costs duplicates and nothing else.
It is wrong when the state carries something whose absence is a downgrade rather than a repeat. http_source keeps its revocation tombstones in state, so starting fresh after a decode failure would serve every revoked endpoint again, with its secret. Its restore returns Err instead and the connector refuses to open. A source in that position asserts the refusal:
#[test]
fn given_invalid_state_should_refuse_to_open() {
let invalid = ConnectorState(b"not valid msgpack".to_vec());
let restored = MyState::restore(&config_entries(), Some(invalid), 1);
assert!(
restored.is_err(),
"discarding this state silently would re-serve what it was revoking"
);
}
Name the test for whichever it is and say why in a comment. What is never right is starting fresh silently when the state that was discarded was load-bearing.
Cover pure per-message logic:
Payload variant the sink claims to handle.postgres_sink::build_batch_insert_query).postgres_sink::is_transient_error).Four branches must be covered (see connector-transform):
Ok(None).Ok(Some(unchanged)).new() returns Err(Error::InvalidConfigValue(_)).If you need a fake backend, use wiremock (a real local HTTP server) - see core/integration/tests/connectors/http/.
The canonical pattern lives in core/integration/tests/connectors/. Plugins that integrate with external services (Postgres, Elasticsearch, Iceberg, MongoDB, InfluxDB, Quickwit, HTTP/wiremock) have a paired fixture + test file there.
core/integration/tests/connectors/
├── mod.rs Common helpers (TestMessage, create_test_messages)
├── fixtures/ One subdir per backend
│ ├── postgres/
│ │ ├── container.rs PostgresContainer (testcontainers wrapper), env-var consts
│ │ ├── sink.rs PostgresSinkFixture, PostgresSinkByteaFixture, PostgresSinkJsonFixture
│ │ ├── source.rs PostgresSourceJsonFixture, PostgresSourceJsonbFixture, ...
│ │ └── mod.rs re-exports
│ ├── elasticsearch/ iceberg/ mongodb/ influxdb/ quickwit/ http/ delta/
│ └── wiremock.rs HTTP mocking for http_sink tests
├── postgres/
│ ├── postgres_sink.rs The actual #[iggy_harness] tests
│ ├── postgres_source.rs
│ ├── restart.rs Restart + state-survival tests
│ ├── sink.toml Runtime config used by sink tests
│ ├── source.toml Runtime config used by source tests
│ └── mod.rs Shared constants (TEST_MESSAGE_COUNT, POLL_ATTEMPTS, ...)
├── delta/ elasticsearch/ iceberg/ mongodb/ influxdb/ random/ quickwit/ http/ stdout/
├── http_config_provider/ HTTP config provider end-to-end
├── runtime/ Runtime-only tests (error isolation, missing plugin, ...)
└── api/ HTTP control plane tests
Every test container MUST be named with the iggy-test- prefix so a single
docker ps -aqf 'name=^iggy-test-' sweep reaps them (just clean-test-containers,
alias ctc; the test/nextest recipes also reap on exit). New fixtures get
this for free by naming through fixtures::unique_container_name("<svc>"), which
appends a uuid for parallel-safe ephemeral containers. Reuse fixtures use a fixed
iggy-test-<svc> literal instead, because the stable name is what lets later
nextest processes attach to the same container via ReuseDirective::Always. Do
not hand-roll a different prefix - the sweep only finds iggy-test-*.
As of now only elasticsearch and doris share a container. Both have a slow
boot (JVM / Doris FE+BE, tens of seconds), so reusing one across the whole
regression beats spinning a fresh one per test. Every other fixture boots a
fresh, ephemeral container per test - cheap enough that the isolation is worth
more than the reuse.
#[iggy_harness] proc macroEach integration test is annotated with this macro from the integration crate. It boots an in-process Iggy server (and optionally the connectors runtime) for the duration of the test, runs the seeds, then injects a &TestHarness and your fixture.
use integration::harness::seeds;
use integration::iggy_harness;
#[iggy_harness(
server(connectors_runtime(config_path = "tests/connectors/postgres/sink.toml")),
seed = seeds::connector_stream
)]
async fn json_messages_sink_stores_as_bytea(
harness: &TestHarness,
fixture: PostgresSinkFixture,
) {
let client = harness.root_client().await.unwrap();
let pool = fixture.create_pool().await.expect("Failed to create pool");
fixture.wait_for_table(&pool, "iggy_messages").await;
// ... send messages, assert on rows
}
Key pieces:
server(connectors_runtime(config_path = ...)) boots the connectors runtime against the given TOML.seed = seeds::connector_stream runs the named seed to create the test stream/topic.fixture: PostgresSinkFixture - the test harness calls PostgresSinkFixture::setup() (which spins up the testcontainer) and injects the result.harness: &TestHarness (defined in core/integration/src/harness/orchestrator/harness.rs) exposes .root_client().await, .connectors_runtime().expect("...").http_url(), etc. - connectors_runtime() returns Option, so .expect() first.TestFixture trait)Each fixture implements integration::harness::TestFixture. Pattern:
// core/integration/tests/connectors/fixtures/<backend>/container.rs
use testcontainers_modules::{
<backend_module>,
testcontainers::{ContainerAsync, runners::AsyncRunner},
};
pub struct MyContainer {
container: ContainerAsync<<backend_module>::Image>,
pub connection_string: String,
}
impl MyContainer {
pub async fn start() -> Result<Self, TestBinaryError> {
let container = <backend_module>::Image::default().start().await?;
let port = container.get_host_port_ipv4(DEFAULT_PORT).await?;
Ok(Self {
container,
connection_string: format!("...://localhost:{port}"),
})
}
}
The fixture itself wraps the container and implements TestFixture::setup() to spin it up and inject env vars that the runtime picks up.
ConfigEnvThe runtime's config structs derive ConfigEnv (configs_derive::ConfigEnv, applied in runtime/src/configs/connectors.rs to SinkConfig, SourceConfig, StreamConsumerConfig, StreamProducerConfig). Fields are addressable by env var with a path-built prefix. Three concrete forms observed in core/integration/tests/connectors/fixtures/postgres/container.rs:
// Plugin-config fields (your sink/source's own config struct):
// IGGY_CONNECTORS_<TYPE>_<KEY>_PLUGIN_CONFIG_<FIELD>
pub const ENV_SINK_CONNECTION_STRING: &str =
"IGGY_CONNECTORS_SINK_POSTGRES_PLUGIN_CONFIG_CONNECTION_STRING";
// Indexed nested fields (per-stream entries are a Vec):
// IGGY_CONNECTORS_<TYPE>_<KEY>_STREAMS_<INDEX>_<FIELD>
pub const ENV_SINK_STREAMS_0_STREAM: &str =
"IGGY_CONNECTORS_SINK_POSTGRES_STREAMS_0_STREAM";
// Top-level scalar fields on SinkConfig/SourceConfig itself:
// IGGY_CONNECTORS_<TYPE>_<KEY>_<FIELD>
pub const ENV_SINK_PATH: &str =
"IGGY_CONNECTORS_SINK_POSTGRES_PATH";
<TYPE> is SINK or SOURCE. <KEY> is the connector key (TOML key field, uppercased). PLUGIN_CONFIG is the literal segment used when reaching into your plugin's nested config. Fields marked #[config_env(skip)] (e.g., transforms, plugin_config as a whole) are NOT env-addressable. primitive leaves marked #[config_env(leaf)] are.
The fixture sets these in setup() before the runtime reads its config. This is how a dynamic container port reaches a static TOML.
Integration tests use a descriptive declarative style, not the unit-test given_should form. Match the codebase:
async fn json_messages_sink_stores_as_bytea(...) { ... }
async fn binary_messages_sink_stores_as_bytea(...) { ... }
async fn json_messages_sink_stores_as_jsonb(...) { ... }
async fn delete_after_read_source_removes_rows_after_producing(...) { ... }
async fn processed_column_source_marks_rows_after_producing(...) { ... }
async fn state_persists_across_connector_restart(...) { ... }
async fn restart_sink_connector_continues_processing(...) { ... }
Pattern: <subject>_<action>_<observation>. Reads like a sentence describing what the test proves.
Integration tests poll until an expected condition - never sleep(big_duration). Constants POLL_ATTEMPTS and POLL_INTERVAL_MS defined per-backend mod.rs. Fixture helpers like PostgresSinkFixture::fetch_rows_as and PostgresSinkFixture::wait_for_table encapsulate the pattern.
Schema and per supported plugin mode. Concrete coverage in core/integration/tests/connectors/postgres/: 3 sink tests (json_messages_sink_stores_as_bytea, binary_messages_sink_stores_as_bytea, json_messages_sink_stores_as_jsonb) backed by 3 sink fixtures, plus 5 source fixtures (Json, Jsonb, Bytea, Delete, Mark) and a dedicated restart.rs for state-survival.core/connectors/sdk/tests/ - cross-module SDK concerns. In tree: protobuf_integration.rs, flatbuffer_integration.rs (round-trip schema decoders/encoders).core/integration/tests/connectors/runtime/ and core/integration/tests/connectors/api/. They exercise the runtime alone (no plugin-specific backend) - error isolation, missing plugin, invalid config, HTTP API contract.Before pushing:
cargo fmt --all
cargo sort --no-format --workspace
cargo clippy -p <crate> --all-targets -- -D warnings
cargo test -p <crate>
For integration tests, the relevant package is integration. There is no --test connectors target - the integration crate has no [[test]] entries and tests/ is structured as a single binary entry through tests/mod.rs with connectors/, server/, cli/, etc. as nested modules. Run via name filter:
cargo test -p integration -- connectors::postgres::postgres_sink
cargo test -p integration -- connectors::postgres:: # all postgres tests
cargo test -p integration -- connectors:: # all connector tests
Integration tests assume Docker is available locally and pull images from a public registry on first run.
testcontainers - test exercises the mock.#[tokio::test] in src/lib.rs - pulls tokio test macros into prod build. Use Runtime::new() instead. (#[tokio::test] is fine in tests/ directories, which compile separately.)unwrap() in test assertions - prefer assert_eq! / expect("descriptive") so failures tell you which step blew up.tokio::time::pause().AtomicU64, env vars) - tests must be independent and parallelizable.Display of an error - use matches!(err, Error::Variant(_)). assert_eq!(err.to_string(), ...) breaks on message tweaks.| Concern | Where |
|---|---|
| Source state round-trip | sources/random_source/src/lib.rs::tests |
| Config + query-building unit tests | sinks/postgres_sink/src/lib.rs::tests |
| Real-infra sink integration tests | core/integration/tests/connectors/postgres/postgres_sink.rs |
| Real-infra source integration tests | core/integration/tests/connectors/postgres/postgres_source.rs |
| Restart + state survival | core/integration/tests/connectors/postgres/restart.rs |
| Wiremock-driven HTTP sink test | core/integration/tests/connectors/http/http_sink.rs |
| Testcontainer wrapper | core/integration/tests/connectors/fixtures/postgres/container.rs |
Fixture impl TestFixture | core/integration/tests/connectors/fixtures/postgres/sink.rs |
| Runtime-only error-isolation | core/integration/tests/connectors/runtime/error_isolation.rs |
| Transform tests | sdk/src/transforms/add_fields.rs::tests, filter_fields.rs::tests |
Discussion / help: see AGENTS.md.
まだレビューはありません。使ってみた感想をお寄せください。
ログインなしで書き込めます。使ってみた感想、質問、動かなかった環境などを気軽にどうぞ。
まだコメントはありません。最初のコメントを書いてみませんか。
Apache Iggyのコネクター変更を複数の専門視点から厳しくレビューし、指摘ごとに独立した検証を行って、修正が必要な問題を整理します。
Apache Iggyとコネクタープラグインをつなぐランタイムの開発に使います。読み込みやデータ転送、状態保存、設定、HTTP API、メトリクスなどの変更箇所を把握できます。
Apache Iggy Connectors SDKのスキーマ、エンコーダー、デコーダー、エラー型やFFIなどを追加・変更するときに、関連箇所と互換性への影響を整理するスキルです。
Apache Iggyのストリームからメッセージを受け取り、データベースやHTTPなど外部システムへ送るSinkプラグインの作成・変更を支援します。
データベース、API、キューなど外部システムをポーリングし、Apache Iggyのストリームへメッセージを送るSourceプラグインの作成・変更を支援します。
Apache Iggyコネクターのメッセージにフィールド追加・削除や形式変換などの処理を加える変換機能を設計し、設定連携やテストまで整えます。