Skip to main content

connector-testing

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.

Datos de origen

Repositorio
apache/iggy
Última actividad en el origen
12 de septiembre de 2026 a las 11:45
Idioma detectado de SKILL.md
inglés
Estrellas
5089
Forks
479

Opciones de instalación

De forma predeterminada está seleccionado el prompt que primero revisa el origen. Puedes cambiar a un comando directo o descargar una copia local.

Revisa los archivos de origen

Lee SKILL.md y los archivos complementarios que muestra SkillsMP antes de decidir si quieres instalarlo.

Mostrando SKILL.md

SKILL.md
Instrucciones de origen · Vista previa de solo lectura
name
connector-testing
description
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.
# Testing Apache Iggy Connectors > **Universal connector rules** live in > [connectors-overview](../connectors-overview/SKILL.md). Repo-wide testing rules (`#[iggy_harness]`, BDD naming, harness layout) live in [AGENTS.md](../../../AGENTS.md#testing). This skill covers connector-specific test conventions. ## Contents - [STOP and ask the user before](#stop-and-ask-the-user-before) - [Unit tests](#unit-tests) - [Integration tests (real infra via Docker)](#integration-tests-real-infra-via-docker) - [SDK + runtime tests](#sdk--runtime-tests) - [CI / verification flow](#ci--verification-flow) - [Common pitfalls](#common-pitfalls) - [Examples worth copying](#examples-worth-copying) ## STOP and ask the user before - Mocking the database or backend in an integration test - we hit real infra via `testcontainers-modules` (mocks miss migration bugs). - Adding a unit test that requires Docker - that belongs under `core/integration/tests/connectors/<backend>/`, not in `<plugin>/src/lib.rs`. - Skipping the four canonical source state tests - they're mandatory for every source plugin. - Naming a test `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 | ## Unit tests ### Naming - BDD `given_when_should` Match the convention used in the codebase (`sources/random_source/src/lib.rs::tests`, `sinks/postgres_sink/src/lib.rs::tests`): ```rust #[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. ### Prove a test can fail, before trusting it A passing test says nothing until you have watched it fail. Break the behaviour it names, run the suite, and put the code back: ```bash # 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`: - **A fixture that makes the interesting case unreachable.** A guard test for endpoint resurrection passed `&[]` as the static config, so there was nothing to resurrect and the assertion held whatever `restore` did. - **An assertion aimed at something self-healing.** A test for a teardown guard asserted on gauges, which `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()` helper Every 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. ```rust fn test_config() -> RandomSourceConfig { RandomSourceConfig { interval: Some("100ms".to_string()), max_count: Some(100), messages_range: Some((5, 10)), payload_size: Some(50), } } ``` ### Async testing pattern 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): ```rust #[test] fn given_X_should_Y() { let runtime = tokio::runtime::Runtime::new().unwrap(); runtime.block_on(async { // await calls here }); } ``` ### Mandatory: four canonical source state tests Every source must have these. Copy from `sources/random_source/src/lib.rs::tests`: ```rust #[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: ```rust #[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. ### Sink unit tests Cover pure per-message logic: - Config defaults + optional fields + invalid values → fallback. - Payload conversion for each `Payload` variant the sink claims to handle. - Header encoding (especially base64 for binary headers). - Query/document building (e.g., `postgres_sink::build_batch_insert_query`). - Transient vs permanent error classification (e.g., `postgres_sink::is_transient_error`). ### Transform tests Four branches must be covered (see `connector-transform`): 1. Happy path: matching input → expected output. 2. Filter path: input that should be dropped returns `Ok(None)`. 3. Pass-through path: non-JSON payload (or unmatched condition) returns `Ok(Some(unchanged))`. 4. Invalid config: `new()` returns `Err(Error::InvalidConfigValue(_))`. ### What NOT to mock - The underlying client lib (sqlx, reqwest, mongodb). Tests would exercise the mock, not the code. - The Iggy server. - The runtime. If you need a fake backend, use `wiremock` (a real local HTTP server) - see `core/integration/tests/connectors/http/`. ## Integration tests (real infra via Docker) 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. ### Layout ```text 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 ``` ### Container naming + cleanup 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. ### The `#[iggy_harness]` proc macro Each 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. ```rust 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.
Ver en GitHub
Este SKILL.md es muy grande, por eso SkillsMP muestra aqui solo la primera seccion. Ver en GitHub