Micronaut Test: @MicronautTest, Testcontainers, and MockBean

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.ContainerDatabaseDriver

Testing 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

  • @MicronautTest starts a full Micronaut context — fast enough to use for all integration tests
  • @MockBean factory methods replace beans at context initialization — not via runtime proxies
  • @Replaces provides full bean substitutions for complex test doubles
  • Testcontainers integration with @TestPropertyProvider gives 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

Read more