Введение: Почему продвинутое тестирование — основа надежного Kotlin-кода
В современной Kotlin-разработке написание кода — это лишь половина дела. Настоящее мастерство проявляется в умении создавать надежные, тестируемые и легко поддерживаемые системы. Базовые unit-тесты знакомы каждому, но когда проект вырастает до микросервисной архитектуры или сложной бизнес-логики, простых проверок становится недостаточно. Продвинутое тестирование включает в себя не только изоляцию классов, но и проверку взаимодействия между компонентами (интеграционные тесты) и полное воспроизведение пользовательских сценариев (E2E).
Эта статья предназначена для разработчиков, которые уже знакомы с основами Kotlin и хотят поднять качество своих проектов на новый уровень. Мы разберем три ключевых уровня тестирования: unit, integration и e2e, используя современные инструменты: MockK, Testcontainers и Ktor Client. Вы узнаете, как писать тесты, которые не просто "проходят", а реально защищают ваш код от регрессий.
1. Unit-тестирование: Изоляция и моки с MockK
Unit-тесты — это фундамент пирамиды тестирования. На продвинутом уровне мы не просто проверяем функции, а тестируем сложные взаимодействия между объектами, используя моки. В Kotlin лучшей библиотекой для этого является MockK.
1.1. Продвинутые возможности MockK
В отличие от Mockito, MockK написан специально для Kotlin и поддерживает корутины, лямбды и extension-функции. Рассмотрим пример тестирования сервиса, который использует репозиторий и корутины:
// Сервис для проверки
class UserService(private val repo: UserRepository) {
suspend fun getUserName(id: Long): String {
val user = repo.findById(id)
return user?.name ?: "Unknown"
}
}
// Тест
class 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)
}
}
Ключевые моменты:
coEveryиcoVerify— для работы с suspend-функциями.mockk()создает пустой мок,spyk()— частичный мок реального объекта.- Используйте
slotsдля захвата аргументов.
1.2. Тестирование лямбд и extension-функций
MockK позволяет мокать функции-расширения, что критично для фреймворков вроде Ktor или Spring WebFlux:
class StringValidator {
fun String.isValidEmail(): Boolean = this.contains("@")
}
// Тест
@Test
fun `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. Интеграционное тестирование: База данных и Testcontainers
Интеграционные тесты проверяют, как ваш код взаимодействует с реальными зависимостями: базами данных, очередями сообщений, внешними API. Здесь на помощь приходит Testcontainers — библиотека, которая поднимает Docker-контейнеры прямо во время тестов.
2.1. Настройка Testcontainers с PostgreSQL
Для Spring Boot приложения это выглядит так:
@SpringBootTest
@Testcontainers
class UserRepositoryIntegrationTest {
companion object {
@Container
val postgres = PostgreSQLContainer<Nothing>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
@DynamicPropertySource
@JvmStatic
fun configureProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl)
registry.add("spring.datasource.username", postgres::getUsername)
registry.add("spring.datasource.password", postgres::getPassword)
}
}
@Autowired
private lateinit var repository: UserRepository
@Test
fun `should save and find user by email`() {
val user = User(name = "Bob", email = "bob@test.com")
val saved = repository.save(user)
val found = repository.findByEmail("bob@test.com")
assertNotNull(found)
assertEquals("Bob", found?.name)
assertEquals(saved.id, found?.id)
}
}
Преимущества:
- Изолированное окружение — тесты не портят реальные данные.
- Легко переключаться между версиями СУБД.
- Работает локально и в CI/CD.
2.2. Тестирование с Ktor и H2
Для Ktor-приложений интеграционные тесты можно писать без Spring, используя встроенный тестовый движок:
class UserRoutesTest {
@Test
fun `test create user endpoint`() = testApplication {
application {
module()
}
val response = client.post("/users") {
contentType(ContentType.Application.Json)
setBody(UserCreateRequest("Charlie", "charlie@test.com"))
}
assertEquals(HttpStatusCode.Created, response.status)
val user = response.body<UserResponse>()
assertEquals("Charlie", user.name)
}
}
3. E2E-тестирование: Полный цикл REST API
End-to-End тесты проверяют работу всего приложения от HTTP-запроса до ответа, включая базу данных, бизнес-логику и внешние интеграции. Для Kotlin-проектов часто используют Ktor Client или RestAssured.
3.1. Пример E2E-теста на Ktor Client
class E2ETest {
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json()
}
}
@Test
fun `full user lifecycle`() = runBlocking {
// 1. Соз