Hello and welcome to my next article, in which I will show you how to test a Micronaut application in Kotlin.
To be even more specific- we will work with an application that exposes a REST API and connects to MongoDB. If you would like to learn how to do that step-by-step, then you can check out my other article. (But don’t worry, we will see its code snippets in this tutorial, too).
For testing, we’re going to use JUnit 5, MockK, REST Assured, as well as the @MicronautTest and Micronaut Test Resources.
Video Tutorial
If you prefer video content, then check out my video:
If you find this content useful, please leave a subscription 😉
Micronaut Project Overview
Let’s start everything by checking the project we will test today. Of course, we will focus on the most important parts, and for the full source code, I invite you to this GitHub repository.
Application Properties
In our project, we introduce 3 application properties files:
// application.properties micronaut.application.name=micronaut-testing // application-mongo.properties mongodb.uri=mongodb://localhost:27017/example // application-test.properties mongodb.package-names=com.codersee.model
The application name is the default property inserted when generating a new project.
Nevertheless, the two remaining are created on purpose. With their names- *mongo
, *test
– we instruct Micronaut that it should consider them only when mongo
and test
profiles are active.
But why this way? Well, to avoid two issues.
MongoDB Connection Issues with Micronaut Test Resources
So, let’s start everything by explaining why we don’t put the Mongo URI inside the main properties.
Well, when we use the Micronaut Test Resources, MongoDB support will start a MongoDB container and provide the value of the mongodb.uri
property.
Nevertheless, this won’t happen when we explicitly set the URI in the application properties. And that’s why we want to make this setting available only when we set the environment to mongo
.
CodecCacheKey Issue
Additionally, if we don’t specify explicitly the package name, where our model classes live, we’re going to end up with the following issue when running our Micronaut tests:
[io-executor-thread-1] ERROR i.m.http.server.RouteExecutor - Unexpected error occurred: Can't find a codec for CodecCacheKey{clazz=class com.codersee.model.AppUser, types=null}. org.bson.codecs.configuration.CodecConfigurationException: Can't find a codec for CodecCacheKey{clazz=class com.codersee.model.AppUser, types=null}. at org.bson.internal.ProvidersCodecRegistry.lambda$get$0(ProvidersCodecRegistry.java:87) at java.base/java.util.Optional.orElseGet(Optional.java:364) at org.bson.internal.ProvidersCodecRegistry.get(ProvidersCodecRegistry.java:80) at org.bson.internal.ProvidersCodecRegistry.get(ProvidersCodecRegistry.java:50) at com.mongodb.internal.operation.Operations.createFindOperation(Operations.java:188) ...
To me, looks like a bug. But thankfully, we can easily avoid that by putting that in the application-test.properties
(which is used in tests by default).
Models
Following, we introduce a bunch of models:
// Address.kt: import io.micronaut.serde.annotation.Serdeable @Serdeable.Serializable @Serdeable.Deserializable data class Address( val street: String, val city: String, val code: Int ) // AppUser.kt: import io.micronaut.data.annotation.GeneratedValue import io.micronaut.data.annotation.Id import io.micronaut.data.annotation.MappedEntity @MappedEntity data class AppUser( @field:Id @field:GeneratedValue val id: String? = null, val firstName: String, val lastName: String, val email: String, val address: Address ) // AppUserRequest.kt: import io.micronaut.serde.annotation.Serdeable @Serdeable.Deserializable data class AppUserRequest( val firstName: String, val lastName: String, val email: String, val street: String, val city: String, val code: Int ) // SearchRequest.kt: import io.micronaut.serde.annotation.Serdeable @Serdeable.Deserializable data class SearchRequest(val name: String)
Those classes are either used to persist/retrieve data from MongoDB or when deserializing JSON payloads into the objects.
Repository
Following, we have a repository layer responsible for communicating with our storage:
// AppUserRepository.kt: import com.codersee.model.AppUser import io.micronaut.data.mongodb.annotation.MongoFindQuery import io.micronaut.data.mongodb.annotation.MongoRepository import io.micronaut.data.repository.CrudRepository @MongoRepository interface AppUserRepository : CrudRepository<AppUser, String> { fun findByFirstNameEquals(firstName: String): List<AppUser> @MongoFindQuery("{ firstName: { \$regex: :name}}") fun findByFirstNameLike(name: String): List<AppUser> }
Service
Nextly, the service layer, where we inject the repository and encapsulate its methods:
// AppUserService.kt: import com.codersee.model.Address import com.codersee.model.AppUser import com.codersee.model.AppUserRequest import com.codersee.repository.AppUserRepository import io.micronaut.http.HttpStatus import io.micronaut.http.exceptions.HttpStatusException import jakarta.inject.Singleton @Singleton class AppUserService( private val appUserRepository: AppUserRepository ) { fun create(userRequest: AppUserRequest): AppUser = appUserRepository.save( userRequest.toAppUserEntity() ) fun findAll(): List<AppUser> = appUserRepository .findAll() .toList() fun findById(id: String): AppUser = appUserRepository.findById(id) .orElseThrow { HttpStatusException(HttpStatus.NOT_FOUND, "User with id: $id was not found.") } fun update(id: String, updateRequest: AppUserRequest): AppUser { val foundUser = findById(id) val updatedEntity = updateRequest .toAppUserEntity() .copy(id = foundUser.id) return appUserRepository.update(updatedEntity) } fun deleteById(id: String) { val foundUser = findById(id) appUserRepository.delete(foundUser) } fun findByNameLike(name: String): List<AppUser> = appUserRepository .findByFirstNameLike(name) private fun AppUserRequest.toAppUserEntity(): AppUser { val address = Address( street = this.street, city = this.city, code = this.code ) return AppUser( id = null, firstName = this.firstName, lastName = this.lastName, email = this.email, address = address ) } }
Controller
And lastly, the place where we expose our REST endpoints:
// AppUserController.kt: import com.codersee.model.AppUserRequest import com.codersee.service.AppUserService import com.codersee.model.SearchRequest import com.codersee.model.AppUser import io.micronaut.http.HttpStatus import io.micronaut.http.annotation.* import io.micronaut.scheduling.TaskExecutors import io.micronaut.scheduling.annotation.ExecuteOn @Controller("/users") @ExecuteOn(TaskExecutors.IO) class AppUserController( private val appUserService: AppUserService ) { @Get fun findAllUsers(): List<AppUser> = appUserService.findAll() @Get("/{id}") fun findById(@PathVariable id: String): AppUser = appUserService.findById(id) @Post @Status(HttpStatus.CREATED) fun createUser(@Body request: AppUserRequest): AppUser = appUserService.create(request) @Post("/search") fun searchUsers(@Body searchRequest: SearchRequest): List<AppUser> = appUserService.findByNameLike( name = searchRequest.name ) @Put("/{id}") fun updateById( @PathVariable id: String, @Body request: AppUserRequest ): AppUser = appUserService.update(id, request) @Delete("/{id}") @Status(HttpStatus.NO_CONTENT) fun deleteById(@PathVariable id: String) = appUserService.deleteById(id) }
Simple Kotlin Testing in Micronaut
Excellent. At this point, we know what exactly we are going to test.
And as the first approach we’re going to see will be a plain, old unit test.
Why?
Because at the end of the day, that’s what we will be dealing with most of the time.
So with that said, let’s take a look at the example test:
import com.codersee.repository.AppUserRepository import io.mockk.every import io.mockk.mockk import io.mockk.verify import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class AppUserServiceTest { private val appUserRepository = mockk<AppUserRepository>() private val appUserService = AppUserService(appUserRepository) @Test fun `should return empty user list`() { every { appUserRepository.findAll() } returns emptyList() val result = appUserService.findAll() assertTrue(result.isEmpty()) verify(exactly = 1) { appUserRepository.findAll() } } }
As we can see above, we simply mock (with MockK) the AppUserRepository and inject it into the AppUserService instance.
Then, we specify that all invocations of the findAll
method must return the empty list (with every
).
Lastly, we assert that the findAll
method from our service returns an empty list (assertTrue
) and that the findAll
method from the repository was invoked only once (verify
).
Nevertheless, we’re not going to spend too much here right now as this tutorial focuses on Micronaut testing in Kotlin. If you are interested in learning more about these tools, then let me know in the comments section 🙂
Integration Testing with @MicronautTest
With all of that done, let’s see how the Micronaut framework helps us with testing and a few interesting cases that will help us in real life.
What is a @MicronautTest?
Let’s start everything by explaining what exactly is the @MicronautTest.
In simple words, it’s an annotation that we can put on the test class to mark it a Micronaut test (no sh… Sherlock 🙂 :
import io.micronaut.test.extensions.junit5.annotation.MicronautTest @MicronautTest class SomeTest { // tests }
In practice, it means that when we run a test with that annotation, it will run a real application. It will use internal Micronaut features with no mocking. So at this point, we can clearly see that this will be the right solution for integration testing.
Moreover, this annotation can be used not only with JUnit 5, but also with Spock, Kotest, and Kotest 5.
@MicronautTest Configuration Options
The @MicronautTest allows us to configure a few properties, like the environments we want to run our test with, the packages to scan, or whether the automatic transaction wrapping should be enabled, or not:
import io.micronaut.test.extensions.junit5.annotation.MicronautTest @MicronautTest( environments = ["env-1, env-2"], packages = ["com.codersee.somepackage"], transactional = false ) class SomeTest { // tests }
For the full list of options, I highly encourage you to check out the docs (we can do that for instance in IntelliJ IDEA by clicking on the annotation with the left mouse button when we keep the left ctrl pressed).
What Are Micronaut Test Resources?
At the beginning of this tutorial, I mentioned the Micronaut Test Resources and I would like to make sure that we are on the same page with them.
So basically, Micronaute Test Resources integrates seamlessly with Testcontainers to provide throwaway containers for testing. But, we need to remember that Testcontainers require Docker-API compatible container runtime (in simple words- either Docker installed locally, or the Testcontainers Cloud).
In our case, we would like to do integration tests that require MongoDB. We could make use of the real database (which sometimes may be the case for you), provide some H2 database (not the best approach), or integrate Testcontainers. Thankfully, we don’t need to do that and the only thing we need is the appropriate import in our project:
plugins { id("io.micronaut.test-resources") version "4.2.1" }
As a reminder, I want to emphasize that test resources will be used only when the datasources.default.url
is missing. (and that’s why we removed the URI for MongoDB from tests).
Integration Tests With REST Assured
With all of that said, let’s combine everything together and test our Kotlin Micronaut application with JUnit 5 and REST Assured.
To do so, we must remember to add the necessary import:
testImplementation("io.micronaut.test:micronaut-test-rest-assured")
Verify Status Codes and Headers
Let’s start with a simple request to the GET /users
endpoint:
@MicronautTest class AppUserControllerTestWithoutMocking { @Test fun `should return 200 OK on GET users`(spec: RequestSpecification) { spec .`when`() .get("/users") .then() .statusCode(200) .header("Content-Type", "application/json") } }
As I mentioned previously- by default, with @MicronautTest the real application is started. Moreover, nothing here is mocked, so the Mongo test container delivered by the Micronaut Test Resources is used.
Thanks to the dedicated module we added, we can inject the RequestSpecification
into our tests and easily validate our endpoint.
In this case, we perform a GET request and verify that the response status code is 200 OK and the response Content-Type
header value is application/json
.
Verify 404 Not Found
Similarly, we can verify that no entry is present in the database:
@Test fun `should return 404 Not Found on GET user by ID`(spec: RequestSpecification) { spec .`when`() .get("/users/123123123123123123121231") .then() .statusCode(404) }
This test will be also good proof that entries inserted to MongoDB in other tests do not affect other tests.
Extract and JSON Array in REST Assured
Nextly, let’s take a look at the more complicated example:
@Test fun `should create a user`(spec: RequestSpecification) { val request = AppUserRequest( firstName = "Piotr", lastName = "Wolak", email = "contact@codersee.com", street = "Street", city = "City", code = 123, ) spec .`when`() .contentType(ContentType.JSON) .body(request) .post("/users") .then() .statusCode(201) val list = spec .`when`() .get("/users") .then() .statusCode(200) .body("size()", `is`(1)) .extract() .`as`(object : TypeRef<List<AppUser>>() {}) assertEquals(1, list.size) val createdUser = list.first() assertEquals(request.firstName, createdUser.firstName) assertEquals(request.street, createdUser.address.street) }
This time, we do a bunch of more interesting things.
Firstly, we make the POST request with a request body and verify that 201 Creates is returned.
After that, we call the GET /users
endpoint to get a list of users. When we get the response, we verify that the JSON array size is equal to 1 (with the .body("size()", is(1))
call). Lastly, we make use of the extract().`as` to convert the payload into the List of AppUser instances. Thanks to that we can easily perform assertions with JUnit 5 functions.
Add Kotlin Utils For REST Assured
As you probably know, when
and as
are keywords in Kotlin and that’s why we had to use backticks (“).
And to make our lives easier, let’s add the util
package in test
and create the TestUtil.kt
:
import io.restassured.common.mapper.TypeRef import io.restassured.response.ValidatableResponse import io.restassured.specification.RequestSpecification fun RequestSpecification.whenever(): RequestSpecification { return this.`when`() } fun <T> ValidatableResponse.extractAs(clazz: Class<T>) = this.extract() .`as`(clazz) fun <T> ValidatableResponse.extractAs(typeRef: TypeRef<T>) = this.extract() .`as`(typeRef)
As a result, from now on we can simply use the whenever() instead of `when`() and extractAs() instead of extract().`as`().
Reference The Server / Context
As the next step, let’s take a look at how to reference the server or current application context:
@MicronautTest class InjectingExample{ @Inject private lateinit var context: ApplicationContext @Inject private lateinit var server: EmbeddedServer // tests }
Sometimes it can be useful, and as we can see, we can simply inject them using the @Inject annotation.
Conditionally Run Tests
After that, let’s see how we can skip tests execution.
Why would we need that?
Well, integration tests sometimes can take a lot of time, and the bigger our project becomes, the bigger the chance they become annoying. And because of that, generally, it’s a good approach to somehow separate them from the faster unit tests.
When testing in Micronaut, we can easily achieve that using the @Requires annotation:
import io.micronaut.context.annotation.Requires import io.micronaut.test.extensions.junit5.annotation.MicronautTest import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @MicronautTest @Requires(env = ["integration-test"]) class SomeIntegrationTest { @Test fun `should perform integration test`() { assertTrue(false) } }
The @MicronautTest annotation turns tests into beans. And that’s why we can use the @Requires, just like we would do with a “standard” bean.
As a result, tests inside the SomeIntegrationTest
will run only, when the integration-test
the environment is active. (And we can set that for example by setting the environment variable- MICRONAUT_ENVIRONMENTS=integration-test
).
Testing Micronaut With MockK Mocks
As the last thing in our tutorial about testing Micronaut with Kotlin, let’s take a look at how we can mock things using the MockK.
Could we use Mockito? Yes. However, I find MockK better for Kotlin (DSLs <3 ).
In order to mock a bean in Micronaut, the only thing we need to do is annotate the method/inner class with the @MockBean annotation:
@MicronautTest class AppUserControllerTestWithMocking { private val repo: AppUserRepository = mockk<AppUserRepository>() @MockBean(AppUserRepository::class) fun appUserRepository(): AppUserRepository = repo }
As we can see, we introduce the AppUserRepository
mock as the repo
property, so that later, we will be able to refer to it easily in our test cases. Additionally, we add the function annotated with @MockBean
, thus informing Micronaut that the AppUserRepository
is the type we want to replace with our mock.
Moreover, thanks to this approach this mock will be limited only to tests defined in this class. So we can “rest assured” (hue hue) it won’t interfere with other classes.
As a result, our tests will look, as follows:
@MicronautTest class AppUserControllerTestWithMocking { private val repo: AppUserRepository = mockk<AppUserRepository>() @Test fun `should return 200 OK on GET users`(spec: RequestSpecification) { every { repo.findAll() } returns emptyList() spec .whenever() .get("/users") .then() .statusCode(200) .header("Content-Type", "application/json") } @Test fun `should return user by ID`(spec: RequestSpecification) { val foundUser = AppUser( id = "123", firstName = "Piotr", lastName = "Wolak", email = "contact@codersee.com", address = Address( street = "street", city = "city", code = 123 ) ) every { repo.findById("123") } returns Optional.of(foundUser) spec .whenever() .get("/users/123") .then() .statusCode(200) .body("id", equalTo("123")) .body("firstName", equalTo("Piotr")) .body("address.street", equalTo("street")) .body("address.code", equalTo(123)) } @Test fun `should return user by ID and verify with extract`(spec: RequestSpecification) { val foundUser = AppUser( id = "123", firstName = "Piotr", lastName = "Wolak", email = "contact@codersee.com", address = Address( street = "street", city = "city", code = 123 ) ) every { repo.findById("123") } returns Optional.of(foundUser) val extracted = spec .whenever() .get("/users/123") .then() .statusCode(200) .extractAs(AppUser::class.java) assertEquals(foundUser, extracted) } @Test fun `should create a user`(spec: RequestSpecification) { val request = AppUserRequest( firstName = "Piotr", lastName = "Wolak", email = "contact@codersee.com", street = "Street", city = "City", code = 123, ) val createdUser = AppUser( id = "123", firstName = "Piotr", lastName = "Wolak", email = "contact@codersee.com", address = Address( street = "street", city = "city", code = 123 ) ) every { repo.save(any()) } returns createdUser val extracted = spec .whenever() .contentType(ContentType.JSON) .body(request) .post("/users") .then() .statusCode(201) .extractAs(AppUser::class.java) assertEquals(createdUser, extracted) } @MockBean(AppUserRepository::class) fun appUserRepository(): AppUserRepository = repo }
Summary
And that’s all for this tutorial on how to perform testing in Micronaut with Kotlin.
Together, we’ve discovered a bunch of interesting things that I hope will be useful in your projects.
If you would like to see the codebase for this tutorial, then check out this GitHub repository. Or, if you are interested in learning more about Micronaut, then check out my other posts.
Let me know your thoughts in the comments section below! 🙂