Last Updated: May 31, 2026 at 10:00

Micronaut Testing Explained for Spring Boot Developers

Learn unit testing, @MicronautTest, Testcontainers, WireMock, HTTP endpoint testing, Kafka testing, and configuration testing through familiar Spring Boot comparisons

Micronaut's fast startup times change the way many developers approach testing. Instead of reserving integration tests for a small subset of scenarios, developers can often run full application-context tests throughout the development cycle without significant overhead. In this guide, you'll learn how to test Micronaut applications at every level — from isolated unit tests and dependency injection wiring to REST APIs, databases, Kafka consumers, and end-to-end workflows — while understanding how each approach compares to familiar Spring Boot testing patterns.

Image

The testing philosophy

Micronaut's test support is built around one central idea: the application context starts fast enough to use in most tests.

While the testing pyramid still applies, Micronaut's fast startup times make integration-style testing less expensive than in many traditional enterprise frameworks. A @MicronautTest context typically starts significantly faster than an equivalent Spring Boot test context, often making broader integration testing practical even during local development. The exact startup time depends on factors like bean count, Flyway, Hibernate/JPA, Testcontainers, and environment configuration — some Micronaut test contexts will still take multiple seconds in complex applications.

That said, the full testing pyramid still applies. This article covers each layer in order, from the fastest and most isolated to the slowest and most realistic.

Setup: common test dependencies

// build.gradle
dependencies {
// Core test support
testImplementation("io.micronaut.test:micronaut-test-junit5")
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core")
testImplementation("org.assertj:assertj-core")

// Testcontainers (database + Kafka)
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
testImplementation("org.testcontainers:kafka")

// WireMock — for stubbing external HTTP APIs
testImplementation("org.wiremock:wiremock:3.3.1")
}

1. Unit tests — no application context

Unit tests verify a single class in complete isolation. No Micronaut context is started, no database, no HTTP server. These are the fastest tests you have — they should run in milliseconds.

The key insight: because Micronaut uses constructor injection, your classes are plain Java objects. You instantiate them directly in tests and pass mock dependencies via the constructor. No @SpringRunner, no @ExtendWith(MockitoExtension.class) required — though Mockito still helps.

Testing a service with mocked dependencies

// The class under test
@Singleton
public class OrderService {

private final OrderRepository orderRepository;
private final NotificationService notificationService;
private final PricingService pricingService;

public OrderService(OrderRepository orderRepository,
NotificationService notificationService,
PricingService pricingService) {
this.orderRepository = orderRepository;
this.notificationService = notificationService;
this.pricingService = pricingService;
}

public Order placeOrder(CreateOrderRequest request) {
BigDecimal finalPrice = pricingService.calculate(request.getItems());
Order order = orderRepository.save(
new Order(request.getCustomerId(), finalPrice));
notificationService.send(order.getCustomerId(),
"Order " + order.getId() + " placed");
return order;
}
}


// Pure unit test — no Micronaut context
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

class OrderServiceTest {

private OrderRepository orderRepository;
private NotificationService notificationService;
private PricingService pricingService;
private OrderService orderService;

@BeforeEach
void setUp() {
orderRepository = Mockito.mock(OrderRepository.class);
notificationService = Mockito.mock(NotificationService.class);
pricingService = Mockito.mock(PricingService.class);

// Construct directly — no container needed
orderService = new OrderService(
orderRepository, notificationService, pricingService);
}

@Test
void placeOrderSavesOrderAndSendsNotification() {
// Arrange
CreateOrderRequest request = new CreateOrderRequest("CUST-1", List.of());
Order savedOrder = new Order(1L, "CUST-1", new BigDecimal("99.99"));
when(pricingService.calculate(any())).thenReturn(new BigDecimal("99.99"));
when(orderRepository.save(any())).thenReturn(savedOrder);

// Act
Order result = orderService.placeOrder(request);

// Assert
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getCustomerId()).isEqualTo("CUST-1");

verify(notificationService).send("CUST-1", "Order 1 placed");
verify(orderRepository).save(any(Order.class));
}

@Test
void placeOrderUsesCalculatedPrice() {
when(pricingService.calculate(any())).thenReturn(new BigDecimal("49.99"));
when(orderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

Order result = orderService.placeOrder(
new CreateOrderRequest("CUST-2", List.of()));

assertThat(result.getTotal()).isEqualByComparingTo("49.99");
}

@Test
void placeOrderThrowsWhenPricingFails() {
when(pricingService.calculate(any()))
.thenThrow(new PricingException("Service unavailable"));

assertThrows(PricingException.class,
() -> orderService.placeOrder(
new CreateOrderRequest("CUST-3", List.of())));

verifyNoInteractions(orderRepository);
verifyNoInteractions(notificationService);
}
}

Key point: Notice there is no @MicronautTest, no @ExtendWith, and no @Inject. This is plain JUnit 5 with Mockito. Constructor injection is what makes this possible — the service has no hidden Micronaut magic to satisfy.

Testing domain logic and utility classes

class PricingServiceTest {

private PricingService pricingService;

@BeforeEach
void setUp() {
pricingService = new PricingService();
}

@Test
void calculatesTotalCorrectly() {
List<OrderItem> items = List.of(
new OrderItem("SKU-1", 2, new BigDecimal("10.00")), // 20.00
new OrderItem("SKU-2", 1, new BigDecimal("15.50")) // 15.50
);

BigDecimal total = pricingService.calculate(items);

assertThat(total).isEqualByComparingTo("35.50");
}

@Test
void appliesDiscountForLargeOrders() {
List<OrderItem> items = List.of(
new OrderItem("SKU-1", 10, new BigDecimal("100.00")) // 1000.00
);

BigDecimal total = pricingService.calculate(items);

// 10% bulk discount applies above 500
assertThat(total).isEqualByComparingTo("900.00");
}

@Test
void returnsZeroForEmptyOrder() {
assertThat(pricingService.calculate(List.of()))
.isEqualByComparingTo("0.00");
}
}

2. Integration tests — with the Micronaut context

Integration tests start the Micronaut application context using @MicronautTest. The full DI container is active, beans are wired, and @Requires conditions are evaluated.

The rule of thumb used throughout this article: use a real database (via Testcontainers, covered in section 3) rather than mocking your repositories. Mocking persistence tends to produce tests that pass even when your queries are wrong. The one category where mocking remains the right call is outbound HTTP calls — third-party services you don't own, and other microservices in your own system that you're not trying to test here. In both cases the problem is the same: a real call would make the test slow, flaky, or dependent on infrastructure that isn't the subject of the test. For those, WireMock is the right tool.

Mocking an external HTTP API with WireMock

Imagine your ShippingService calls an external carrier API to get a shipping quote. You don't want real HTTP calls in your test suite, but you do want to verify that your service correctly parses the response, handles errors, and passes the right request.

WireMock works exactly that way: it spins up an embedded or standalone HTTP/HTTPS server locally (or on a remote host). You then define configuration rules (known as stubs) to tell it how to react when it receives matching API requests. You point your Micronaut HTTP client at it by overriding the base URL via TestPropertyProvider.

One important detail: Micronaut resolves configuration before the test method executes, so the WireMock server must be started statically before context initialisation. TestPropertyProvider.getProperties() is called at the right moment — before the context starts — which makes it the correct hook for passing the dynamic port.

import com.github.tomakehurst.wiremock.WireMockServer;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import io.micronaut.test.support.TestPropertyProvider;
import jakarta.inject.Inject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;

import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;

@MicronautTest
class ShippingServiceIntegrationTest implements TestPropertyProvider {

// Started statically so the port is known before the Micronaut context initialises
static WireMockServer wireMock = new WireMockServer(wireMockConfig().dynamicPort());

static {
wireMock.start();
}

@Override
public Map<String, String> getProperties() {
// Called before context startup — the port is available here
return Map.of("carrier.api.url", "http://localhost:" + wireMock.port());
}

@AfterEach
void resetStubs() {
wireMock.resetAll();
}

@Inject
ShippingService shippingService;

@Test
void returnsQuoteFromCarrierApi() {
wireMock.stubFor(post(urlEqualTo("/quotes"))
.withHeader("Content-Type", containing("application/json"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"quoteId": "Q-999",
"price": 12.50,
"estimatedDays": 3
}
""")));

ShippingQuote quote = shippingService.getQuote(
new ShippingRequest("ADDR-FROM", "ADDR-TO", 2.5));

assertThat(quote.getQuoteId()).isEqualTo("Q-999");
assertThat(quote.getPrice()).isEqualByComparingTo("12.50");
assertThat(quote.getEstimatedDays()).isEqualTo(3);
}

@Test
void throwsWhenCarrierApiReturns503() {
wireMock.stubFor(post(urlEqualTo("/quotes"))
.willReturn(aResponse()
.withStatus(503)
.withBody("Service Unavailable")));

assertThrows(CarrierUnavailableException.class,
() -> shippingService.getQuote(
new ShippingRequest("ADDR-FROM", "ADDR-TO", 2.5)));
}

@Test
void sendsCorrectPayloadToCarrierApi() {
wireMock.stubFor(post(urlEqualTo("/quotes"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{"quoteId": "Q-1", "price": 5.00, "estimatedDays": 1}
""")));

shippingService.getQuote(new ShippingRequest("ADDR-FROM", "ADDR-TO", 1.0));

// Verify the outbound request contained the right fields
wireMock.verify(postRequestedFor(urlEqualTo("/quotes"))
.withRequestBody(matchingJsonPath("$.fromAddress", equalTo("ADDR-FROM")))
.withRequestBody(matchingJsonPath("$.weightKg", equalTo("1.0"))));
}
}

Key point: The third test above — sendsCorrectPayloadToCarrierApi — is something you can't easily do with a Mockito mock on a Java interface. WireMock lets you assert on the actual serialised HTTP request that left your application, which catches mapping errors that in-process mocks would miss entirely.

WireMock vs MockServer: Both tools serve equally well for this use case. WireMock tends to be more familiar to developers coming from the Spring ecosystem; MockServer is a strong alternative with a slightly different DSL and built-in support for request sequence verification. If your team already uses MockServer, there's no reason to switch.

The equivalent stub in MockServer looks like this:

// Dependency: testImplementation("org.mock-server:mockserver-netty:5.15.0")

import org.mockserver.client.MockServerClient;
import org.mockserver.integration.ClientAndServer;
import io.micronaut.test.support.TestPropertyProvider;

import static org.mockserver.model.HttpRequest.request;
import static org.mockserver.model.HttpResponse.response;

@MicronautTest
class ShippingServiceMockServerTest implements TestPropertyProvider {

static ClientAndServer mockServer = ClientAndServer.startClientAndServer(0);

@Override
public Map<String, String> getProperties() {
return Map.of("carrier.api.url", "http://localhost:" + mockServer.getPort());
}

@AfterEach
void resetExpectations() {
mockServer.reset();
}

@Inject
ShippingService shippingService;

@Test
void returnsQuoteFromCarrierApi() {
new MockServerClient("localhost", mockServer.getPort())
.when(request()
.withMethod("POST")
.withPath("/quotes"))
.respond(response()
.withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"quoteId": "Q-999",
"price": 12.50,
"estimatedDays": 3
}
"""));

ShippingQuote quote = shippingService.getQuote(
new ShippingRequest("ADDR-FROM", "ADDR-TO", 2.5));

assertThat(quote.getQuoteId()).isEqualTo("Q-999");
assertThat(quote.getPrice()).isEqualByComparingTo("12.50");
}
}

The structure is the same as WireMock: start a server, define stubs, point your client at the local URL. The choice between them is mostly team familiarity and DSL preference.

Using @MockBean for non-HTTP collaborators

@MockBean is still the right choice for internal beans that have side effects you don't want in a test — for example, a NotificationService that sends real emails, or an AuditLogger that writes to an external audit system. The key distinction: use @MockBean when the collaborator is an internal bean with an unwanted side effect; use WireMock when the collaborator makes an outbound HTTP call.

@MicronautTest
class OrderServiceIntegrationTest {

@Inject
OrderService orderService;

@Inject
NotificationService notificationService;

@MockBean(NotificationService.class)
NotificationService mockNotificationService() {
return Mockito.mock(NotificationService.class);
}

@Test
void placeOrderSendsNotification() {
// OrderService uses a real database (via Testcontainers config in application-test.yml)
// but we don't want it sending real emails during tests
Order order = orderService.placeOrder(
new CreateOrderRequest("CUST-1", List.of()));

verify(notificationService).send(eq("CUST-1"), contains("Order"));
}
}

Lightweight bean testing without @MicronautTest

Experienced Micronaut developers often test individual beans using ApplicationContext.run() directly, without the full @MicronautTest annotation. This is a distinctly Micronaut pattern that many Spring developers haven't encountered before:

@Test
void beanCanBeResolved() {
try (ApplicationContext context = ApplicationContext.run()) {

OrderService service = context.getBean(OrderService.class);

assertThat(service).isNotNull();
}
}

This is useful when you want to verify that a bean can be constructed and resolved from the container, without starting the embedded HTTP server and without the full test harness provided by @MicronautTest. The try-with-resources block ensures the context is closed cleanly after the test.

3. Database tests — with Testcontainers

Database tests run against a real PostgreSQL instance managed by Testcontainers. They verify repository queries, transactions, constraints, and Flyway migrations.

Test configuration

src/test/resources/application-test.yml:

datasources:
default:
url: jdbc:tc:postgresql:15:///testdb # tc: prefix = Testcontainers JDBC driver
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
username: test
password: test
dialect: POSTGRES

flyway:
datasources:
default:
enabled: true # migrations run automatically on the test container

The jdbc:tc: URL prefix is all you need — Testcontainers pulls the PostgreSQL Docker image, starts a container, and tears it down after the test run. No manual container management required.

A word on in-memory databases

Some teams reach for an in-memory database like H2 (in PostgreSQL compatibility mode) when they want tests to run faster or when a Docker daemon isn't available — on a locked-down CI pipeline, for example, or a contributor machine where Docker isn't installed. This is a real and understandable pragmatic choice, but it comes with a meaningful tradeoff: you're no longer testing against the database your application actually runs on.

A note on Micronaut Test Resources

Modern Micronaut projects increasingly use Micronaut Test Resources as an alternative to managing Testcontainers manually. It's worth understanding what it actually does: Test Resources intercepts your datasource configuration at test time, detects that a real database server isn't available, and automatically starts the appropriate Docker container on your behalf — no @Container annotations, no TestPropertyProvider, no URL wiring.

To appreciate what it removes, here is the manual Testcontainers approach:

// build.gradle — manual approach
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")


// Every test class that needs a database must carry this lifecycle boilerplate
@MicronautTest
@Testcontainers
class ProductRepositoryTest implements TestPropertyProvider {

@Container
static PostgreSQLContainer postgres =
new PostgreSQLContainer<>("postgres:15");

@Override
public Map getProperties() {
return Map.of(
"datasources.default.url", postgres.getJdbcUrl(),
"datasources.default.username", postgres.getUsername(),
"datasources.default.password", postgres.getPassword()
);
}

@Inject
ProductRepository productRepository;

// tests...
}

With Test Resources, the dependency changes and all of that lifecycle code disappears:

// build.gradle — Test Resources approach
testImplementation("io.micronaut.testresources:micronaut-test-resources-client")


# src/test/resources/application-test.yml
datasources:
default:
db-type: postgres # Test Resources reads this and starts the right container


// The test class — no @Container, no TestPropertyProvider, no URL wiring
@MicronautTest
class ProductRepositoryTest {

@Inject
ProductRepository productRepository;

// tests...
}

That's it. Micronaut starts a PostgreSQL container, injects the correct JDBC URL, and tears the container down when the test run completes. The container is also shared across test classes in the same run by default, which means you don't pay the startup cost multiple times.

The same mechanism works for Kafka, Redis, MongoDB, and several other infrastructure types — you declare what you need, and Test Resources provisions it. For Spring developers used to managing Testcontainers lifecycle manually, this feels like a significant quality-of-life improvement. It's one of the more distinctly Micronaut features worth adopting early if your project is greenfield or if you're finding the Testcontainers boilerplate growing unwieldy.

Testing a repository

Each test creates its own data and cleans up after itself — avoiding the shared state and ordering dependencies that make test suites fragile.

@MicronautTest
class ProductRepositoryTest {

@Inject
ProductRepository productRepository;

@AfterEach
void cleanUp() {
productRepository.deleteAll();
}

@Test
void savesProductAndPopulatesAuditFields() {
Product saved = productRepository.save(
new Product("Widget", "A basic widget", new BigDecimal("9.99"), "tools"));

assertThat(saved.getId()).isNotNull();
assertThat(saved.getCreatedAt()).isNotNull(); // @DateCreated populated
}

@Test
void findsProductById() {
Product saved = productRepository.save(
new Product("Widget", "A basic widget", new BigDecimal("9.99"), "tools"));

Optional found = productRepository.findById(saved.getId());

assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("Widget");
assertThat(found.get().getPrice()).isEqualByComparingTo("9.99");
}

@Test
void findsByCategoryReturnsOnlyMatchingProducts() {
productRepository.save(new Product("Hammer", "Heavy", new BigDecimal("14.99"), "tools"));
productRepository.save(new Product("Notebook", "Ruled", new BigDecimal("2.99"), "stationery"));

List tools = productRepository.findByCategory("tools");

assertThat(tools).hasSize(1);
assertThat(tools.get(0).getName()).isEqualTo("Hammer");
}

@Test
void updatesProduct() {
Product saved = productRepository.save(
new Product("Widget", "A basic widget", new BigDecimal("9.99"), "tools"));
saved.setPrice(new BigDecimal("12.99"));

Product updated = productRepository.update(saved);

assertThat(updated.getPrice()).isEqualByComparingTo("12.99");
}

@Test
void deletesProduct() {
Product saved = productRepository.save(
new Product("Widget", "A basic widget", new BigDecimal("9.99"), "tools"));

productRepository.deleteById(saved.getId());

assertThat(productRepository.findById(saved.getId())).isEmpty();
}
}

Spring comparison: Spring Boot offers @DataJpaTest for repository-slice tests that start only the persistence layer. In Micronaut, the equivalent pattern is @MicronautTest with a test datasource (as configured above). Micronaut doesn't have the same slice-testing culture as Spring, but it provides bean replacement, test environments, and Micronaut Test Resources for narrowing test scope when needed.

4. REST / HTTP tests

REST tests verify the full HTTP stack — routing, request binding, validation, status codes, response bodies, and error handling. When testing HTTP endpoints, @MicronautTest typically starts the embedded Netty server, allowing tests to make real HTTP calls against it.

Using a declarative test client

The cleanest approach is to define a @Client interface that mirrors your controller:

@Client("/products")
interface ProductClient {

@Get
List findAll();

@Get("/{id}")
Product findById(Long id);

@Post
HttpResponse create(@Body CreateProductRequest request);

@Put("/{id}")
Product update(Long id, @Body CreateProductRequest request);

@Delete("/{id}")
@Status(HttpStatus.NO_CONTENT)
void delete(Long id);
}


@MicronautTest
class ProductControllerTest {

@Inject
ProductClient productClient;

@MockBean(ProductService.class)
ProductService mockProductService() {
return Mockito.mock(ProductService.class);
}

@Inject
ProductService productService;

@Test
void createReturns201WithCreatedProduct() {
Product created = new Product(1L, "Widget", new BigDecimal("9.99"));
when(productService.create(any())).thenReturn(created);

HttpResponse response = productClient.create(
new CreateProductRequest("Widget", "desc", new BigDecimal("9.99"), "tools"));

assertThat(response.getStatus()).isEqualTo(HttpStatus.CREATED);
assertThat(response.body().getId()).isEqualTo(1L);
assertThat(response.body().getName()).isEqualTo("Widget");
}

@Test
void getReturns404WhenProductNotFound() {
when(productService.findById(99L)).thenReturn(Optional.empty());

HttpClientResponseException ex = assertThrows(
HttpClientResponseException.class,
() -> productClient.findById(99L));

assertThat(ex.getStatus()).isEqualTo(HttpStatus.NOT_FOUND);
}

@Test
void createReturns400WhenValidationFails() {
// Empty name violates @NotBlank
HttpClientResponseException ex = assertThrows(
HttpClientResponseException.class,
() -> productClient.create(
new CreateProductRequest("", "desc", new BigDecimal("9.99"), "tools")));

assertThat(ex.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST);
}
}

Testing with the raw HttpClient for response inspection

When you need to inspect response headers, status codes, or raw response bodies:

@MicronautTest
class ProductControllerRawTest {

@Inject
@Client("/")
HttpClient client;

@MockBean(ProductService.class)
ProductService mockService() {
return Mockito.mock(ProductService.class);
}

@Inject
ProductService productService;

@Test
void responseContainsLocationHeaderOnCreate() {
Product created = new Product(1L, "Widget", new BigDecimal("9.99"));
when(productService.create(any())).thenReturn(created);

HttpResponse response = client.toBlocking()
.exchange(HttpRequest.POST("/products",
new CreateProductRequest("Widget", "desc",
new BigDecimal("9.99"), "tools")),
Product.class);

assertThat(response.getStatus()).isEqualTo(HttpStatus.CREATED);
assertThat(response.header("Location")).isNotNull();
}

@Test
void errorResponseBodyHasExpectedStructure() {
when(productService.findById(99L)).thenReturn(Optional.empty());

HttpClientResponseException ex = assertThrows(
HttpClientResponseException.class,
() -> client.toBlocking().exchange(
HttpRequest.GET("/products/99"), Product.class));

Optional error = ex.getResponse().getBody(ErrorResponse.class);
assertThat(error).isPresent();
assertThat(error.get().getCode()).isEqualTo("PRODUCT_NOT_FOUND");
}
}

End-to-end REST + database test

When you need the full stack — HTTP → service → real database:

@MicronautTest // uses application-test.yml with Testcontainers PostgreSQL
class ProductControllerE2ETest {

@Inject
ProductClient productClient;

@Inject
ProductRepository productRepository;

@BeforeEach
void cleanUp() {
productRepository.deleteAll();
}

@Test
void fullCrudLifecycle() {
// Create
HttpResponse createResponse = productClient.create(
new CreateProductRequest("Widget", "A widget",
new BigDecimal("9.99"), "tools"));
assertThat(createResponse.getStatus()).isEqualTo(HttpStatus.CREATED);
Product created = createResponse.body();
assertThat(created.getId()).isNotNull();

// Read
Product retrieved = productClient.findById(created.getId());
assertThat(retrieved.getName()).isEqualTo("Widget");

// Update
Product updated = productClient.update(created.getId(),
new CreateProductRequest("Super Widget", "Improved",
new BigDecimal("14.99"), "tools"));
assertThat(updated.getName()).isEqualTo("Super Widget");
assertThat(updated.getPrice()).isEqualByComparingTo("14.99");

// Delete
productClient.delete(created.getId());

// Confirm deletion
assertThrows(HttpClientResponseException.class,
() -> productClient.findById(created.getId()));
}
}

5. Kafka tests

Micronaut's Kafka integration includes first-class test support via @KafkaClient and a Kafka broker provided by Testcontainers.

Dependencies

implementation("io.micronaut.kafka:micronaut-kafka")

// Awaitility — for polling async conditions without Thread.sleep()
testImplementation("org.awaitility:awaitility:4.2.0")

// Only needed if managing the Kafka container manually (see below)
testImplementation("org.testcontainers:kafka")

The producer and consumer under test

// Producer
@KafkaClient
public interface OrderEventProducer {

@Topic("order.events")
void send(@KafkaKey String orderId, OrderEvent event);
}

// Consumer
@KafkaListener(groupId = "notification-service")
public class OrderEventConsumer {

private final List receivedEvents = new CopyOnWriteArrayList<>();

@Topic("order.events")
public void consume(OrderEvent event) {
receivedEvents.add(event);
}

public List getReceivedEvents() {
return Collections.unmodifiableList(receivedEvents);
}
}

Managing the Kafka broker

As with databases, you have two options: manage the container yourself with Testcontainers, or let Micronaut Test Resources handle it.

Manual approach — Testcontainers with TestPropertyProvider

You declare the container, implement TestPropertyProvider to wire the dynamic broker address into the context, and tear it down yourself:

import io.micronaut.test.support.TestPropertyProvider;
import org.awaitility.Awaitility;
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.utility.DockerImageName;

import static org.awaitility.Awaitility.await;

@MicronautTest
@Testcontainers
class OrderEventKafkaTest implements TestPropertyProvider {

@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));

@Override
public Map getProperties() {
return Map.of(
"kafka.bootstrap.servers", kafka.getBootstrapServers()
);
}

@Inject
OrderEventProducer producer;

@Inject
OrderEventConsumer consumer;

// tests...
}

With Micronaut Test Resources

Add the Test Resources dependency and declare Kafka in your test configuration. The container lifecycle, broker URL, and wiring are all handled automatically — the test class carries no infrastructure code at all:

// build.gradle
testImplementation("io.micronaut.testresources:micronaut-test-resources-client")


# src/test/resources/application-test.yml
kafka:
enabled: true # A Kafka configuration can trigger Test Resources to provision a broker automatically


import static org.awaitility.Awaitility.await;

@MicronautTest // no @Testcontainers, no @Container, no TestPropertyProvider
class OrderEventKafkaTest {

@Inject
OrderEventProducer producer;

@Inject
OrderEventConsumer consumer;

// tests...
}

The tests themselves are identical either way — the only difference is how the broker gets started. The examples below use the cleaner Test Resources form.

Testing producer and consumer

@Test
void producerSendsEventAndConsumerReceivesIt() {
OrderEvent event = new OrderEvent("ORDER-123", "PLACED", "CUST-1");

producer.send("ORDER-123", event);

// Kafka is async — use Awaitility to poll until received rather than Thread.sleep()
await().atMost(10, TimeUnit.SECONDS)
.until(() -> consumer.getReceivedEvents().size() >= 1);

List received = consumer.getReceivedEvents();
assertThat(received).hasSize(1);
assertThat(received.get(0).getOrderId()).isEqualTo("ORDER-123");
assertThat(received.get(0).getStatus()).isEqualTo("PLACED");
}

@Test
void multipleEventsAreAllDelivered() {
List events = List.of(
new OrderEvent("ORDER-1", "PLACED", "CUST-1"),
new OrderEvent("ORDER-2", "SHIPPED", "CUST-2"),
new OrderEvent("ORDER-3", "DELIVERED", "CUST-3")
);

events.forEach(e -> producer.send(e.getOrderId(), e));

await().atMost(15, TimeUnit.SECONDS)
.until(() -> consumer.getReceivedEvents().size() >= 3);

List receivedOrderIds = consumer.getReceivedEvents()
.stream()
.map(OrderEvent::getOrderId)
.collect(Collectors.toList());

assertThat(receivedOrderIds).containsExactlyInAnyOrder(
"ORDER-1", "ORDER-2", "ORDER-3");
}

Key point: Kafka is always asynchronous. Avoid Thread.sleep() as your primary synchronisation strategy — it produces arbitrary wait times that make tests either slow or brittle. Use the Awaitility library (await().atMost(...)) to poll until a condition is met, which keeps tests deterministic and as fast as the system allows.

6. Configuration and conditional bean tests

@MicronautTest
class EmailConfigTest {

@Inject
EmailConfig emailConfig;

@Test
void configLoadsFromTestYml() {
assertThat(emailConfig.getHost()).isEqualTo("localhost");
assertThat(emailConfig.getPort()).isEqualTo(3025);
}
}

@MicronautTest
@Property(name = "myapp.features.notifications", value = "true")
class NotificationsEnabledTest {

@Inject
ApplicationContext context;

@Test
void notificationServiceBeanIsPresent() {
assertThat(context.containsBean(NotificationService.class)).isTrue();
}
}

@MicronautTest
@Property(name = "myapp.features.notifications", value = "false")
class NotificationsDisabledTest {

@Inject
ApplicationContext context;

@Test
void notificationServiceBeanIsAbsent() {
assertThat(context.containsBean(NotificationService.class)).isFalse();
}
}

7. Testing summary: which test type for what

Business logic in a single class → Unit test with plain JUnit 5 + Mockito

Beans wire correctly, DI container → Integration test with @MicronautTest + @MockBean for side-effectful internal beans

Lightweight bean resolution → ApplicationContext.run() without @MicronautTest

Outbound HTTP calls (external APIs, other microservices) → Integration test with @MicronautTest + WireMock

Repository queries, transactions, constraints → Database test with @MicronautTest + Testcontainers — prefer a real database whenever repository behaviour, SQL generation, migrations, or constraints are being tested

HTTP routing, status codes, validation, error handling → REST test with @MicronautTest + @Client

Full stack end-to-end (HTTP → DB) → E2E test with @MicronautTest + Testcontainers

Kafka producer publishes correct events → @MicronautTest + Testcontainers Kafka + Awaitility

Configuration loads and validates correctly → Integration test with @MicronautTest + @Inject config class

8. Spring → Micronaut quick reference

Full context test Spring Boot: @SpringBootTest Micronaut: @MicronautTest

Mock an internal bean with side effects Spring Boot: @MockBean (annotation on a field) Micronaut: @MockBean (annotation on a method that returns the mock)

Stub an outbound HTTP call (external API or another microservice) Spring Boot: WireMock + @DynamicPropertySource Micronaut: WireMock + TestPropertyProvider to supply the dynamic port before context startup

Override a property Spring Boot: @TestPropertySource Micronaut: @Property(name=..., value=...)

Inject test HTTP client Spring Boot: TestRestTemplate / MockMvc Micronaut: @Client declarative interface or HttpClient

Error response assertion Spring Boot: MockMvc.perform(...).andExpect(status().is4xxClientError()) Micronaut: assertThrows(HttpClientResponseException.class, ...)

Repository-focused tests Spring Boot: @DataJpaTest Micronaut: @MicronautTest with a real Testcontainers datasource

Kafka test broker Spring Boot: Embedded Kafka (@EmbeddedKafka) Micronaut: Testcontainers KafkaContainer + TestPropertyProvider

Async assertion Both: Awaitility await().atMost(...) — framework-agnostic, works in Spring Boot and Micronaut equally

Inject app context Spring Boot: @Autowired ApplicationContext Micronaut: @Inject ApplicationContext or ApplicationContext.run()

What to explore next

Micronaut Security testing — testing JWT-protected endpoints with @Secured, and injecting test tokens

WireMock advanced patterns — request templating, stateful scenarios, and fault simulation for more complex external API contracts

Test transactions — using @TransactionScope to roll back database state after each test method without deleteAll()

Micronaut OpenAPI — generating and asserting against the compiled Swagger spec as a contract test

N

About N Sharma

Lead Architect at StackAndSystem

N Sharma is a technologist with over 28 years of experience in software engineering, system architecture, and technology consulting. He holds a Bachelor’s degree in Engineering, a DBF, and an MBA. His work focuses on research-driven technology education—explaining software architecture, system design, and development practices through structured tutorials designed to help engineers build reliable, scalable systems.

Disclaimer

This article is for educational purposes only. Assistance from AI-powered generative tools was taken to format and improve language flow. While we strive for accuracy, this content may contain errors or omissions and should be independently verified.

Micronaut Testing Explained for Spring Boot Developers