Kotlin Integration Testing
Introduction
Integration testing is a crucial phase in the software testing process that focuses on verifying that different modules or components of your application work correctly together. While unit tests ensure individual components function as expected in isolation, integration tests validate the interaction between these components.
In this tutorial, we'll explore how to write effective integration tests in Kotlin, understand their importance in the testing pyramid, and learn best practices for implementing them in real-world applications.
What is Integration Testing?
Integration testing sits between unit testing and end-to-end testing in the testing pyramid:
- Unit tests: Test individual functions or classes in isolation
- Integration tests: Test how components interact with each other
- End-to-end tests: Test the entire application flow
Integration tests are particularly valuable because they can catch issues that unit tests might miss, such as:
- Data inconsistencies between components
- API contract violations
- Configuration issues
- Dependency injection problems
- Database interaction issues
Setting Up Integration Tests in Kotlin
Required Dependencies
Let's start by setting up the necessary dependencies for integration testing in a Kotlin project. Add the following to your build.gradle.kts
file:
dependencies {
// JUnit 5 for testing
testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.2")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.2")
// MockK for mocking dependencies
testImplementation("io.mockk:mockk:1.13.4")
// For testing Spring applications (if applicable)
testImplementation("org.springframework.boot:spring-boot-starter-test:2.7.8")
// Testcontainers for database integration tests
testImplementation("org.testcontainers:junit-jupiter:1.17.6")
testImplementation("org.testcontainers:postgresql:1.17.6") // if using PostgreSQL
}
tasks.test {
useJUnitPlatform()
}
Project Structure
It's a good practice to separate your integration tests from unit tests:
src/
├── main/
│ └── kotlin/
│ └── com/example/
│ └── yourapp/
│ ├── controllers/
│ ├── services/
│ └── repositories/
├── test/
│ └── kotlin/
│ └── com/example/
│ └── yourapp/
│ ├── unit/
│ │ ├── controllers/
│ │ ├── services/
│ │ └── repositories/
│ └── integration/
│ ├── api/
│ ├── service/
│ └── repository/
Basic Integration Test Example
Let's create a simple integration test for a user service that interacts with a repository:
// UserRepository.kt
interface UserRepository {
fun findById(id: Long): User?
fun save(user: User): User
}
// UserService.kt
class UserService(private val repository: UserRepository) {
fun getUserById(id: Long): User? {
return repository.findById(id)
}
fun createUser(user: User): User {
// Some validation logic
if (user.email.isEmpty() || !user.email.contains("@")) {
throw IllegalArgumentException("Invalid email")
}
return repository.save(user)
}
}
// User.kt
data class User(
val id: Long? = null,
val name: String,
val email: String
)
Now, let's write an integration test for the UserService
and UserRepository
interaction:
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
class UserServiceIntegrationTest {
@Test
fun `getUserById should return user from repository`() {
// Setup
val repository = mockk<UserRepository>()
val service = UserService(repository)
val expectedUser = User(1, "John Doe", "[email protected]")
every { repository.findById(1) } returns expectedUser
// Execute
val result = service.getUserById(1)
// Verify
assertEquals(expectedUser, result)
verify { repository.findById(1) }
}
@Test
fun `createUser should validate and save user to repository`() {
// Setup
val repository = mockk<UserRepository>()
val service = UserService(repository)
val inputUser = User(name = "Jane Doe", email = "[email protected]")
val savedUser = inputUser.copy(id = 2)
every { repository.save(inputUser) } returns savedUser
// Execute
val result = service.createUser(inputUser)
// Verify
assertEquals(savedUser, result)
verify { repository.save(inputUser) }
}
@Test
fun `createUser should throw exception when email is invalid`() {
// Setup
val repository = mockk<UserRepository>()
val service = UserService(repository)
val userWithInvalidEmail = User(name = "Invalid", email = "invalid-email")
// Execute & Verify
val exception = assertThrows(IllegalArgumentException::class.java) {
service.createUser(userWithInvalidEmail)
}
assertEquals("Invalid email", exception.message)
// Verify repository was never called
verify(exactly = 0) { repository.save(any()) }
}
}
In this example, we're testing the interaction between the UserService
and UserRepository
components using MockK to mock the repository.
Database Integration Tests
For real-world applications, you often need to test interactions with a database. Here's how to set up a database integration test using Testcontainers:
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.AfterAll
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import javax.sql.DataSource
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.datasource.DriverManagerDataSource
@Testcontainers
class UserRepositoryIntegrationTest {
companion object {
@Container
val postgres = PostgreSQLContainer<Nothing>("postgres:14").apply {
withDatabaseName("testdb")
withUsername("test")
withPassword("test")
}
private lateinit var dataSource: DataSource
private lateinit var jdbcTemplate: JdbcTemplate
@JvmStatic
@BeforeAll
fun setup() {
// Set up the data source
dataSource = DriverManagerDataSource().apply {
setDriverClassName("org.postgresql.Driver")
url = postgres.jdbcUrl
username = postgres.username
password = postgres.password
}
jdbcTemplate = JdbcTemplate(dataSource)
// Create tables
jdbcTemplate.execute("""
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL
)
""")
}
}
@Test
fun `should save and retrieve user from database`() {
// Create a real repository implementation
val repository = PostgresUserRepository(jdbcTemplate)
// Create a test user
val user = User(name = "Test User", email = "[email protected]")
// Save user
val savedUser = repository.save(user)
assertNotNull(savedUser.id)
// Retrieve user
val retrievedUser = repository.findById(savedUser.id!!)
// Verify
assertNotNull(retrievedUser)
assertEquals(savedUser.id, retrievedUser?.id)
assertEquals("Test User", retrievedUser?.name)
assertEquals("[email protected]", retrievedUser?.email)
}
}
// PostgresUserRepository implementation
class PostgresUserRepository(private val jdbcTemplate: JdbcTemplate) : UserRepository {
override fun findById(id: Long): User? {
return try {
jdbcTemplate.queryForObject(
"SELECT id, name, email FROM users WHERE id = ?",
{ rs, _ -> User(rs.getLong("id"), rs.getString("name"), rs.getString("email")) },
id
)
} catch (e: Exception) {
null
}
}
override fun save(user: User): User {
if (user.id == null) {
// Insert new user
val generatedId = jdbcTemplate.queryForObject(
"""
INSERT INTO users (name, email) VALUES (?, ?)
RETURNING id
""",
Long::class.java,
user.name,
user.email
)
return user.copy(id = generatedId)
} else {
// Update existing user
jdbcTemplate.update(
"UPDATE users SET name = ?, email = ? WHERE id = ?",
user.name,
user.email,
user.id
)
return user
}
}
}
This example shows how to use Testcontainers to spin up a PostgreSQL database for integration testing and test actual database operations.
API Integration Tests
For testing API endpoints, you might use Spring's MockMvc
or a real HTTP client:
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerIntegrationTest {
@Autowired
private lateinit var mockMvc: MockMvc
@Test
fun `should get user by id`() {
// Assuming a user with ID 1 exists in test database
mockMvc.perform(get("/api/users/1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").exists())
.andExpect(jsonPath("$.email").exists())
}
@Test
fun `should create new user`() {
val userJson = """
{
"name": "New User",
"email": "[email protected]"
}
""".trimIndent()
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(userJson))
.andExpect(status().isCreated)
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.name").value("New User"))
.andExpect(jsonPath("$.email").value("[email protected]"))
}
}
Integration Testing Best Practices
-
Keep tests independent: Each test should run independently without relying on other tests.
-
Clean up after tests: Reset the environment (e.g., database) between tests to avoid test dependencies.
-
Test realistic scenarios: Integration tests should reflect real-world usage of your application.
-
Use appropriate isolation: Sometimes you need to isolate external dependencies like APIs with stubs or mocks.
-
Focus on boundaries: Test the interaction points between components, not the internal logic.
-
Balance coverage with speed: Integration tests are slower than unit tests, so be strategic about what you test.
-
Test error scenarios: Don't just test the happy path; verify how your components handle errors.
-
Use meaningful assertions: Make your assertions specific and descriptive.
Advanced Integration Testing Patterns
Service Virtualization
When your application interacts with third-party services, you can use service virtualization tools to simulate these dependencies:
@Test
fun `should process payment with payment gateway`() {
// Start a WireMock server to simulate payment gateway
val wireMockServer = WireMockServer(WireMockConfiguration.options().dynamicPort())
wireMockServer.start()
try {
val paymentGatewayUrl = "http://localhost:${wireMockServer.port()}"
// Configure the mock response
wireMockServer.stubFor(
WireMock.post(WireMock.urlPathEqualTo("/api/payments"))
.willReturn(WireMock.aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""{"id":"pay_123","status":"succeeded"}"""))
)
// Create payment service with mocked URL
val paymentService = PaymentService(paymentGatewayUrl)
// Execute the payment
val result = paymentService.processPayment(100.0, "usd", "4242424242424242")
// Verify
assertEquals("succeeded", result.status)
assertEquals("pay_123", result.id)
// Verify the request was made as expected
wireMockServer.verify(
WireMock.postRequestedFor(WireMock.urlPathEqualTo("/api/payments"))
.withHeader("Content-Type", WireMock.equalTo("application/json"))
)
} finally {
wireMockServer.stop()
}
}
Testing Asynchronous Operations
Integration tests involving asynchronous operations like message queues require special handling:
@Test
fun `should process message from queue`() = runBlocking {
// Setup in-memory message broker
val messageBroker = InMemoryMessageBroker()
val messageProcessor = MessageProcessor(messageBroker)
// Start the processor
val processorJob = launch {
messageProcessor.startProcessing()
}
// Send a test message
val testMessage = Message("test-id", "Hello, World!")
messageBroker.send(testMessage)
// Wait for processing with timeout
delay(1000) // Give processor time to process
// Verify message was processed
assertTrue(messageProcessor.processedMessages.contains("test-id"))
// Cleanup
processorJob.cancel()
}
Summary
Integration testing is a vital part of ensuring your Kotlin applications work correctly as a whole. In this tutorial, we covered:
- What integration testing is and its role in the testing pyramid
- How to set up integration tests in Kotlin projects
- Basic integration testing techniques with JUnit and MockK
- Database integration testing with Testcontainers
- API testing approaches
- Best practices for writing effective integration tests
- Advanced patterns for handling external dependencies and asynchronous code
By implementing thorough integration tests alongside unit tests, you can catch issues early and ensure your components work together as expected.
Additional Resources
- JUnit 5 User Guide
- MockK Documentation
- Testcontainers for Java/Kotlin
- Spring Boot Testing Documentation
Exercises
- Write an integration test for a service that depends on two repositories.
- Create a test that verifies a database transaction rolls back properly when an error occurs.
- Implement an integration test for a REST API that creates, reads, updates, and deletes a resource.
- Write a test for a component that processes messages from a queue and updates a database.
- Create an integration test that verifies error handling between components when one component fails.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)