Mastering Micronaut Testing: From Basics to Advanced Techniques

Micronaut testing enables comprehensive end-to-end tests simulating real-world scenarios. It offers tools for REST endpoints, database interactions, mocking external services, async operations, error handling, configuration overrides, and security testing.

Mastering Micronaut Testing: From Basics to Advanced Techniques

Alright, let’s dive into the world of advanced Micronaut testing! If you’re like me, you’ve probably spent countless hours debugging your Micronaut applications, wishing there was a better way to catch issues before they hit production. Well, good news - there is! End-to-end testing in Micronaut is not only possible but also incredibly powerful when done right.

Micronaut’s built-in test tools, combined with JUnit, offer a robust framework for writing comprehensive end-to-end tests. These tests can simulate real-world scenarios, ensuring your application behaves correctly from start to finish. But where do you begin? Let’s break it down step by step.

First things first, you’ll need to set up your testing environment. Make sure you have the necessary dependencies in your build file. For Gradle users, add the following to your build.gradle:

testImplementation("io.micronaut.test:micronaut-test-junit5")
testImplementation("org.junit.jupiter:junit-jupiter-api")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")

If you’re using Maven, add these to your 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-api</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <scope>test</scope>
</dependency>

Now that we’ve got our dependencies sorted, let’s create our first end-to-end test. We’ll start with a simple example - testing a REST endpoint. Imagine we have a BookController that returns a list of books. Here’s how we might test it:

@MicronautTest
class BookControllerTest {

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

    @Test
    void testGetBooks() {
        HttpRequest<String> request = HttpRequest.GET("/books");
        HttpResponse<List<Book>> response = client.toBlocking().exchange(request, Argument.listOf(Book.class));

        assertEquals(HttpStatus.OK, response.status());
        assertNotNull(response.body());
        assertFalse(response.body().isEmpty());
    }
}

Let’s break this down. The @MicronautTest annotation tells Micronaut to start up an application context for our test. We’re injecting an HttpClient, which we’ll use to make requests to our application. The test method sends a GET request to the “/books” endpoint, expects a list of Book objects in response, and checks that the response is successful and contains data.

But what if we want to test more complex scenarios? Say, creating a new book and then retrieving it? No problem! Here’s how we might do that:

@Test
void testCreateAndRetrieveBook() {
    Book newBook = new Book("1984", "George Orwell");
    HttpRequest<Book> createRequest = HttpRequest.POST("/books", newBook);
    HttpResponse<Book> createResponse = client.toBlocking().exchange(createRequest, Book.class);

    assertEquals(HttpStatus.CREATED, createResponse.status());
    assertNotNull(createResponse.body());
    assertEquals("1984", createResponse.body().getTitle());

    Long bookId = createResponse.body().getId();
    HttpRequest<String> getRequest = HttpRequest.GET("/books/" + bookId);
    HttpResponse<Book> getResponse = client.toBlocking().exchange(getRequest, Book.class);

    assertEquals(HttpStatus.OK, getResponse.status());
    assertEquals("1984", getResponse.body().getTitle());
    assertEquals("George Orwell", getResponse.body().getAuthor());
}

This test creates a new book, checks that it was created successfully, then retrieves it and verifies the details. It’s a great example of how we can string together multiple operations in a single test to verify the full flow of our application.

Now, you might be thinking, “But what about database interactions? How do we test those?” Great question! Micronaut makes it easy to spin up a test database for your end-to-end tests. Here’s an example using H2:

@MicronautTest(transactional = false)
@Property(name = "datasources.default.url", value = "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
@Property(name = "datasources.default.driverClassName", value = "org.h2.Driver")
@Property(name = "datasources.default.username", value = "sa")
@Property(name = "datasources.default.password", value = "")
class BookRepositoryTest {

    @Inject
    BookRepository bookRepository;

    @Test
    void testSaveAndRetrieveBook() {
        Book book = new Book("The Hobbit", "J.R.R. Tolkien");
        book = bookRepository.save(book);

        assertNotNull(book.getId());

        Optional<Book> retrievedBook = bookRepository.findById(book.getId());
        assertTrue(retrievedBook.isPresent());
        assertEquals("The Hobbit", retrievedBook.get().getTitle());
    }
}

In this example, we’re using @Property annotations to configure an in-memory H2 database for our test. We’re then injecting our BookRepository and testing its save and retrieve operations directly.

But what if your application relies on external services? Testing these scenarios can be tricky, but Micronaut has us covered with its powerful mocking capabilities. Let’s say we have a WeatherService that calls an external API. We can mock this service in our tests like so:

@MicronautTest
class WeatherControllerTest {

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

    @MockBean(WeatherService.class)
    WeatherService weatherService() {
        return Mockito.mock(WeatherService.class);
    }

    @Test
    void testGetWeather() {
        WeatherService mock = applicationContext.getBean(WeatherService.class);
        when(mock.getWeather("London")).thenReturn(new Weather("London", "Cloudy", 15));

        HttpRequest<String> request = HttpRequest.GET("/weather/London");
        HttpResponse<Weather> response = client.toBlocking().exchange(request, Weather.class);

        assertEquals(HttpStatus.OK, response.status());
        assertEquals("London", response.body().getCity());
        assertEquals("Cloudy", response.body().getCondition());
        assertEquals(15, response.body().getTemperature());
    }
}

Here, we’re using @MockBean to replace the real WeatherService with a mock. We then set up the mock to return a specific weather report for London, and verify that our controller returns this data correctly.

Now, let’s talk about testing asynchronous operations. Micronaut excels at reactive programming, and our tests need to handle this too. Here’s an example of testing a reactive endpoint:

@MicronautTest
class ReactiveBookControllerTest {

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

    @Test
    void testGetBooksReactive() {
        HttpRequest<String> request = HttpRequest.GET("/books/reactive");
        List<Book> books = client.retrieve(request, Argument.listOf(Book.class)).blockingFirst();

        assertNotNull(books);
        assertFalse(books.isEmpty());
    }
}

In this test, we’re using the reactive retrieve method of the HttpClient, then using blockingFirst() to wait for the result. This allows us to test reactive endpoints in a synchronous manner.

But what about testing the actual asynchronous behavior? For that, we can use Micronaut’s support for CompletableFuture:

@Test
void testGetBooksAsync() throws ExecutionException, InterruptedException {
    HttpRequest<String> request = HttpRequest.GET("/books/async");
    CompletableFuture<HttpResponse<List<Book>>> future = client.exchange(request, Argument.listOf(Book.class));

    HttpResponse<List<Book>> response = future.get();
    assertEquals(HttpStatus.OK, response.status());
    assertNotNull(response.body());
    assertFalse(response.body().isEmpty());
}

This test sends an asynchronous request and waits for the response using CompletableFuture.get(). It’s a great way to ensure your async endpoints are working correctly.

Now, let’s talk about something that often gets overlooked in testing - error handling. How do we ensure our application behaves correctly when things go wrong? Here’s an example:

@Test
void testBookNotFound() {
    HttpRequest<String> request = HttpRequest.GET("/books/999");
    HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> {
        client.toBlocking().exchange(request, Book.class);
    });

    assertEquals(HttpStatus.NOT_FOUND, exception.getStatus());
}

This test verifies that our application returns a 404 Not Found status when we try to retrieve a non-existent book. It’s crucial to test these error scenarios to ensure your application degrades gracefully under unexpected conditions.

But what about testing different configurations? Micronaut makes it easy to override configuration for tests. Let’s say we want to test our application with a different database:

@MicronautTest
@Property(name = "datasources.default.url", value = "jdbc:h2:mem:testdb")
@Property(name = "datasources.default.driverClassName", value = "org.h2.Driver")
class AlternativeDatabaseTest {

    @Inject
    BookRepository bookRepository;

    @Test
    void testWithAlternativeDatabase() {
        Book book = new Book("1984", "George Orwell");
        book = bookRepository.save(book);

        assertNotNull(book.getId());
    }
}

Here, we’re using @Property annotations to override the default database configuration for this specific test class. This allows us to test our application with different configurations without changing our main application code.

Now, let’s talk about something that’s often overlooked in testing - security. How do we test endpoints that require authentication? Micronaut’s test framework has us covered:

@MicronautTest
class SecureEndpointTest {

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

    @Test
    void testSecureEndpoint() {
        HttpRequest<?> request = HttpRequest.GET("/secure")
                .basicAuth("user", "password");
        HttpResponse<String> response = client.toBlocking().exchange(request, String.class);

        assertEquals(HttpStatus.OK, response.status());
        assertEquals("Secret data", response.body());
    }

    @Test
    void testSecureEndpointUnauthorized() {
        HttpRequest<?> request = HttpRequest.GET("/secure");
        HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> {
            client.toBlocking().exchange(request, String.class);
        });

        assertEquals(HttpStatus.UNAUTHORIZED, exception.getStatus());
    }
}

In these tests, we’re verifying that our secure endpoint returns the expected data when provided with valid credentials, and returns an UNAUTHORIZED status when accessed without credentials.

As your application grows, you might find yourself with a large number of tests that take a long time to run. This is where Micronaut’s support for parallel test execution comes in handy. You can enable this in your build file. For Gradle:

test {
    useJUnitPlatform()
    systemProperty "junit.jupiter.execution.parallel.enabled", "true"
}

For Maven:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <includes>
            <include>**/*Spec.*</include>
            <include>**/*