Introduction: Why Advanced Testing is the Foundation of Reliable Kotlin Code
In modern Kotlin development, writing code is only half the battle. True mastery lies in the ability to create reliable, testable, and easily maintainable systems. Basic unit tests are familiar to everyone, but when a project grows to a microservice architecture or complex business logic, simple checks are no longer enough. Advanced testing includes not only isolating classes but also verifying interactions between components (integration tests) and fully reproducing user scenarios (E2E).
This article is intended for developers who are already familiar with the basics of Kotlin and want to take the quality of their projects to the next level. We will break down three key levels of testing: unit, integration, and e2e, using modern tools: MockK, Testcontainers, and Ktor Client. You will learn how to write tests that don't just "pass" but actually protect your code from regressions.
1. Unit Testing: Isolation and Mocks with MockK
Unit tests are the foundation of the testing pyramid. At an advanced level, we don't just check functions; we test complex interactions between objects using mocks. In Kotlin, the best library for this is MockK.
1.1. Advanced MockK Features
Unlike Mockito, MockK is written specifically for Kotlin and supports coroutines, lambdas, and extension functions. Let's look at an example of testing a service that uses a repository and coroutines:
// Service under testclass UserService(private val repo: UserRepository) { suspend fun getUserName(id: Long): String { val user = repo.findById(id) return user?.name ?: "Unknown" }}
// Testclass UserServiceTest { private val repo = mockk<UserRepository>() private val service = UserService(repo)
@Test fun `should return user name when user exists`() = runTest { // given coEvery { repo.findById(1L) } returns User(1, "Alice")
// when val result = service.getUserName(1L)
// then assertEquals("Alice", result) coVerify(exactly = 1) { repo.findById(1L) } }
@Test fun `should return Unknown when user not found`() = runTest { // given coEvery { repo.findById(any()) } returns null
// when val result = service.getUserName(99L)
// then assertEquals("Unknown", result) }}Key points:
coEveryandcoVerify— for working with suspend functions.mockk()creates an empty mock,spyk()creates a partial mock of a real object.- Use
slotsto capture arguments.
1.2. Testing Lambdas and Extension Functions
MockK allows mocking extension functions, which is critical for frameworks like Ktor or Spring WebFlux:
class StringValidator { fun String.isValidEmail(): Boolean = this.contains("@")}
// Test@Testfun `test extension mock`() { val validator = mockk<StringValidator>() with(validator) { every { "test@mail.com".isValidEmail() } returns true every { "invalid".isValidEmail() } returns false } assertTrue("test@mail.com".isValidEmail()) assertFalse("invalid".isValidEmail())}2. Integration Testing: Database and Testcontainers
Integration tests check how your code interacts with real dependencies: databases, message queues, external APIs. Here, Testcontainers comes to the rescue — a library that spins up Docker containers directly during tests.
2.1. Setting up Testcontainers with PostgreSQL
For a Spring Boot application, it looks like this:
Blogs
Book Recommendations
Read also
Error Handling in Kotlin: try/catch and Advanced Techniques
"React refers to a UMD global": How TypeScript Goes Crazy
Big Data Processing in Go: Power and Simplicity for Data Science
Code Review in PHP: 8 Key Things Every Developer Should Check
Advanced Serverless Functions in JavaScript on AWS Lambda