Modèles de test Kotlin
Modèles de test Kotlin complets pour écrire des tests fiables et maintenables en suivant la méthodologie TDD avec Kotest et MockK.
Quand l'utiliser
- Écrire de nouvelles fonctions ou classes Kotlin
- Ajouter une couverture de test à du code Kotlin existant
- Implémenter des tests basés sur les propriétés
- Suivre un workflow TDD dans des projets Kotlin
- Configurer Kover pour la couverture de code
Comment ça fonctionne
- Identifier le code cible — Trouver la fonction, la classe ou le module à tester
- Écrire une spécification Kotest — Choisir un style de spécification (StringSpec, FunSpec, BehaviorSpec) adapté à la portée du test
- Simuler les dépendances — Utiliser MockK pour isoler l'unité testée
- Exécuter les tests (ROUGE) — Vérifier que le test échoue avec l'erreur attendue
- Implémenter le code (VERT) — Écrire le code minimal pour réussir le test
- Refactoriser — Améliorer l'implémentation tout en gardant les tests verts
- Vérifier la couverture — Lancer
./gradlew koverHtmlReportet vérifier une couverture de 80%+
Exemples
Les sections suivantes contiennent des exemples détaillés et exécutables pour chaque modèle de test :
Référence rapide
- Spécifications Kotest — Exemples de StringSpec, FunSpec, BehaviorSpec, DescribeSpec dans Styles de spécification Kotest
- Mocking — Configuration MockK, mocking de coroutines, capture d'arguments dans MockK
- Procédure TDD — Cycle ROUGE/VERT/REFACTOR complet avec EmailValidator dans Workflow TDD pour Kotlin
- Couverture — Configuration et commandes Kover dans Couverture Kover
- Test Ktor — Configuration de testApplication dans Test d'application Ktor
Workflow TDD pour Kotlin
Le cycle ROUGE-VERT-REFACTOR
ROUGE -> Écrire un test qui échoue en premier
VERT -> Écrire le code minimal pour réussir le test
REFACTOR -> Améliorer le code tout en gardant les tests verts
RÉPÉTER -> Continuer avec l'exigence suivante
TDD pas à pas en Kotlin
// Étape 1 : Définir l'interface/la signature
// EmailValidator.kt
package com.example.validator
fun validateEmail(email: String): Result<String> {
TODO("not implemented")
}
// Étape 2 : Écrire un test qui échoue (ROUGE)
// EmailValidatorTest.kt
package com.example.validator
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.result.shouldBeFailure
import io.kotest.matchers.result.shouldBeSuccess
class EmailValidatorTest : StringSpec({
"un email valide renvoie un succès" {
validateEmail("[email protected]").shouldBeSuccess("[email protected]")
}
"un email vide renvoie un échec" {
validateEmail("").shouldBeFailure()
}
"un email sans @ renvoie un échec" {
validateEmail("userexample.com").shouldBeFailure()
}
})
// Étape 3 : Exécuter les tests - vérifier l'ÉCHEC
// $ ./gradlew test
// EmailValidatorTest > un email valide renvoie un succès FAILED
// kotlin.NotImplementedError: An operation is not implemented
// Étape 4 : Implémenter le code minimal (VERT)
fun validateEmail(email: String): Result<String> {
if (email.isBlank()) return Result.failure(IllegalArgumentException("L'email ne peut pas être vide"))
if ('@' !in email) return Result.failure(IllegalArgumentException("L'email doit contenir @"))
val regex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
if (!regex.matches(email)) return Result.failure(IllegalArgumentException("Format d'email invalide"))
return Result.success(email)
}
// Étape 5 : Exécuter les tests - vérifier le SUCCÈS
// $ ./gradlew test
// EmailValidatorTest > un email valide renvoie un succès PASSED
// EmailValidatorTest > un email vide renvoie un échec PASSED
// EmailValidatorTest > un email sans @ renvoie un échec PASSED
// Étape 6 : Refactoriser si nécessaire, vérifier que les tests passent toujours
Styles de spécification Kotest
StringSpec (le plus simple)
class CalculatorTest : StringSpec({
"additionner deux nombres positifs" {
Calculator.add(2, 3) shouldBe 5
}
"additionner des nombres négatifs" {
Calculator.add(-1, -2) shouldBe -3
}
"additionner zéro" {
Calculator.add(0, 5) shouldBe 5
}
})
FunSpec (style JUnit)
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
test("getUser renvoie l'utilisateur quand trouvé") {
val expected = User(id = "1", name = "Alice")
coEvery { repository.findById("1") } returns expected
val result = service.getUser("1")
result shouldBe expected
}
test("getUser lève une exception quand non trouvé") {
coEvery { repository.findById("999") } returns null
shouldThrow<UserNotFoundException> {
service.getUser("999")
}
}
})
BehaviorSpec (style BDD)
class OrderServiceTest : BehaviorSpec({
val repository = mockk<OrderRepository>()
val paymentService = mockk<PaymentService>()
val service = OrderService(repository, paymentService)
Given("une demande de commande valide") {
val request = CreateOrderRequest(
userId = "user-1",
items = listOf(OrderItem("product-1", quantity = 2)),
)
When("la commande est passée") {
coEvery { paymentService.charge(any()) } returns PaymentResult.Success
coEvery { repository.save(any()) } answers { firstArg() }
val result = service.placeOrder(request)
Then("elle doit renvoyer une commande confirmée") {
result.status shouldBe OrderStatus.CONFIRMED
}
Then("elle doit facturer le paiement") {
coVerify(exactly = 1) { paymentService.charge(any()) }
}
}
When("le paiement échoue") {
coEvery { paymentService.charge(any()) } returns PaymentResult.Declined
Then("elle doit lever une PaymentException") {
shouldThrow<PaymentException> {
service.placeOrder(request)
}
}
}
}
})
DescribeSpec (style RSpec)
class UserValidatorTest : DescribeSpec({
describe("validateUser") {
val validator = UserValidator()
context("avec une entrée valide") {
it("accepte un utilisateur normal") {
val user = CreateUserRequest("Alice", "[email protected]")
validator.validate(user).shouldBeValid()
}
}
context("avec un nom invalide") {
it("rejette un nom vide") {
val user = CreateUserRequest("", "[email protected]")
validator.validate(user).shouldBeInvalid()
}
it("rejette un nom dépassant la longueur maximale") {
val user = CreateUserRequest("A".repeat(256), "[email protected]")
validator.validate(user).shouldBeInvalid()
}
}
}
})
Matchers Kotest
Matchers principaux
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.*
import io.kotest.matchers.collections.*
import io.kotest.matchers.nulls.*
// Égalité
result shouldBe expected
result shouldNotBe unexpected
// Chaînes de caractères
name shouldStartWith "Al"
name shouldEndWith "ice"
name shouldContain "lic"
name shouldMatch Regex("[A-Z][a-z]+")
name.shouldBeBlank()
// Collections
list shouldContain "élément"
list shouldHaveSize 3
list.shouldBeSorted()
list.shouldContainAll("a", "b", "c")
list.shouldBeEmpty()
// Nulls
result.shouldNotBeNull()
result.shouldBeNull()
// Types
result.shouldBeInstanceOf<User>()
// Nombres
count shouldBeGreaterThan 0
price shouldBeInRange 1.0..100.0
// Exceptions
shouldThrow<IllegalArgumentException> {
validateAge(-1)
}.message shouldBe "L'âge doit être positif"
shouldNotThrow<Exception> {
validateAge(25)
}
Matchers personnalisés
fun beActiveUser() = object : Matcher<User> {
override fun test(value: User) = MatcherResult(
value.isActive && value.lastLogin != null,
{ "L'utilisateur ${value.id} doit être actif avec une dernière connexion" },
{ "L'utilisateur ${value.id} ne doit pas être actif" },
)
}
// Utilisation
user should beActiveUser()
MockK
Mocking de base
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val logger = mockk<Logger>(relaxed = true) // Relaxed : renvoie des valeurs par défaut
val service = UserService(repository, logger)
beforeTest {
clearMocks(repository, logger)
}
test("findUser délègue au repository") {
val expected = User(id = "1", name = "Alice")
every { repository.findById("1") } returns expected
val result = service.findUser("1")
result shouldBe expected
verify(exactly = 1) { repository.findById("1") }
}
test("findUser renvoie null pour un identifiant inconnu") {
every { repository.findById(any()) } returns null
val result = service.findUser("inconnu")
result.shouldBeNull()
}
})
Mocking de coroutines
class AsyncUserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
test("fonction suspend getUser") {
coEvery { repository.findById("1") } returns User(id = "1", name = "Alice")
val result = service.getUser("1")
result.name shouldBe "Alice"
coVerify { repository.findById("1") }
}
test("getUser avec délai") {
coEvery { repository.findById("1") } coAnswers {
delay(100) // Simule un travail asynchrone
User(id = "1", name = "Alice")
}
val result = service.getUser("1")
result.name shouldBe "Alice"
}
})
Capture d'arguments
test("save capture l'argument utilisateur") {
val slot = slot<User>()
coEvery { repository.save(capture(slot)) } returns Unit
service.createUser(CreateUserRequest("Alice", "[email protected]"))
slot.captured.name shouldBe "Alice"
slot.captured.email shouldBe "[email protected]"
slot.captured.id.shouldNotBeNull()
}
Espion et mocking partiel
test("espionner un objet réel") {
val realService = UserService(repository)
val spy = spyk(realService)
every { spy.generateId() } returns "fixed-id"
spy.createUser(request)
verify { spy.generateId() } // Remplacé
// Les autres méthodes utilisent l'implémentation réelle
}
Tests de coroutines
runTest pour les fonctions suspend
import kotlinx.coroutines.test.runTest
class CoroutineServiceTest : FunSpec({
test("les récupérations concurrentes se terminent ensemble") {
runTest {
val service = DataService(testScope = this)
val result = service.fetchAllData()
result.users.shouldNotBeEmpty()
result.products.shouldNotBeEmpty()
}
}
test("délai d'attente après un délai") {
runTest {
val service = SlowService()
shouldThrow<TimeoutCancellationException> {
withTimeout(100) {
service.slowOperation() // Prend > 100ms
}
}
}
}
})
Tests de flux
import io.kotest.matchers.collections.shouldContainInOrder
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
class FlowServiceTest : FunSpec({
test("observeUsers émet des mises à jour") {
runTest {
val service = UserFlowService()
val emissions = service.observeUsers()
.take(3)
.toList()
emissions shouldHaveSize 3
emissions.last().shouldNotBeEmpty()
}
}
test("searchUsers débounce l'entrée") {
runTest {
val service = SearchService()
val queries = MutableSharedFlow<String>()
val results = mutableListOf<List<User>>()
val job = launch {
service.searchUsers(queries).collect { results.add(it) }
}
queries.emit("a")
queries.emit("ab")
queries.emit("abc") // Seul celui-ci doit déclencher la recherche
advanceTimeBy(500)
results shouldHaveSize 1
job.cancel()
}
}
})
TestDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
class DispatcherTest : FunSpec({
test("utilise le répartiteur de test pour une exécution contrôlée") {
val dispatcher = StandardTestDispatcher()
runTest(dispatcher) {
var completed = false
launch {
delay(1000)
completed = true
}
completed shouldBe false
advanceTimeBy(1000)
completed shouldBe true
}
}
})
Tests basés sur les propriétés
Tests de propriétés Kotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
import io.kotest.property.forAll
import io.kotest.property.checkAll
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
// Remarque : Le test de sérialisation roundtrip ci-dessous nécessite que la classe de données User
// soit annotée avec @Serializable (provenant de kotlinx.serialization).
class PropertyTest : FunSpec({
test("l'inversion de chaîne est involutive") {
forAll<String> { s ->
s.reversed().reversed() == s
}
}
test("le tri de liste est idempotent") {
forAll(Arb.list(Arb.int())) { list ->
list.sorted() == list.sorted().sorted()
}
}
test("le roundtrip de sérialisation préserve les données") {
checkAll(Arb.bind(Arb.string(1..50), Arb.string(5..100)) { name, email ->
User(name = name, email = "$email@test.com")
}) { user ->
val json = Json.encodeToString(user)
val decoded = Json.decodeFromString<User>(json)
decoded shouldBe user
}
}
})
Générateurs personnalisés
test("utilisateur avec propriétés aléatoires") {
checkAll(
Arb.string(1..20),
Arb.email(),
Arb.int(18..120)
) { name, email, age ->
val user = User(name, email, age)
user.validate().shouldBeValid()
}
}
Couverture Kover
Configuration Gradle
// build.gradle.kts
plugins {
kotlin("jvm") version "1.9.0"
id("org.jetbrains.kotlinx.kover") version "0.7.0"
}
kover {
reports {
filters {
excludes {
classes(
"*Test*",
"*Application*"
)
}
}
verify {
rule {
// Le rapport sur les lignes a une couverture minimale de 80%
isEnabled = true
entity = KotlinProjectCoverage()
filters {
// exclure les classes annotées par @Generated
classes {
annotatedBy("javax.annotation.processing.Generated")
}
}
bound {
min = 80
}
}
}
}
}
dependencies {
testImplementation("io.kotest:kotest-runner-junit5:5.7.0")
testImplementation("io.kotest:kotest-assertions-core:5.7.0")
testImplementation("io.kotest:kotest-property:5.7.0")
testImplementation("io.mockk:mockk:1.13.8")
}
Commandes
# Exécuter les tests et générer un rapport HTML
./gradlew koverHtmlReport
# Ouvrir le rapport vers une cible
./gradlew koverReport
# Vérifier les règles de couverture (s'arrête en cas d'échec)
./gradlew koverVerify
# Voir la couverture dans le terminal
./gradlew koverXmlReport
Test d'application Ktor
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.testApplication
class ApplicationTest : FunSpec({
test("l'endpoint racine renvoie Hello") {
testApplication {
application {
module()
}
val response = client.get("/")
response.status shouldBe HttpStatusCode.OK
response.bodyAsText() shouldBe "Hello, World!"
}
}
test("l'endpoint user renvoie les détails de l'utilisateur") {
testApplication {
application {
module()
}
val response = client.get("/user/1")
response.status shouldBe HttpStatusCode.OK
response.bodyAsText() shouldContain """"id":"1""""
}
}
test("l'endpoint inconnu renvoie 404") {
testApplication {
application {
module()
}
val response = client.get("/unknown")
response.status shouldBe HttpStatusCode.NotFound
}
}
})
Bonnes pratiques
- Écrire d'abord le test — Suivre la méthodologie RED/GREEN/REFACTOR
- Tester le comportement, pas l'implémentation — Se concentrer sur ce que fait le code, pas comment
- Maintenir les tests indépendants — Chaque test doit pouvoir s'exécuter seul
- Utiliser des noms de test descriptifs — Le nom doit expliquer ce qui est testé et le résultat attendu
- Limiter les objets mockés — Trop de mocks rendent les tests fragiles
- Tester les cas limites — Entrées vides, valeurs nulles, grandes entrées
- Visée une couverture de 80%+ — Utiliser Kover pour suivre la couverture
- Exécuter les tests fréquemment — Intégrer dans le pipeline CI/CD