Kotlin · DSL · tests d'acceptation · lisibilité métier

Jeudi 7 mai 2026

Les fonctions infix Kotlin : petite syntaxe, grand levier.

Une fonction infix ne rend pas seulement le code plus joli. Bien utilisée, elle transforme des tests techniques en phrases métier que l'équipe peut lire, discuter et maintenir.

Kotlin DSL Jeudi 7 mai 2026 Niveau intermédiaire

Kotlin a une fonctionnalité discrète mais redoutable : les fonctions infix. Elles permettent d'appeler certaines fonctions sans point ni parenthèses. Sur le papier, cela ressemble à du sucre syntaxique. Dans un vrai code de tests, c'est souvent beaucoup plus que ça.

Prenons un exemple issu d'un DSL de tests d'acceptation pour des campagnes multicanales. Le test ne cherche pas à montrer comment cliquer sur un bouton ou comment persister une entité. Il raconte un comportement produit.

Kotlin
Given {
    aUserNamed("Alice") {
        withExtensionConnected()
        withFolder("Prospects") containing listOf(
            lead("John", "Doe", profileUrl = "https://profile.example/johndoe"),
            lead("Jane", "Smith", profileUrl = "https://profile.example/janesmith"),
        )
        withMessageTemplate("Bonjour {{firstName}}, ravi de vous connaître !")
    }
}

When {
    she creates campaignNamed("Q1 Outreach") {
        withLeads("John Doe", "Jane Smith")
        withWaitDays(3)
        withSelectedTemplate()
    }
    she launches campaignLaunch("Q1 Outreach")
}

Then {
    she isRedirectedTo trackingPageOf("Q1 Outreach")
    campaign("Q1 Outreach") shouldBe CampaignStatus.IN_PROGRESS
    lead("John Doe") shouldBeAtStep "Initial Contact"
}

Les mots importants ici sont containing, creates, launches, isRedirectedTo, shouldBe et shouldBeAtStep. Ce sont des fonctions, mais elles se lisent comme des connecteurs de phrase.

La forme normale

Sans infix, Kotlin impose l'appel classique : un receveur, un point, une fonction, des parenthèses.

Kotlin
withFolder("Prospects").containing(
    listOf(
        lead("John", "Doe"),
        lead("Jane", "Smith"),
    )
)

she.creates(
    campaignNamed("Q1 Outreach") {
        withLeads("John Doe", "Jane Smith")
        withSelectedTemplate()
    }
)

campaign("Q1 Outreach").shouldBe(CampaignStatus.IN_PROGRESS)

Ce code est correct. Mais il attire l'oeil vers la mécanique du langage : points, parenthèses, imbrications. Dans un test d'acceptation, ce n'est pas ce qu'on veut mettre au premier plan.

La même intention en infix

Avec infix, on garde le typage, les objets et les fonctions Kotlin, mais on retire un peu de bruit visuel.

Kotlin
withFolder("Prospects") containing listOf(
    lead("John", "Doe"),
    lead("Jane", "Smith"),
)

she creates campaignNamed("Q1 Outreach") {
    withLeads("John Doe", "Jane Smith")
    withSelectedTemplate()
}

campaign("Q1 Outreach") shouldBe CampaignStatus.IN_PROGRESS

La différence est subtile, mais elle change le rythme de lecture. On ne lit plus seulement une suite d'appels. On lit une phrase : un dossier contient des leads, Alice crée une campagne, la campagne doit être en cours.

Comment on déclare une fonction infix

Une fonction infix doit être une fonction membre ou une extension, recevoir un seul paramètre, et être marquée avec le mot-clé infix. Voici le cas le plus simple du DSL : attacher une liste de leads à un dossier.

Kotlin
class FolderBuilder(
    private val folderName: String,
    private val userBuilder: UserBuilder
) {
    infix fun containing(leads: List<LeadSpec>): UserBuilder {
        userBuilder.addFolder(folderName, leads)
        return userBuilder
    }
}

fun withFolder(folderName: String) = FolderBuilder(folderName, this)

withFolder("Prospects") retourne un FolderBuilder. Ensuite, containing reçoit une seule valeur : la liste de leads. C'est exactement le format attendu par Kotlin pour autoriser l'appel infix.

Pourquoi c'est puissant dans un DSL

Dans un DSL de tests, on essaie souvent de cacher trois types de complexité : la préparation des données, l'interaction avec l'interface, et les assertions. Les fonctions infix aident à exprimer ces trois couches dans le vocabulaire du métier.

1. Les actions deviennent des verbes

Avant de parler de creates et launches, il faut regarder d'où vient she. Ce n'est pas un mot-clé Kotlin. C'est une propriété du DSL qui retourne un objet UserActions pour l'utilisateur courant.

Kotlin
class CampaignDsl(
    private val driver: CampaignDriver,
    private val context: CampaignDslContext,
    private val baseUrl: String
) {
    fun Given(action: GivenBuilder.() -> Unit) {
        GivenBuilder(context, driver, baseUrl).apply(action)
    }

    fun When(action: WhenScope.() -> Unit) {
        WhenScope(context, driver, baseUrl).apply(action)
    }

    fun Then(action: ThenScope.() -> Unit) {
        ThenScope(context, driver, baseUrl).apply(action)
    }

    val he: UserActions
        get() = UserActions(context.getCurrentUser(), driver, context, baseUrl)

    val she: UserActions
        get() = UserActions(context.getCurrentUser(), driver, context, baseUrl)
}

Le Given crée un utilisateur et le place dans le contexte. Quand le scénario arrive dans When, she récupère ce même utilisateur et expose les actions qu'il peut effectuer. Les fonctions infix creates et launches transforment ensuite ces opérations techniques en actions produit.

Kotlin
class UserActions(
    private val user: User,
    private val driver: CampaignDriver,
    private val context: CampaignDslContext,
    private val baseUrl: String
) {
    infix fun creates(builder: CampaignCreationBuilder) {
        builder.execute(driver, context, baseUrl)
    }

    infix fun launches(action: CampaignLaunchAction) {
        action.execute(driver, context)
    }
}

Derrière she creates campaignNamed("Q1"), il peut y avoir une navigation vers la page des leads, une sélection, l'ouverture d'un panneau, la saisie d'un nom, le choix d'un template et l'enregistrement de données de test. Le test, lui, garde une intention claire.

2. Les assertions lisent comme des attentes

Les assertions sont un excellent terrain pour infix, parce qu'elles ont naturellement deux côtés : un sujet et une attente.

Kotlin
fun campaign(name: String) = CampaignStatusSubject(name, driver, context)

class CampaignStatusSubject(
    private val campaignName: String,
    private val driver: CampaignDriver,
    private val context: CampaignDslContext
) {
    infix fun shouldBe(expectedStatus: CampaignStatus) {
        val actualStatus = driver.getCampaignStatus()

        assertThat(actualStatus)
            .withFailMessage(
                "Expected campaign '$campaignName' to be $expectedStatus but was $actualStatus"
            )
            .containsIgnoringCase(expectedStatus.name)
    }
}

Le résultat côté scénario est presque auto-documenté : campaign("Q1") shouldBe CampaignStatus.PAUSED. On comprend le comportement attendu sans descendre dans l'implémentation.

3. Les détails d'infrastructure restent interchangeables

Le même scénario peut tourner avec un driver in-memory, MockMvc ou Playwright. Le DSL infix ne sait pas comment le driver travaille ; il exprime seulement ce que l'utilisateur fait et ce que le produit doit garantir.

Kotlin
override val drivers = listOf(
    InMemoryCampaignDriver,
    MockMvcCampaignDriver,
    PlaywrightCampaignDriver,
)

override val dslFactory = CampaignDsl

C'est là que la syntaxe devient plus qu'une coquetterie. Le scénario reste stable pendant que l'équipe change le niveau d'exécution : domaine pur, HTTP, navigateur réel. Les fonctions infix participent à cette séparation en gardant le test au niveau du langage métier.

Quand utiliser infix

Toutes les fonctions ne méritent pas d'être infix. La syntaxe est forte, donc elle doit être réservée aux relations qui se lisent naturellement entre deux éléments.

  • folder containing leads fonctionne, parce que le verbe décrit une relation évidente.
  • user creates campaign fonctionne, parce que l'action métier est centrale dans le scénario.
  • subject shouldBe expected fonctionne, parce que c'est la forme naturelle d'une assertion.
  • service process payload serait moins clair si le verbe cache trop d'options ou d'effets secondaires.

Les limites à garder en tête

Une fonction infix reste une fonction. Si elle cache trop de choses, le test devient magique. Si elle est trop générique, le lecteur doit deviner ce qui se passe. Le bon équilibre consiste à l'utiliser pour nommer une intention, pas pour masquer un comportement surprenant.

  • Gardez des noms précis : creates, launches, containing, shouldBeAtStep.
  • Évitez les fonctions infix qui prennent un objet trop vague, comme Any ou une map de configuration.
  • Préférez un builder lambda quand il y a plusieurs paramètres à configurer.
  • Assurez-vous que l'appel classique reste compréhensible : she.creates(...) doit encore avoir du sens.

À retenir

  • infix permet d'appeler une fonction membre ou extension avec un seul paramètre sans point ni parenthèses.
  • Son intérêt principal n'est pas d'économiser quelques caractères, mais d'améliorer la lecture d'un langage métier.
  • Dans un DSL de tests, il aide à séparer le scénario produit de la mécanique technique.
  • Il devient très puissant avec les builders Kotlin, les lambdas et des sujets d'assertion typés.
  • Il faut l'utiliser avec parcimonie : une bonne fonction infix se lit comme une phrase évidente.