Введение: Почему тесты без моков — как кофе без кофеина?
Привет, коллега! Если ты пишешь на Kotlin и хочешь, чтобы твой код не ломался при каждом втором коммите, рано или поздно ты придёшь к тестам. А в тестах — к mock-объектам и фикстурам. Без них юнит-тесты превращаются в интеграционные: ты тестируешь не один класс, а целую цепочку зависимостей. И если база данных упадёт или API вернёт 500 — тест упадёт, хотя твой код может быть идеален.
В этой статье мы разберём, как правильно использовать Mock-объекты и фикстуры в Kotlin тестах, чтобы тесты были быстрыми, надёжными и говорили тебе правду, а не «всё сломалось, чини». Поехали!
Что такое mock и зачем он нужен в Kotlin?
Mock-объект — это имитация реального объекта. Вместо того чтобы поднимать целый сервер или базу данных, мы создаём «заглушку», которая ведёт себя так, как мы скажем. Это позволяет изолировать тестируемый код от внешнего мира.
Основные понятия
- Mock — объект, у которого мы контролируем поведение методов. Мы можем задать, что он вернёт, и проверить, вызывался ли он.
- Stub — частный случай мока, который только возвращает заранее заданные значения. Не проверяет вызовы.
- Fixture (фикстура) — набор предварительно подготовленных данных или состояний, необходимых для выполнения теста. Например, тестовый пользователь или настройки БД.
В мире Kotlin есть два главных игрока: MockK (родной для Kotlin) и Mockito (популярный в Java). Мы будем использовать MockK — он лучше дружит с корутинами, функциями-расширениями и sealed-классами.
Настройка проекта: добавляем MockK и JUnit 5
Для начала добавим зависимости в build.gradle.kts:
dependencies {
testImplementation("io.mockk:mockk:1.13.8")
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
}
Теперь можно писать тесты. Создадим простой сервис и протестируем его с помощью моков.
Первый пример: мокаем репозиторий
Представь, что у нас есть UserService, который зависит от UserRepository:
class UserService(private val repository: UserRepository) {
fun getUserName(id: Long): String {
val user = repository.findById(id)
?: throw IllegalArgumentException("User not found")
return user.name
}
}
Теперь напишем тест, используя mock-объект для репозитория:
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class UserServiceTest {
private val repository = mockk<UserRepository>()
private val service = UserService(repository)
@Test
fun `should return user name when user exists`() {
// Arrange
every { repository.findById(1L) } returns User(1L, "Alice")
// Act
val name = service.getUserName(1L)
// Assert
assertEquals("Alice", name)
verify { repository.findById(1L) }
}
@Test
fun `should throw exception when user not found`() {
// Arrange
every { repository.findById(any()) } returns null
// Act & Assert
assertFailsWith<IllegalArgumentException> {
service.getUserName(99L)
}
}
}
Обрати внимание: every задаёт поведение мока, verify проверяет, что метод был вызван. Это основа mock-объектов и фикстур в Kotlin тестах.
Фикстуры: готовим данные заранее
Когда тестов много, дублировать создание данных — плохая практика. На помощь приходят фикстуры. В Kotlin их удобно реализовать через фабричные функции или объекты-компаньоны.
Пример фикстуры для User
object UserFixtures {
fun alice() = User(1L, "Alice", "alice@example.com")
fun bob() = User(2L, "Bob", "bob@example.com")
fun defaultUser() = User(0L, "Default", "default@test.com")
}
Теперь в тесте можно писать:
every { repository.findById(1L) } returns UserFixtures.alice()
Это делает код чище и снижает вероятность опечаток. Фикстуры можно расширять: добавлять списки, вложенные объекты, разные сценарии.
MockK для корутин: suspend-функции
Kotlin славится корутинами, и MockK отлично их поддерживает. Для мока suspend-функций используем coEvery и coVerify:
class OrderService(private val paymentApi: PaymentApi) {
suspend fun processOrder(orderId: String): Boolean {
return paymentApi.charge(orderId)
}
}
interface PaymentApi {
suspend fun charge(orderId: String): Boolean
}
Тест:
@Test
fun `should process order successfully`() = runTest {
// Arrange
val api = mockk<PaymentApi>()
val service = OrderService(api)
coEvery { api.charge("order-1") } returns true
// Act
val result = service.processOrder("order-1")
// Assert
assertTrue(result)
coVerify { api.charge("order-1") }
}
Обрати внимание на runTest — это функция из kotlinx-coroutines-test, которая нужна для запуска корутин в тестах. Без неё suspend-функции не вызвать.
Продвинутые техники: capture, slot и relaxed mock
Иногда нужно не просто проверить, что метод вызван, а ещё и посмотреть, с какими аргументами. Для этого используем slot:
@Test
fun `should save user with correct email`() {
val repository = mockk<UserRepository>()
val service = UserService(repository)
val slot = slot<User>()
every { repository.save(capture(slot)) } returns Unit
service.registerUser("Charlie", "charlie@test.com")
assertEquals("charlie@test.com", slot.captured.email)
}
Ещё одна полезная штука — relaxed mock. Это мок, который по умолчанию возвращает «пустые» значения для всех методов (0, null, false и т.д.). Удобно, если нужно замокать только часть поведения:
val repository = mockk<UserRepository>(relaxed = true)
Но будь осторожен: relaxed mock может скрыть ошибки, если ты забудешь задать нужное поведение.
Организация тестов: структура и best practices
Чтобы тесты не превратились в ка