Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

Testing with @MicronautTest

Checking the API by hand with curl after every change takes a long time. Tests do it automatically. The @MicronautTest annotation starts a real application for the test class: a context with beans, the H2 database and an HTTP server on a random port. Any bean can be injected into the test with @Inject, just like into an ordinary class.

Where tests live

Tests live in src/test/java/com/example/. The generator already created BlogTest.java, which checks that the application starts. You can create a new test by hand or with mn create-test ArticleController. Running all tests:

./mvnw test

A controller test

// src/test/java/com/example/ArticleControllerTest.java
package com.example;
 
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
 
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
 
@MicronautTest
class ArticleControllerTest {
 
    @Inject
    @Client("/")
    HttpClient client;
 
    @Test
    void createsAndReadsArticle() {
        HttpResponse<Article> response = client.toBlocking().exchange(
                HttpRequest.POST("/articles", new ArticleForm("Testing", "Body")),
                Article.class);
        assertEquals(HttpStatus.CREATED, response.getStatus());
 
        Long id = response.body().id();
        Article loaded = client.toBlocking().retrieve("/articles/" + id, Article.class);
        assertEquals("Testing", loaded.title());
    }
 
    @Test
    void returns404ForUnknownArticle() {
        HttpClientResponseException e = assertThrows(HttpClientResponseException.class,
                () -> client.toBlocking().retrieve("/articles/999", Article.class));
        assertEquals(HttpStatus.NOT_FOUND, e.getStatus());
    }
}

@Client("/") gives an HTTP client pointed at the test server. exchange returns the whole response with status and body, retrieve returns just the body. The client throws a response with code 400 or above as an HttpClientResponseException, so errors are checked with assertThrows. The third test in this class, rejectsBlankTitle, sends an article with an empty title the same way and expects HttpStatus.BAD_REQUEST.

Replacing a bean

A test should not go out to the internet. A method marked @MockBean replaces the real bean with yours for the duration of the test:

// src/test/java/com/example/ImportControllerTest.java
@MicronautTest
class ImportControllerTest {
 
    @Inject
    @Client("/")
    HttpClient client;
 
    @MockBean(PlaceholderClient.class)
    PlaceholderClient fakePlaceholder() {
        return new PlaceholderClient() {
            @Override
            public Optional<ExternalPost> fetchPost(Long id) {
                return Optional.of(new ExternalPost(id, 1L, "Fake title", "Fake body"));
            }
 
            @Override
            public List<ExternalPost> fetchByUser(Long userId) {
                return List.of();
            }
        };
    }
 
    @Test
    void importsPostFromRemoteServer() {
        Article article = client.toBlocking().retrieve(
                HttpRequest.POST("/import/5", ""), Article.class);
        assertEquals("Fake title", article.title());
    }
}

ImportController receives the fake client instead of the real one. Instead of an anonymous class you can use Mockito (the mockito feature in the generator).

The result

[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.ArticleControllerTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.ArticleServiceTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.BlogTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.ImportControllerTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Common mistake

By default @MicronautTest wraps every test method in a transaction and rolls it back at the end, so tests do not affect each other. But then the @Transactional publish method joins the test's transaction and does not roll back on its own. Here is a test that checks the rollback:

// src/test/java/com/example/ArticleServiceTest.java
@MicronautTest
class ArticleServiceTest {
 
    @Inject
    ArticleService articleService;
 
    @Inject
    ArticleRepository articleRepository;
 
    @Test
    void publishRollsBackWhenCommentIsBlank() {
        long before = articleRepository.count();
 
        assertThrows(IllegalArgumentException.class,
                () -> articleService.publish(new ArticleForm("Draft", "Text"), " "));
 
        assertEquals(before, articleRepository.count());
    }
}

With a plain @MicronautTest it fails: inside the shared transaction the article is still visible.

[ERROR] ArticleServiceTest.publishRollsBackWhenCommentIsBlank:26 expected: <0> but was: <1>

For tests that check transactions themselves, switch the wrapper off: @MicronautTest(transactional = false).

Thanks for reading!

Donate to the site — we work hard for you.