Guides
Passthrough mode
One property points the whole harness at a broker you already run. The annotation, the topics, the seeds, the assertions and the per-method cleanup all stay — what changes is who owns the cluster, and therefore how careful cleanup has to be.
One property, one difference
By default wiretap starts one EmbeddedKafkaKraftBroker and shares it across the JVM. Set wiretap.test.mode=passthrough and it starts nothing: it uses the cluster at wiretap.test.passthrough.bootstrap-servers instead — a Testcontainers broker, a docker-compose service, a local Kafka you keep running while you work.
Nothing else about the test changes. Topics are still created, seeds still applied, listeners still awaited, records still purged between methods.
The property is a mode switch, not an activator: without @EnableWiretapKafkaBroker on the test class, wiretap wires nothing, and wiretap.test.mode=passthrough on its own quietly does nothing at all — no topics, no readiness, no cleanup. The annotation stays on the class in both modes.
wiretap.test.mode=passthrough
wiretap.test.passthrough.bootstrap-servers=localhost:9092
# Everything else is unchanged from an embedded suite
wiretap.test.topics=orders,payments
wiretap.test.partitions=2Or scope it to one class, which is the usual way to try it:
@SpringBootTest(properties = {
"wiretap.test.mode=passthrough",
"wiretap.test.passthrough.bootstrap-servers=localhost:9092"
})
@EnableWiretapKafkaBroker(topics = "orders")
class OrderFlowPassthroughIT {
@Test
void publishesOrderEvent() {
KafkaTestPublisher.send("orders", "order-123", new OrderEvent("order-123", "CREATED"));
assertThatTopic("orders").hasRecordCount(1);
}
}Properties
| Property | Default | Description |
|---|---|---|
wiretap.test.modeString | embedded | embedded starts and shares one broker for the JVM; passthrough uses the cluster named below. Compared case-insensitively after trimming; any other value fails the context immediately, naming the two it accepts. |
wiretap.test.passthrough.bootstrap-serversString | — | The cluster to use in passthrough mode: the usual comma-separated host:port list, optionally with listener-name prefixes (PLAINTEXT://localhost:9092) and bracketed IPv6 literals ([::1]:9092). Required in passthrough mode. Ignored — with a warning — in embedded mode, so a stale address cannot quietly win. |
wiretap.test.passthrough.allow-remoteboolean | false | Opts a passthrough suite in to a non-loopback cluster. See the loopback guard. |
wiretap also publishes two keys, in both modes, so application code and later test infrastructure can read which mode they are in:
| Property | Default | Description |
|---|---|---|
wiretap.test.active | — | true in any context wiretap has wired. |
wiretap.test.mode | — | The mode wiretap resolved — embedded or passthrough. |
The mode matrix
Everything wiretap does works in both modes. This is the complete list of what is actually different:
| embedded (default) | passthrough | |
|---|---|---|
| Broker | One EmbeddedKafkaKraftBroker started by wiretap, shared by the JVM | Whatever is at wiretap.test.passthrough.bootstrap-servers |
| spring.kafka.bootstrap-servers | Published | Published |
| spring.embedded.kafka.brokers | Published (@EmbeddedKafka compatibility) | Not published — no embedded broker exists, so the key would lie |
| embeddedKafkaBroker bean | Registered | Not registered — there is nothing to inject |
| JVM system properties | Set once by the embedded broker | Not set — a per-context address must not leak across contexts |
| Topics | Created on the embedded broker | Created on your cluster, through the same two-phase ledger |
| Bean overrides | Retargeted at the embedded broker | Retargeted at the passthrough cluster |
| Mock schema registry | Available | Available — mock:// URLs are client-side |
| Per-method cleanup | Every non-internal topic and every consumer group on the broker | Only the topics wiretap created or seeded on that cluster, and only the consumer groups carrying wiretap's unique suffix |
| Seeding | Records published to the embedded broker | Records published to your cluster — which is also what brings a topic wiretap did not create into the cleanup scope |
The loopback guard
Per-method cleanup deletes records from wiretap's topics and deletes the consumer groups carrying wiretap's suffix on the cluster the address points at. That is exactly what you want for a container or a local broker, and exactly what you do not want to discover you have done to a shared cluster. So wiretap refuses to start when any host in the list is not loopback.
Loopback means literally localhost (any case), any address in 127.0.0.0/8 (so 127.0.0.1, 127.0.0.2, …), or ::1 — bare or bracketed. Set wiretap.test.passthrough.allow-remote=true to opt a suite in to anything else.
wiretap.test.mode=passthrough
wiretap.test.passthrough.bootstrap-servers=kafka.ci.internal:9092
wiretap.test.passthrough.allow-remote=trueReach for the opt-in for a dedicated, disposable test deployment — a CI-owned broker that exists to be cleaned. Not for a cluster anyone else is using.
What passthrough does not do
Three omissions are deliberate, and worth knowing before switching a class over.
- No
embeddedKafkaBrokerbean. There is no embedded broker, so a passthrough test cannot@Autowired EmbeddedKafkaBroker. Readspring.kafka.bootstrap-serversinstead — both modes publish it. - No
spring.embedded.kafka.brokers. That key exists for@EmbeddedKafkacompatibility. Publishing it while nothing embedded is running would be a lie some other library may act on. - No JVM system properties. The embedded broker sets
spring.kafka.bootstrap-serversandspring.embedded.kafka.brokersas system properties, which is defensible for one JVM-wide broker. A passthrough target is per-context configuration: writing it into JVM-global state would let one context's address bleed into every other context in the suite, embedded ones included.
What keeps working
- Topics.
wiretap.test.topics,wiretap.test.topics-fileand@EnableWiretapKafkaBroker(topics = ...)are created on your cluster, before auto-starting listener containers subscribe. - Seeding.
wiretap.test.seed.locationsand@KafkaSeedpublish their fixtures to your cluster, and re-apply after each cleanup exactly as they do embedded. - Publisher, reader and assertions. Unchanged, including the implicit no-servers forms — the engine's broker address simply points at your cluster.
- Listener readiness.
hasReadyListeners(...)and thewiretap.test.listeners.ready-timeoutdefault work the same way, for classic and share containers alike. - Share groups. When the context declares a share factory or container, wiretap checks once that the cluster's finalized
share.versionis at least 1 and fails the context with the remedy if it is not; it never changes cluster features.wiretap.test.broker-properties.*is ignored in this mode. See share groups. - Bean overrides. Your application's own
ProducerFactory,ConsumerFactory,KafkaTemplateand listener container factories are retargeted at the passthrough cluster exactly as they would be at the embedded broker. - Mock schema registry. A
mock://registry URL is resolved inside the client, so it never depends on which cluster the records are going to.
Cleanup scope
On the embedded broker, cleanup enumerates: every non-internal topic is purged and every consumer group deleted. wiretap owns that broker outright, so that is safe and complete.
On a cluster wiretap does not own, cleanup is narrowed to what wiretap can account for: only the topics wiretap created or seeded on that cluster, and only the consumer groups carrying wiretap's unique group-id suffix. The cluster is never enumerated to discover what to clean — the only listing performed is an intersection, so a known topic that is not on the cluster is skipped rather than failing the call.
Both halves of that set are recorded against the bootstrap address they were created or seeded under, so a topic wiretap created on the embedded broker never puts a same-named topic on your cluster in reach. And because seeding records its own per-cluster mark, a fixture written into a topic your cluster already carried is cleaned between methods — which is what makes seeding a pre-existing topic safe to do.
Testcontainers recipe
If the suite already starts a Kafka container, passthrough stops wiretap from starting a second broker next to it. The container owns the cluster; wiretap owns the topics, the seeds and the cleanup.
@SpringBootTest(properties = "wiretap.test.mode=passthrough")
@EnableWiretapKafkaBroker(topics = "orders")
@Testcontainers
class OrderFlowContainerIT {
@Container
static final KafkaContainer KAFKA =
new KafkaContainer(DockerImageName.parse("apache/kafka:3.9.2"));
// The mode is static configuration — set it via properties, not @DynamicPropertySource,
// so a class whose own @ContextConfiguration shadows the annotation's initializer is
// still wired correctly (dynamic properties register too late on that path). Only the
// container's address is genuinely dynamic.
@DynamicPropertySource
static void kafka(DynamicPropertyRegistry registry) {
registry.add("wiretap.test.passthrough.bootstrap-servers", KAFKA::getBootstrapServers);
}
@Test
void publishesOrderEvent() {
KafkaTestPublisher.send("orders", "order-123", new OrderEvent("order-123", "CREATED"));
assertThatTopic("orders").hasRecordCount(1);
}
}A container address is loopback (Testcontainers maps the port onto the host), so no opt-in is needed. For a broker that already runs — docker compose, a local install, a CI service — put the two properties in a profile instead and switch profiles rather than code:
# CI profile: the same tests, a real broker, no code changes
wiretap.test.mode=passthrough
wiretap.test.passthrough.bootstrap-servers=${KAFKA_BOOTSTRAP_SERVERS}Where the mode has to be set
The mode and the address are read from the Environment before the context refreshes, because everything that can fail on bad configuration has to fail before a broker is started or a topic created. application.properties, @TestPropertySource and @SpringBootTest(properties = ...) all qualify, and so does @DynamicPropertySource on the normal path — which is why the Testcontainers recipe above works.
@SpringBootTest
@EnableWiretapKafkaBroker(topics = "orders")
// A directly declared @ContextConfiguration shadows the annotation's initializer, so this class
// is wired from WiretapContextCustomizerFactory — which runs before dynamic properties exist.
@ContextConfiguration(initializers = SomeOtherLibraryInitializer.class)
@TestPropertySource(properties = {
"wiretap.test.mode=passthrough",
"wiretap.test.passthrough.bootstrap-servers=localhost:9092"
})
class ShadowedContextIT {
}Mixed suites in one JVM
Embedded and passthrough classes may coexist. They are separate Spring contexts, each publishing its own spring.kafka.bootstrap-servers, and a cached passthrough context is re-pointed at its own cluster per test class even when an embedded class ran in between.
The one shared piece of state is the engine's implicit broker address — the one used by the no-servers overloads of KafkaTestPublisher, KafkaTopicReader and the assertions. It follows the most recently started test class. In a mixed suite, prefer the explicit-servers overloads in any helper that outlives a single test class:
@Value("${spring.kafka.bootstrap-servers}")
private String bootstrapServers;
// Explicit-servers overloads are exact in either mode, and in a mixed suite
// they are the only ones that cannot drift with the most recent test class.
KafkaTestPublisher.sendValue(bootstrapServers, "orders", order);
KafkaTopicReader.readSingleRecord(bootstrapServers, "orders", OrderEvent.class);Related reading: Configuration for the full property list, Seeding for fixtures and placeholders, and Core lifecycle for what the initializer does in each mode.