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.
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.
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.
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.
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.
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.
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.
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.
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 leadsfonctionne, parce que le verbe décrit une relation évidente.user creates campaignfonctionne, parce que l'action métier est centrale dans le scénario.subject shouldBe expectedfonctionne, parce que c'est la forme naturelle d'une assertion.service process payloadserait 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
Anyou 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
infixpermet 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.