Micronaut Test: @MicronautTest, Testcontainers, and MockBean
Micronaut's ahead-of-time (AOT) compilation model eliminates reflection-based dependency injection, producing applications that start in milliseconds. This same philosophy extends to testing — Micronaut's test framework (micronaut-test) integrates tightly with JUnit 5 and Spock, giving you fast, deterministic test execution with minimal boilerplate.
The Micronaut Testing Philosophy
Unlike Spring's runtime proxy model, Micronaut resolves injection at compile time. This means test startup is genuinely fast — a Micronaut application context typically starts in under 300ms for a moderate-sized service. The trade-off is that you must use Micronaut-aware annotations to participate in the IoC container during tests.
Project Setup
Add to pom.xml:
<dependency>
<groupId>io.micronaut.test</groupId>
<artifactId>micronaut-test-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>For Gradle (build.gradle):
testImplementation("io.micronaut.test:micronaut-test-junit5")
testImplementation("org.junit.jupiter:junit-jupiter-engine")Writing Tests with @MicronautTest
The @MicronautTest annotation starts a full Micronaut application context and injects beans into your test class:
@MicronautTest
class CalculatorTest {
@Inject
CalculatorService calculatorService;
@Test
void testAddition() {
assertThat(calculatorService.add(3, 4)).isEqualTo(7);
}
@Test
void testDivisionByZero() {
assertThrows(ArithmeticException.class,
() -> calculatorService.divide(10, 0));
}
}Testing HTTP Controllers with Declarative Clients
Micronaut's declarative HTTP client mirrors your server endpoints, making test code read almost identically to production client code:
// The controller
@Controller("/products")
public class ProductController {
private final ProductService productService;
ProductController(ProductService productService) {
this.productService = productService;
}
@Get("/{id}")
public HttpResponse<Product> getProduct(String id) {
return productService.findById(id)
.map(HttpResponse::ok)
.orElse(HttpResponse.notFound());
}
@Post
public HttpResponse<Product> createProduct(@Body ProductRequest request) {
Product created = productService.create(request);
return HttpResponse.created(created);
}
}
// The declarative test client
@Client("/products")
interface ProductClient {
@Get("/{id}")
Optional<Product> findById(String id);
@Post
@Status(HttpStatus.CREATED)
Product create(@Body ProductRequest request);
}
// The test
@MicronautTest
class ProductControllerTest {
@Inject
ProductClient productClient;
@Test
void testGetExistingProduct() {
Optional<Product> product = productClient.findById("prod-001");
assertThat(product).isPresent()
.get()
.extracting(Product::getName)
.isEqualTo("Widget Pro");
}
@Test
void testCreateProduct() {
ProductRequest request = new ProductRequest("New Widget", 29.99);
Product created = productClient.create(request);
assertThat(created.getId()).isNotNull();
assertThat(created.getName()).isEqualTo("New Widget");
}
}Mocking Beans with @MockBean
@MockBean replaces a bean in the application context with a Mockito mock, scoped to the test class:
@MicronautTest
class NotificationControllerTest {
@Inject
@Client("/notifications")
HttpClient httpClient;
@MockBean(EmailService.class)
EmailService emailService() {
return mock(EmailService.class);
}
@Inject
EmailService emailService;
@Test
void testNotificationSendsEmail() {
given(emailService.send(any()))
.willReturn(true);
HttpRequest<NotificationRequest> request = HttpRequest.POST(
"/notifications",
new NotificationRequest("user@example.com", "Test Subject", "Body")
);
HttpResponse<?> response = httpClient.toBlocking().exchange(request);
assertThat(response.getStatus().getCode()).isEqualTo(202);
verify(emailService, times(1)).send(any());
}
}The @MockBean factory method must match the bean type exactly. Micronaut replaces the real implementation at context startup — unlike runtime patching in some other frameworks.
Replacing Beans with @Replaces
For more complex scenarios where you want a full replacement implementation (not just a mock), use @Replaces:
@Singleton
@Replaces(ExternalPaymentGateway.class)
public class FakePaymentGateway implements PaymentGateway {
private final List<PaymentRequest> recorded = new ArrayList<>();
@Override
public PaymentResult charge(PaymentRequest request) {
recorded.add(request);
return new PaymentResult("FAKE-TXN-" + recorded.size(), "SUCCESS");
}
public List<PaymentRequest> getRecorded() {
return Collections.unmodifiableList(recorded);
}
}
@MicronautTest
class OrderServiceTest {
@Inject
OrderService orderService;
@Inject
FakePaymentGateway fakePaymentGateway;
@Test
void testOrderWithPayment() {
Order order = orderService.placeOrder(new OrderRequest("item-1", 2));
assertThat(order.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
assertThat(fakePaymentGateway.getRecorded()).hasSize(1);
assertThat(fakePaymentGateway.getRecorded().get(0).getAmount())
.isEqualTo(order.getTotal());
}
}Integration Testing with Testcontainers
Micronaut integrates with Testcontainers via the micronaut-test-core module and property injection:
@MicronautTest
@Testcontainers
class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("usersdb")
.withUsername("app")
.withPassword("secret");
@TestPropertyProvider
static Map<String, String> getProperties() {
return Map.of(
"datasources.default.url", postgres.getJdbcUrl(),
"datasources.default.username", postgres.getUsername(),
"datasources.default.password", postgres.getPassword()
);
}
@Inject
UserRepository userRepository;
@Test
@Transactional
void testPersistAndQuery() {
User user = new User(null, "bob@example.com", "Bob Smith");
user = userRepository.save(user);
assertThat(user.getId()).isNotNull();
Optional<User> found = userRepository.findByEmail("bob@example.com");
assertThat(found).isPresent()
.get()
.extracting(User::getName)
.isEqualTo("Bob Smith");
}
}For Micronaut 4.x, the recommended pattern uses @MicronautTest(environments = "test") combined with application-test.yml to override datasource properties:
# src/test/resources/application-test.yml
datasources:
default:
url: ${TC_POSTGRES_URL:`jdbc:tc:postgresql:15:///testdb`}
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriverTesting Reactive Endpoints
Micronaut supports reactive programming with Project Reactor and RxJava. Testing reactive controllers requires handling asynchronous responses:
@MicronautTest
class InventoryControllerTest {
@Inject
@Client("/inventory")
ReactorHttpClient reactorClient;
@Test
void testStreamInventoryUpdates() {
List<InventoryItem> received = reactorClient
.retrieve(HttpRequest.GET("/stream"), InventoryItem.class)
.take(3)
.collectList()
.block(Duration.ofSeconds(5));
assertThat(received).hasSize(3);
assertThat(received).allSatisfy(item ->
assertThat(item.getQuantity()).isGreaterThanOrEqualTo(0)
);
}
}Configuration-Driven Tests with @Property
Override configuration properties per test without spinning up a new context:
@MicronautTest
@Property(name = "feature.experimental-pricing", value = "true")
class ExperimentalPricingTest {
@Inject
PricingService pricingService;
@Test
void testExperimentalDiscountApplied() {
Price price = pricingService.calculate("premium-plan", "user-123");
assertThat(price.getDiscount()).isGreaterThan(BigDecimal.ZERO);
}
}You can also inject @Property directly into test fields:
@Inject
@Property(name = "app.max-retries")
int maxRetries;Transaction Rollback in Tests
Micronaut Test rolls back transactions after each test method when you annotate the test class with @MicronautTest(transactional = true) (the default for JPA/JDBC tests):
@MicronautTest // transactional = true by default with JPA
class AccountRepositoryTest {
@Inject
AccountRepository accountRepository;
@Test
void testCreateAccount() {
// This insert is rolled back after the test
Account account = accountRepository.save(
new Account("alice", BigDecimal.valueOf(1000))
);
assertThat(account.getId()).isNotNull();
}
@Test
void testFindByOwner() {
// No leftover data from previous test
List<Account> accounts = accountRepository.findByOwner("alice");
assertThat(accounts).isEmpty();
}
}Testing Security with @Client Authentication
Micronaut Security integrates with the test framework for authenticated endpoint testing:
@MicronautTest
class SecuredEndpointTest {
@Inject
@Client("/api")
HttpClient client;
@Test
void testUnauthenticatedReturns401() {
assertThrows(HttpClientResponseException.class, () ->
client.toBlocking().retrieve("/secure-data")
);
}
@Test
void testAuthenticatedSuccess() {
String token = generateTestJwt("user-123", List.of("ROLE_USER"));
String result = client.toBlocking()
.retrieve(HttpRequest.GET("/secure-data")
.bearerAuth(token));
assertThat(result).isNotNull();
}
}Connecting Micronaut Tests to End-to-End QA
Micronaut's test framework excels at service-level testing. For end-to-end validation across multiple microservices — checking that a purchase flow works correctly from the UI through the payment service — you need a higher-level tool.
HelpMeTest bridges this gap with plain-English test scenarios that run against your deployed services. Combine @MicronautTest for unit and integration coverage with HelpMeTest for behavioral monitoring, and you have confidence at every layer of your stack.
Summary
@MicronautTeststarts a full Micronaut context — fast enough to use for all integration tests@MockBeanfactory methods replace beans at context initialization — not via runtime proxies@Replacesprovides full bean substitutions for complex test doubles- Testcontainers integration with
@TestPropertyProvidergives you real infrastructure without environment coupling - Transaction rollback is automatic with JPA — each test starts with a clean slate
- Declarative test clients mirror your HTTP API, making tests readable and resilient