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.
💡 Found a typo or mistake? Click "Edit this page" to suggest a correction. Your feedback is greatly appreciated!