When I started to implement tests with Spring Boot, I struggled to find a way to implement integration tests.
Integration tests need to pass through all the flow, beginning at the controller layer and ending at the database layer. But how could I access the database to perform a test? Here are some options:
- Access a different database that exists only for test purposes. This option requires a database infrastructure to maintain, resulting in more costs.
- Use the
@DataJpaTest
to instance an H2 memory database, which will die by the end of the test. But it doesn’t really test in all ways your database layer, because it doesn’t use the same drivers and keywords of your production database. For example: If you have a table namedUser
, with H2 it will run perfectly, but with PostgreSQL, it will fail, sinceUser
is a keyword. - Use Testcontainers to build a Docker container with the same database you use in production. Yes, that’s the best option.
What is Testcontainers?
Testcontainers is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.
Testcontainers Docs
But what does that mean?
It means that you can run not only databases but everything that can run in a Docker container! You can test the whole flow of your app, passing through databases (like PostgreSQL, MySQL, etc…), cache strategies (like using Redis), and more!
How to
To demonstrate how to use Testcontainers I created a simple app with Spring Initializr using Kotlin and Gradle:
- Spring Web to develop endpoints
- Spring Data JPA to access the database without creating native queries
- PostgreSQL Driver to connect to the database
This is how our app will look like:
The base project
This simple app has a Book CRUD with this structure:
The Model:
@Entity
data class Book(
@Id
val id: UUID = UUID.randomUUID(),
val title: String? = null,
val author: String? = null
)
The Controller:
@RestController
@RequestMapping("/books")
class BookController(private val bookService: BookService) {
@PostMapping
fun create(@RequestBody book: Book): ResponseEntity<Book> {
val newBook = bookService.create(book)
return ResponseEntity.created(URI.create("/books/${newBook.id}")).body(newBook)
}
@GetMapping
fun getBooks() = ResponseEntity.ok(bookService.getAll())
@GetMapping("/{id}")
fun findBookById(@PathVariable id: UUID) = ResponseEntity.ok(bookService.findById(id))
@PutMapping("/{id}")
fun update(@PathVariable id: UUID, @RequestBody book: Book) = ResponseEntity.ok(bookService.update(id, book))
@DeleteMapping("/{id}")
fun delete(@PathVariable id: UUID): ResponseEntity<HttpStatus> {
bookService.delete(id)
return ResponseEntity.noContent().build()
}
}
The Service:
@Service
class BookService(private val bookRepository: BookRepository) {
fun create(book: Book): Book = bookRepository.save(book)
fun getAll(): List<Book> = bookRepository.findAll()
fun findById(id: UUID): Book = findBook(id)
fun update(id: UUID, book: Book): Book = bookRepository.save(with(book) {
findBook(id).copy(title = title, author = author)
})
fun delete(id: UUID) = bookRepository.delete(findBook(id))
private fun findBook(id: UUID): Book = bookRepository.findById(id).orElseThrow { BookNotFoundException(id) }
}
The Repository:
@Repository
interface BookRepository : JpaRepository<Book, UUID>
The exception package is not important, but you can check it out in my GitHub Repo.
Until now, It’s a simple CRUD. Nothing special here. Now we can start to add and configure Testcontainers.
Adding Testcontainers dependency
Simply add this Testcontainers dependency at the end of your build.gradle.kts
dependencies.
testImplementation("org.testcontainers:testcontainers:1.16.0")
Configuring the base integration test
In order to make all integration tests have the same configuration, we have to create a base integration test, so our tests will inherit the base behavior by extending it.
The first step is to create the BaseIntegrationTest
class, that will instantiate the tests in a random port.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class BaseIntegrationTest {
}
Now, we can add a cleanDB
method that will truncate all our tables. That’s the method we are going to use before each of our tests, to make sure we have a brand new empty database.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class BaseIntegrationTest {
@Autowired
private val jdbcTemplate: JdbcTemplate? = null
@Transactional
protected fun cleanDB() {
val tablesToTruncate = listOf("book").joinToString()
val sql = """
TRUNCATE TABLE $tablesToTruncate CASCADE
""".trimIndent()
jdbcTemplate?.execute(sql)
}
}
I’m using JDBC to truncate the tables, but you can use any method you want.
Testcontainers initializer
To initialize the containers, we have to create an initializer for the tests, so let’s create the BaseITInitializer
, that will implement the ApplicationContextInitializer
.
class BaseITInitializer : ApplicationContextInitializer<ConfigurableApplicationContext> {
override fun initialize(applicationContext: ConfigurableApplicationContext) {
TODO("Not yet implemented")
}
}
The next step is to configure a docker-compose file to initialize our containers. At the root of the project, let’s create a docker directory with a docker-compose.yml
inside.
version: '3'
services:
postgres:
image: postgres:9.6
environment:
POSGRES_USER: testcontainers
POSTGRES_PASSWORD: 1234
POSTGRES_DB: testcontainers_demo
ports:
- "5432:5432"
Now we can start to create our containers using docker-compose as parameter.
class BaseITInitializer : ApplicationContextInitializer<ConfigurableApplicationContext> {
companion object {
class KDockerComposeContainer(file: File) : DockerComposeContainer<KDockerComposeContainer>(file)
class Container(
val serviceName: String,
val port: Int
)
private val POSTGRES = Container("postgres_1", 5432)
private val COMPOSE_CONTAINER: KDockerComposeContainer by lazy {
KDockerComposeContainer(File("docker/docker-compose.yml"))
.withExposedService(POSTGRES.serviceName, POSTGRES.port)
.withLocalCompose(true)
}
}
override fun initialize(applicationContext: ConfigurableApplicationContext) {
TODO("Not yet implemented")
}
}
It’s time to implement the initialize
method, by initializing our compose container and defining the environment variables.
class BaseITInitializer : ApplicationContextInitializer<ConfigurableApplicationContext> {
companion object {
class KDockerComposeContainer(file: File) : DockerComposeContainer<KDockerComposeContainer>(file)
class Container(
val serviceName: String,
val port: Int
)
private val POSTGRES = Container("postgres_1", 5432)
private val COMPOSE_CONTAINER: KDockerComposeContainer by lazy {
KDockerComposeContainer(File("docker/docker-compose.yml"))
.withExposedService(POSTGRES.serviceName, POSTGRES.port)
.withLocalCompose(true)
}
}
override fun initialize(applicationContext: ConfigurableApplicationContext) {
COMPOSE_CONTAINER.start()
val jdbcURL = "jdbc:postgresql://${getContainerUrl(POSTGRES)}:${POSTGRES.port}/testcontainers_demo"
TestPropertyValues.of(
"spring.datasource.url=$jdbcURL"
).applyTo(applicationContext.environment)
}
private fun getContainerUrl(container: Container) =
COMPOSE_CONTAINER.getServiceHost(container.serviceName, container.port)
}
To finish the configuration, we just need to add our initializer at the base test.
@ContextConfiguration(initializers = [BaseITInitializer::class])
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class BaseIntegrationTest {
@Autowired
private val jdbcTemplate: JdbcTemplate? = null
@Transactional
protected fun cleanDB() {
val tablesToTruncate = listOf("book").joinToString()
val sql = """
TRUNCATE TABLE $tablesToTruncate CASCADE
""".trimIndent()
jdbcTemplate?.execute(sql)
}
}
Implementing the Integration Tests
Since we have the base test, we can start to implement the BookIntegrationTest
, by extending the base, cleaning the database before each test, and initializing the mockMvc
with context.
class BookIntegrationTest : BaseIntegrationTest() {
private lateinit var mockMvc: MockMvc
@Autowired
private lateinit var context: WebApplicationContext
@BeforeEach fun setup() {
cleanDB()
mockMvc = MockMvcBuilders.webAppContextSetup(context).build()
}
}
To test the listing books endpoint, first, we have to populate our empty database with some books. To do that, let’s create a seed.
class BookSeeds(private val bookRepository: BookRepository) {
fun insertMultipleBooks() {
bookRepository.saveAll(
listOf(
Book(
id = UUID.fromString("1e162d4e-66a4-47ba-85bd-9019dedc30a2"),
title = "Title 1",
author = "Author 1"
),
Book(
id = UUID.fromString("1b5ba5f8-b99f-4421-8f0c-7048263a10bb"),
title = "Title 2",
author = "Author 2"
),
Book(
id = UUID.fromString("dd11fc32-bcaa-414d-9c8c-89a50bd05acc"),
title = "Title 3",
author = "Author 3"
)
)
)
}
}
I’m using Spring Data JPA to populate the table with books, but you can use other methods, like JDBC Template, Flyway, Liquibase, etc…
Now, we have to generate the books after cleaning the database.
class BookIntegrationTest : BaseIntegrationTest() {
private lateinit var mockMvc: MockMvc
@Autowired
private lateinit var context: WebApplicationContext
@Autowired
private lateinit var bookRepository: BookRepository
@BeforeEach
fun setup() {
cleanDB()
BookSeeds(bookRepository).insertMultipleBooks()
mockMvc = MockMvcBuilders.webAppContextSetup(context).build()
}
}
Add books listing test, and test it! (Remember to have docker up and running, and nothing can be running on port 5432)
class BookIntegrationTest : BaseIntegrationTest() {
private lateinit var mockMvc: MockMvc
@Autowired
private lateinit var context: WebApplicationContext
@Autowired
private lateinit var bookRepository: BookRepository
@BeforeEach
fun setup() {
cleanDB()
BookSeeds(bookRepository).insertMultipleBooks()
mockMvc = MockMvcBuilders.webAppContextSetup(context).build()
}
@Test
fun should return all books
() {
val request = MockMvcRequestBuilders.get("/books")
val response = mockMvc.perform(request).andExpect(MockMvcResultMatchers.status().isOk)
val jsonArray = JSONArray(response.andReturn().response.contentAsString)
// Http verification
assertEquals(jsonArray.length(), 3)
}
}
Add more tests…
class BookIntegrationTest : BaseIntegrationTest() {
private lateinit var mockMvc: MockMvc
@Autowired
private lateinit var context: WebApplicationContext
@Autowired
private lateinit var bookRepository: BookRepository
private val bookId = UUID.fromString("1e162d4e-66a4-47ba-85bd-9019dedc30a2")
@BeforeEach
fun setup() {
cleanDB()
BookSeeds(bookRepository).insertMultipleBooks()
mockMvc = MockMvcBuilders.webAppContextSetup(context).build()
}
@Test
fun should return all books
() {
val request = MockMvcRequestBuilders.get("/books")
val response = mockMvc.perform(request).andExpect(MockMvcResultMatchers.status().isOk)
val jsonArray = JSONArray(response.andReturn().response.contentAsString)
// Http verification
assertEquals(jsonArray.length(), 3)
}
@Test
fun should return book by id
() {
val request = MockMvcRequestBuilders.get("/books/$bookId")
val response = mockMvc.perform(request).andExpect(MockMvcResultMatchers.status().isOk)
val jsonObject = JSONObject(response.andReturn().response.contentAsString)
// Http verification
assertEquals(jsonObject.get("id"), bookId.toString())
assertEquals(jsonObject.get("title"), "Title 1")
assertEquals(jsonObject.get("author"), "Author 1")
}
@Test
fun should create book
() {
val body = """
{
"title": "A title",
"author": "An author"
}
""".trimIndent()
val request = MockMvcRequestBuilders
.post("/books")
.contentType(MediaType.APPLICATION_JSON)
.content(body)
val response = mockMvc.perform(request).andExpect(MockMvcResultMatchers.status().isCreated)
val jsonObject = JSONObject(response.andReturn().response.contentAsString)
// Http json verification
assertNotNull(jsonObject.get("id"))
assertEquals(jsonObject.get("title"), "A title")
assertEquals(jsonObject.get("author"), "An author")
// Database verification
val book = bookRepository.findById(UUID.fromString(jsonObject.get("id").toString()))
assertEquals(book.get().title, "A title")
assertEquals(book.get().author, "An author")
}
@Test
fun should update book
() {
val body = """
{
"title": "Updated title",
"author": "Updated author"
}
""".trimIndent()
val request = MockMvcRequestBuilders
.put("/books/$bookId")
.contentType(MediaType.APPLICATION_JSON)
.content(body)
val response = mockMvc.perform(request).andExpect(MockMvcResultMatchers.status().isOk)
val jsonObject = JSONObject(response.andReturn().response.contentAsString)
assertEquals(jsonObject.get("id"), bookId.toString())
assertEquals(jsonObject.get("title"), "Updated title")
assertEquals(jsonObject.get("author"), "Updated author")
// Database verification
val book = bookRepository.findById(UUID.fromString(jsonObject.get("id").toString()))
assertEquals(book.get().title, "Updated title")
assertEquals(book.get().author, "Updated author")
}
@Test
fun should delete book by id
() {
val request = MockMvcRequestBuilders.delete("/books/$bookId")
mockMvc.perform(request).andExpect(MockMvcResultMatchers.status().isNoContent)
// Database verification
val bookExists = bookRepository.existsById(bookId)
assertFalse(bookExists)
}
}
It’s working!
This is our finished structure:
The full project can be found on my Github.
Conclusion
Testcontainers can provide a whole environment to implement integration tests. In this post, I covered only database proposes, but you can actually add more infrastructure to cover, like caching, mocking external requests with Mock Server, etc.
There are a lot of ways to implement integration tests, and Testcontainers is a really good choice, so give it a try!
Thank you for reading, and don’t stop learning!
We want to work with you. Check out our "What We Do" section!