Écrire des bons tests automatisés avec une IA : Retour d'expérience

Image générée par une IA

01/11/2024 — 13 minutes de lecture (2778 mots)

L'usage de l'IA se répand chez les développeurs. Pour le meilleur ou pour le pire, de plus en plus de code généré par les IAs va arriver en production... et dans les tests de ce code de production. Dans cet article, je vous présente une expérimentation que j'ai conduite sur l'écriture de tests automatisés à l'aide d'une IA, et les conclusions que j'en ai tirées.

Contents

Genèse

Il y a quelques temps, je suis tombé cette publication sur linkedin.

Bien que je sois d’accord avec Christophe sur le fait que les tests ne doivent pas être vus comme une activité fastidieuse ou secondaire, le propos sur la qualité des tests générés m’a supris : mon intuition au contraire était que l’écriture de tests était justement l’un des cas d’usage intéressants de l’IA, à l’instar d’une requête SQL, d’une fonction pure ou d’un type TypeScript un peu élaboré. J’ai donc décidé d’essayer pour dépasser l’intuition et vérifier ce qu’il en est vraiment.

Mon but dans cette expérimentation a été de répondre aux questions suivantes :

Expérimentation

La classe à tester

L’un des problèmes pour les expérimentations de ce genre est qu’il est difficile de trouver une situation réaliste. Réussir à coder des tests avec une IA n’aurait pas beaucoup de sens sur une classe unique à l’extérieur de tout projet réel. J’ai donc décidé de sortir un projet d’un de mes clients et de générer les tests pour l’une des classes centrales du domaine métier.

Le code de cette classe est présent en annexe, et je vous reporte ici une version sans le corps des méthodes et sans les membres privés.

/**
 * Représente les étapes successives prises pour sélectionner le meilleur contenu d'un `bundle` renvoyé par le moteur d'indexation.
 *
 * Le pipeline de sélection de contenu tel qu'il est actuellement mis en œuvre prend en charge uniquement le contenu texte.
 */
class ContentSelectionPipeline(
    private val parameters: ContentSelectionParameters,
    private val blacklist: Blacklist,
    private val queryingEditor: Editor,
    private val relatedEditors: List<Editor>,
    private val fetchContentMetadata: suspend (List<DocumentIndexMetadataWithScore>) -> List<ContentMetadata>,
    private val clock: Clock
) {
    // Attributs privés omis. Mais la classe est bel et bien stateful.

    suspend fun initializeForDocuments(relatedDocuments: RelatedDocumentsBundle) {
        // ...
    }

    fun execute() {
        // ...
    }
    fun isSuccess() : Boolean {
        // ...
    }
    fun getFinalContents(): List<ContentMetadata> {
        // ...
    }
    
    // Méthodes privées omises. Le fichier réel fait 130 lignes.
}

Cette classe est tirée d’un projet dont le but est de partager des Contenus (Articles…) entre plusieurs Éditeurs (Sites contenant des articles de presse, vidéos…). Le rôle de cette classe est de sélectionner trois contenus à partir d’un ensemble de documents (RelatedDocumentsBundle) retournés par un moteur d’indexation (Indexation Engine : imaginez un elasticsearch). Pour remplir ce rôle, la classe doit récupérer les métadonnées des contenus (ContentMetadata : URL, nombre de mots…) correspondant aux documents, puis appliquer un certain nombre de règles métiers paramétrables.

Le cas nominal d’utilisation peut se décrire ainsi :

  1. Instancier le Pipeline avec un certain nombre de paramètres (le groupe d’éditeurs concernés, des paramètres métier, une liste d’exclusion…)
  2. Initialiser le pipeline avec un ensemble de documents provenant du moteur d’indexation (méthode initializeForDocuments)
  3. Exécuter le pipeline (méthode execute. C’est cette étape qui va appliquer toutes les règles métier pour sélectionner les contenus)
  4. Contrôler le statut de l’opération (isSuccess) et récupérer les contenus sélectionnés (getFinalContents)

Les étapes 2 à 4 peuvent être exécutées plusieurs fois pour une même instance, mais bien entendu, on ne peut pas les exécuter dans le désordre.

Des mauvais tests

La première étape de mon expérience a été de me faire une idée du résultat qu’on obtient sans faire aucun effort. J’ai ouvert mon agent conversationnel (Jetbrains AI), et je lui ai demandé des tests1 :

Voici une classe pour laquelle je veux générer des tests :

class ContentSelectionPipeline {
 [...]
}

Écris les tests pour cette classe.

Encore une fois, le code complet est en annexe, mais voilà le début de la classe de test et la liste des cas de test générés :

package fr.sipaof.flink.domain.content.selection

class IaContentSelectionPipelineTests {

    private lateinit var parameters: ContentSelectionParameters
    private lateinit var blacklist: Blacklist
    private lateinit var queryingEditor: Editor
    private lateinit var relatedEditors: List<Editor>
    private lateinit var fetchContentMetadata: suspend (List<DocumentIndexMetadataWithScore>) -> List<ContentMetadata>
    private lateinit var clock: Clock
    private lateinit var contentSelectionPipeline: ContentSelectionPipeline

    @BeforeEach
    fun setUp() {
        parameters = mockk<ContentSelectionParameters>()
        blacklist = mockk()
        queryingEditor = mockk()
        relatedEditors = listOf()
        fetchContentMetadata = mockk()
        clock = mockk()

        contentSelectionPipeline = ContentSelectionPipeline(
            parameters,
            blacklist,
            queryingEditor,
            relatedEditors,
            fetchContentMetadata,
            clock
        )
    }

    @Test
    fun `initializeForDocuments should initialize contents and initialBundle`() = runBlocking {
        // ...
    }

    @Test
    fun `initializeForDocuments should throw IllegalStateException for unsupported content`(): Unit = runBlocking {
        // ...
    }

    @Test
    fun `execute should populate steps and selectedDocuments`() = runBlocking {
        // ...
    }

    @Test
    fun `isSuccess should return true when at least 2 documents are selected`() = runBlocking {
        // ...
    }

    @Test
    fun `isSuccess should return false when less than 2 documents are selected`() = runBlocking {
        // ...
    }

    @Test
    fun `getFinalContents should return selected content metadata`() = runBlocking {
        // ...
    }

    @Test
    fun `getExecutionSummary should return pipeline execution summary`() = runBlocking {
        // ...
    }

}

Sans surprise, c’est un désastre. Ces tests sont un excellent exemple de ce qu’il ne faut pas faire.

Premièrement, on remarque une liste d’attributs dans la classe qui correspondent à toutes les données qu’on fournit à la classe au moment de l’instanciation. Tous ces attributs sont initialisés par des mocks (mockk) avant chaque test alors que ce sont pour la plupart des data class kotlin qu’il serait beaucoup plus simple d’instancier directement (surtout qu’on préfère tester leurs éventuels comportements, car ils servent justement à la classe ContentSelectionPipeline qu’on est en train de tester). Il y a même un mock de Clock, alors que Jetbrains AI connait certainement Clock.fixed. Bref, beaucoup trop de mocks.

Deuxièmement, si vous regardez le nom des cas de test créés, vous allez y trouver un tas de vocabulaire dont je n’ai pas parlé quand j’ai présenté la classe testée. Et pour cause, il s’agit de vocabulaire qui correspond à des classes, fonctions et attributs privés de ContentSelectionPipeline. Et en effet, les tests ne compilent pas sans rendre publics un certain nombre de ces attributs, ce qui est encore une très mauvaise pratique. Bref, les tests sont couplés à l’implémentation de la classe

Enfin, regardons un de ces cas de test en entier :

    @Test
    fun `initializeForDocuments should initialize contents and initialBundle`() = runBlocking {
        val relatedDocumentsBundle = mockk<RelatedDocumentsBundle>()
        val textContentMetadata = mockk<TextContentMetadata>()
        val documentIndexMetadataWithScore = mockk<DocumentIndexMetadataWithScore>()

        every { relatedDocumentsBundle.allDocuments() } returns listOf(documentIndexMetadataWithScore)
        coEvery { fetchContentMetadata(any()) } returns listOf(textContentMetadata)
        
        // --
        contentSelectionPipeline.initializeForDocuments(relatedDocumentsBundle)
        // --
        
        assertNotNull(contentSelectionPipeline.contents)
        assertNotNull(contentSelectionPipeline.initialBundle)
        assertNull(contentSelectionPipeline.steps)
        assertNull(contentSelectionPipeline.selectedDocuments)
    }

Le début et la fin du test illustrent à nouveau ce que j’ai dit plus haut :

  1. On mocke des classes qu’on voudrait inclure au système testé
  2. On fait des assertions sur des attributs privés

Mais le centre (que j’ai mis entre // --) montre un autre problème : appeler uniquement la méthode initializeForDocuments ne correspond à aucun cas d’usage réel de la classe. Le test fait donc quelque chose qui ne correspond à rien, il n’y a rien à observer, ce qui force à faire des assertions sur l’état privé de la classe. Zéro pointé.

Pourquoi c’est si mauvais ?

Doit-on en conclure que l’IA est nulle pour écrire des tests ? Non. Seulement, on ne peut pas s’attendre à ce qu’une IA réussisse quoi que ce soit avec le peu d’informations que je lui ai donné dans le prompt.

Je pense qu’il est facile de conclure qu’une IA ne sait pas effectuer certaines tâches en se basant sur le genre d’exemples que je viens de montrer. Mais il faut garder en tête qu’un LLM est un outil informatique, pas de la sorcellerie. Comme tous les outils, il nécéssite un minimum d’efforts pour être utilisé et il faut surtout apprendre à l’utiliser. Taper un prompt d’une ligne n’a nécéssité aucun effort et c’est la première chose que ferait n’importe quel développeur qui n’a pas cherché à progresser dans l’utilisation du LLM.

Pour faire mieux, il va falloir améliorer au moins deux points :

Une tentative un peu plus poussée

Premier prompt1

Nous allons écrire des tests unitaires pour la classe #file:ContentSelectionPipeline.kt et ses classes associées ( #symbol:ContentSelectionParameters , #file:Blacklist.kt , #file:Editor.kt , #symbol:DocumentIndexMetadataWithScore , #file:ContentMetadata.kt , #file:RelatedDocumentsBundle.kt , #file:TextContentMetadata.kt , #symbol:ContentAndDocumentMetadataWithScore )

Les méthodes privées ne doivent jamais apparaître dans les tests. Les mocks doivent être évités sauf si je le mentionne explicitement.

D’abord, nous allons écrire une méthode utilitaire qui va nous aider à créer les données d’entrée des tests. La méthode prendra une liste d’éditeurs en paramètre et le chemin vers un fichier csv avec les colonnes suivantes : editorSlug;contenId;title;url;numberOfWords;publicationDate;indexationId;returnedScore;blacklisted. Chaque ligne correspond à un document présent dans le Bundle et les ContentMetadata associées.

Que retenir de ce prompt ?

  1. J’ai inclus le code complet de toutes les classes appartenant au système testé tel que je l’imagine (c’est ce qui est fait par la syntaxe spéciale #file:truc.kt disponible dans Jetbrains AI, mais si vous utilisez autre chose un copier-coller du fichier fera aussi bien l’affaire). Corrolaire : c’est moi qui ai réfléchi au système testé avant, l’IA ne l’a pas fait pour moi.
  2. J’ai été explicite sur les pratiques de test désirées : pas de mocks, pas d’attributs privés dans les tests.
  3. Je découpe le travail. Même si j’ai expliqué au début que la finalité sera d’écrire un test, je commence par écrire une méthode utilitaire pour générer des données d’entrée, car je sais qu’elles sont un peu laborieuses à instancier (il y a pas mal d’attributs et de paramètres). C’est ma connaissance du système à tester qui m’a permis d’identifier cette première étape, l’IA n’a pas réfléchi à ma place.

En réponse à ce prompt, l’IA m’a fourni une fonction correspondant à cette signature :

data class TestDocument(
    val editor: Editor,
    val contentMetadata: TextContentMetadata,
    val documentIndexMetadata: DocumentIndexMetadataWithScore
)

fun createTestDocumentsFromCsv(editors: List<Editor>, csvFilePath: String): List<TestDocument> {
    // ...
}

Ajustement

Ça ne m’a pas convenu, car le résultat de cette méthode n’est pas directement utilisable en entrée de mon système testé. J’ai donc clarifié :

La méthode devrait renvoyer les données d’entrée en tant que RelatedDocumentsBundle et une fonction correspondant au paramètre fetchContentMetadata du pipeline de sélection de contenu.

Cette fois-ci, j’ai décidé de garder le résultat. Il n’est pas parfait, mais un rapide test à base de println() m’a au moins convaincu qu’il fonctionne, et il sera toujours temps de refacto plus tard.

data class TestInput(
    val relatedDocumentsBundle: RelatedDocumentsBundle,
    val fetchContentMetadata: suspend (List<DocumentIndexMetadataWithScore>) -> List<TextContentMetadata>
)

fun createTestInputFromCsv(editors: List<Editor>, csvFilePath: String): TestInput {
    val csvFile = File(csvFilePath)
    val csvLines = csvFile.readLines()

    // Assumes the first line is the header
    val header = csvLines.first().split(";")
    require(header == listOf("editorSlug", "contentId", "title", "url", "numberOfWords", "publicationDate", "indexationId", "returnedScore", "blacklisted"))

    val editorMap = editors.associateBy { it.slug }
    val dateFormatter = DateTimeFormatter.ISO_ZONED_DATE_TIME

    val documentIndexMetadataMap: MutableMap<Collection, MutableList<DocumentIndexMetadataWithScore>> = mutableMapOf()
    val contentMetadataMap = mutableMapOf<InternalContentId, TextContentMetadata>()

    for (line in csvLines.drop(1)) {
        val columns = line.split(";")
        val editorSlug = columns[0]
        val contentId = columns[1]
        val title = columns[2]
        val url = columns[3]
        val numberOfWords = columns[4].toInt()
        val publicationDate = ZonedDateTime.parse(columns[5], dateFormatter)
        val indexationId = UUID.fromString(columns[6])
        val returnedScore = columns[7].toDouble()
        val blacklisted = columns[8].toBoolean()
        
        val editor = editorMap[editorSlug] ?: throw IllegalArgumentException("Editor not found: $editorSlug")

        val contentMetadata = TextContentMetadata(
            id = OpaqueId(contentId),
            editor = editor,
            title = title,
            url = Url(url),
            publicationDate = publicationDate,
            numberOfWords = numberOfWords,
            hashedText = null // or compute based on the text if available
        )

        val documentMetadata = DocumentIndexMetadataWithScore(
            documentIndexMetadata = DocumentIndexMetadata(indexationId),
            score = returnedScore
        )

        documentIndexMetadataMap.computeIfAbsent(editor.collection) { mutableListOf() }.add(documentMetadata)
        contentMetadataMap[contentMetadata.internalId] = contentMetadata()
    }

    val relatedDocumentsBundle = RelatedDocumentsBundle(documentIndexMetadataMap)
    val fetchContentMetadata: suspend (List<DocumentIndexMetadataWithScore>) -> List<TextContentMetadata> = { documentMetadataList -> 
        documentMetadataList.mapNotNull { contentMetadataMap[it.documentIndexMetadata.internalId] }
    }

    return TestInput(relatedDocumentsBundle, fetchContentMetadata)
}

Génération de données pour un premier cas de test

Avec cette fonction prête à générer plein de données de test, il était temps de rédiger un cas de test 

Maintenant, nous allons écrire le test unitaire pour le cas nominal du pipeline de sélection de contenu. Le scénario est le suivant :

  1. Il y a trois éditeurs : fake-editor-1, other-editor et awesome-editor. Ils ont un freshnessCoefficient de 0.97, 0.97 et 0.90 respectivement.
  2. Les paramètres d’entrée du pipeline sont les mêmes que dans la code de production (dans #file:application.yml)
  3. Chaque éditeur a trois documents dans le lot de documents d’entrée.

Merci d’écrire le fichier csv au format défini précédemment qui correspond à ce scénario

Encore une fois, je n’ai pas directement demandé le test, mais un csv contenant plusieurs lignes de données. Et l’IA m’a répondu avec un csv correspondant aux spécifications que je lui ai donné dans le prompt. Elle a correctement généré des scores entre le seuil et le plafond spécifié dans application.yml. Elle a même mis la colonne blacklisted systématiquement à false en m’expliquant que c’était pour simplifier le scénario. Plutôt pertinent pour le cas nominal que j’ai demandé.

Le csv généré. Ne vous prenez pas forcément la tête à le comprendre en détail...
editorSlug;contentId;title;url;numberOfWords;publicationDate;indexationId;returnedScore;blacklisted
fake-editor-1;content-1-1;Title 1-1;http://example.com/1-1;500;2023-01-01T10:00:00Z;00000000-0000-0000-0000-000000000001;0.75;false
fake-editor-1;content-1-2;Title 1-2;http://example.com/1-2;1000;2023-01-02T10:00:00Z;00000000-0000-0000-0000-000000000002;0.82;false
fake-editor-1;content-1-3;Title 1-3;http://example.com/1-3;750;2023-01-03T10:00:00Z;00000000-0000-0000-0000-000000000003;0.90;false
other-editor;content-2-1;Title 2-1;http://example.com/2-1;600;2023-02-01T11:00:00Z;00000000-0000-0000-0000-000000000004;0.70;false
other-editor;content-2-2;Title 2-2;http://example.com/2-2;1100;2023-02-02T11:00:00Z;00000000-0000-0000-0000-000000000005;0.78;false
other-editor;content-2-3;Title 2-3;http://example.com/2-3;700;2023-02-03T11:00:00Z;00000000-0000-0000-0000-000000000006;0.85;false
awesome-editor;content-3-1;Title 3-1;http://example.com/3-1;550;2023-03-01T12:00:00Z;00000000-0000-0000-0000-000000000007;0.65;false
awesome-editor;content-3-2;Title 3-2;http://example.com/3-2;1200;2023-03-02T12:00:00Z;00000000-0000-0000-0000-000000000008;0.80;false
awesome-editor;content-3-3;Title 3-3;http://example.com/3-3;800;2023-03-03T12:00:00Z;00000000-0000-0000-0000-000000000009;0.88;false

Écriture du code du test

Enfin :

Maintenant, écrivons le test unitaire qui utilise ce fichier. Le test doit utiliser le CSV et la méthode utilitaire pour créer les données d’entrée, utiliser Clock.fixed, créer les paramètres selon les vraies valeurs, puis instancier le pipeline. Ensuite, appeler les méthodes dans le bon ordre. Enfin, vérifier que le pipeline est un succès et que les bons contenus ont été sélectionnés.

Encore une fois, je n’ai pas hésité à expliquer ce que j’attendais.

Le résultat :

class ContentSelectionPipelineTest {

    private lateinit var csvFilePath: String

    @BeforeEach
    fun setup() {
        // Create a temporary CSV file with the required data
        val csvContent = """
            editorSlug;contentId;title;url;numberOfWords;publicationDate;indexationId;returnedScore;blacklisted
            fake-editor-1;content-1-1;Title 1-1;http://example.com/1-1;500;2023-01-01T10:00:00Z;00000000-0000-0000-0000-000000000001;0.75;false
            fake-editor-1;content-1-2;Title 1-2;http://example.com/1-2;1000;2023-01-02T10:00:00Z;00000000-0000-0000-0000-000000000002;0.82;false
            fake-editor-1;content-1-3;Title 1-3;http://example.com/1-3;750;2023-01-03T10:00:00Z;00000000-0000-0000-0000-000000000003;0.90;false
            other-editor;content-2-1;Title 2-1;http://example.com/2-1;600;2023-02-01T11:00:00Z;00000000-0000-0000-0000-000000000004;0.70;false
            other-editor;content-2-2;Title 2-2;http://example.com/2-2;1100;2023-02-02T11:00:00Z;00000000-0000-0000-0000-000000000005;0.78;false
            other-editor;content-2-3;Title 2-3;http://example.com/2-3;700;2023-02-03T11:00:00Z;00000000-0000-0000-0000-000000000006;0.85;false
            awesome-editor;content-3-1;Title 3-1;http://example.com/3-1;550;2023-03-01T12:00:00Z;00000000-0000-0000-0000-000000000007;0.65;false
            awesome-editor;content-3-2;Title 3-2;http://example.com/3-2;1200;2023-03-02T12:00:00Z;00000000-0000-0000-0000-000000000008;0.80;false
            awesome-editor;content-3-3;Title 3-3;http://example.com/3-3;800;2023-03-03T12:00:00Z;00000000-0000-0000-0000-000000000009;0.88;false
        """.trimIndent()

        val tempFile = createTempFile(suffix = ".csv")
        Files.writeString(tempFile, csvContent)
        csvFilePath = tempFile.toAbsolutePath().toString()
    }

    @Test
    fun `test content selection pipeline happy path`() = runBlocking {
        // Define editors
        val editorsList = listOf(
            Editor(UUID.randomUUID(), "fake-editor-1", 0.97, UUID.randomUUID()),
            Editor(UUID.randomUUID(), "other-editor", 0.97, UUID.randomUUID()),
            Editor(UUID.randomUUID(), "awesome-editor", 0.90, UUID.randomUUID())
        )

        // Create test input using the factory method
        val testInput = createTestInputFromCsv(editorsList, csvFilePath)

        // Define Content Selection Parameters according to application.yml
        val parameters = ContentSelectionParameters(
            scoreThreshold = 0.48,
            scoreCeiling = 0.95,
            freshnessScoreThreshold = 0.3
        )

        // Fixed Clock
        val fixedClock = Clock.fixed(Instant.parse("2023-04-01T00:00:00Z"), ZoneOffset.UTC)

        // Blacklist implementation (no items blacklisted for simplicity)
        val blacklist = object : Blacklist {
            override val allItems = emptyList<BlacklistItem>()
            override fun contains(content: InternalContentId): Boolean = false
        }

        // Instantiate the pipeline
        val pipeline = ContentSelectionPipeline(
            parameters = parameters,
            blacklist = blacklist,
            queryingEditor = editorsList.first(),
            relatedEditors = editorsList.drop(1),
            fetchContentMetadata = testInput.fetchContentMetadata,
            clock = fixedClock
        )

        // Initialize the pipeline
        pipeline.initializeForDocuments(testInput.relatedDocumentsBundle)

        // Execute the pipeline
        pipeline.execute()

        // Check if the pipeline succeeded
        assertTrue(pipeline.isSuccess())

        // Get the final contents
        val finalContents = pipeline.getFinalContents()

        // Assert the correct contents have been selected
        val expectedContents = listOf(
            "Title 1-1", "Title 1-2", "Title 1-3", 
            "Title 2-1", "Title 2-2", "Title 2-3", 
            "Title 3-1", "Title 3-2", "Title 3-3"
        )
        val finalContentTitles = finalContents.map { it.title }

        assertEquals(expectedContents.sorted(), finalContentTitles.sorted())
    }
}

Je trouve que ce résultat est tout à fait satisfaisant (mis à part le CSV inline dans la classe, mais c’est trivial à extraire). J’ai quand même eu quelques ajustements à faire sur le scénario de test en lui-même : changer une ou deux valeurs dans le CSV, changer la date de la Clock et changer l’assertion, car l’IA n’a pas compris que dans le cas nominal, il ne peut y avoir que 2 ou 3 contenus sélectionnés (la seule vraie déception du résultat)

Néanmoins, ajuster le scénario de la sorte est clairement la tâche la plus complexe. Déterminer, sans l’exécuter, quel sera le résultat du pipeline en fonction des données d’entrée c’est justement ce qui fait toute la valeur du test. Il me parait normal de réserver ce boulot au développeur une fois que tout ce code a été généré en peu de temps par l’IA.

Et le résultat final de l’expérience, bien sûr, est que ce test

  1. Compile ;
  2. Passe ;
  3. Échoue quand on introduit des bugs dans la classe.

Pour les plus assidus d’entre vous, le log complet de ma conversation avec Jetbrains AI est disponible en suivant ce lien.

Fin de l’expérimentation

À ce stade, j’ai stoppé mon test. La première raison, c’est que cette classe est déjà très bien testée dans la réalité, l’exercice est donc un peu vain. Mais surtout, je me suis fait un avis beaucoup plus solide sur la question qui m’intéressait au départ.

Pour poursuivre l’expérience, il faudrait :

  1. Refactorer la méthode de parsing du csv qui est un peu difficile à lire, je trouve.
  2. Créer d’autres scénarios de test, en particulier des cas d’exclusion de contenus basés sur les différentes règles métier.2

Résultat

Des pistes pour utiliser l’IA efficacement

Cette expérimentation a été menée sur des tests automatisés, mais je fais l’hypothèse que ce que j’ai compris en la faisant s’applique aussi pour d’autres tâches. Il faudrait bien sûr le vérifier, mais ça se confirme par les autres expérimentations que j’ai pu faire par le passé (même si elles étaient moins rigoureuses).

L’IA fonctionne quand le code généré est localisé

L’IA fonctionne bien pour générer du code localisé à un ou deux endroits.

  1. Générer une classe de test et une méthode utilitaire (comme je l’ai fait pour cet article)
  2. Générer des requêtes SQL
  3. Générer des types complexes typescript

Avoir besoin de contexte provenant de beaucoup de fichier n’est pas un problème grâce aux fonctionnalités de l’IDE qui permettent de passer rapidement le code de certains fichiers au LLM.

En revanche, modifier un grand nombre de fichiers a été plus difficile, et surtout trop laborieux pour que ça permette de gagner du temps.

Il faut donner beaucoup de contexte à l’IA

Donner une seule classe pour générer des tests n’est pas suffisant. Il faut donner aussi le code des classes utilisées en paramètres et en attributs pour avoir un résultat correct

On peut voir ça comme les données d’entrée du problème, comme on s’attendrait à avoir pour résoudre un problème de maths. De la même manière, si vous voulez générer une fonction algorithmique, il faudra que le LLM connaisse le code des éventuelles classes utilisées en paramètres ou en type de retour. Si vous concevez un contrôleur web, il faudra que le LLM sache quel framework vous utilisez, etc.

Il faut être explicite sur les attendus

Par défaut, le LLM a écrit des tests avec plein de mocks et des assertions sur l’état privé de la classe. Mais ça n’est pas une limitation intrinsèque du modèle, il fallait simplement que je lui demande de ne pas le faire.

De la même manière, si vous demandez une requête SQL un peu complexe, n’hésitez pas à spécifier les colonnes voulues en sortie, les tables à utiliser, les index que vous voulez que la requête utilise en priorité, etc.

Mon avis

Je suis convaincu qu’il est possible d’écrire d’excellents tests avec l’aide d’une IA. Il faut avoir des attentes réalistes, l’IA ne vous dispensera pas de réfléchir au système testé, aux cas de tests, et à l’architecture technique de votre socle de tests. Des questions que vous devez vous poser de toute façon si vous écrivez vos tests avec vos doigts.

J’ai passé environ deux heures à écrire ce premier test. Ce qui a pris le plus de temps a été de recommencer à zéro plusieurs fois pour perfectionner mon approche et vous proposer le résumé d’une session pas trop longue de discussion avec l’IA. Il est clair qu’écrire les cas de test suivants prendrait moins de temps, et au final je pense que cette approche aurait permis de gagner du temps sur l’écriture des tests.

Vais-je adopter l’IA pour tous mes futurs tests automatisés ? Je ne sais pas. Le coût environnemental est élevé, le coût financier aussi puisque l’IA est la nouvelle poule aux œufs d’or pour les entreprises du monde entier. Malgré les progrès rapides de ces technos, les gains de temps ne sont pas encore miraculeux (même s’ils sont réels dans certains cas).

Par contre, il me semble important de continuer à expérimenter. Selon moi, utiliser les LLM pour produire du code est une compétence utile pour un développeur dans un contexte ou la ruée vers l’IA n’est pas près de s’arrêter.

Annexes

La classe ContentSelectionPipeline en entier

/**
 * Represents the successive steps taken to select the best content from a bundle returned by the indexation engine.
 *
 * The ContentSelectionPipeline as currently implemented only supports Text Content.
 */
class ContentSelectionPipeline(
    private val parameters: ContentSelectionParameters,
    private val blacklist: Blacklist,
    private val queryingEditor: Editor,
    private val relatedEditors: List<Editor>,
    private val fetchContentMetadata: suspend (List<DocumentIndexMetadataWithScore>) -> List<ContentMetadata>,
    private val clock: Clock
) {
    private var initialBundle: RelatedDocumentsBundleWithContentMetadata? = null
    private var contents: MutableSet<TextContentMetadata>? = null
    private var steps: MutableList<PipelineStep>? = null
    private var selectedDocuments: MutableList<ContentAndDocumentMetadataWithScore>? = null

    suspend fun initializeForDocuments(relatedDocuments: RelatedDocumentsBundle) {
        // Keep only TextContentMetadata (the only one that exists in the DB for now)
        val fetchedContentMetadata = fetchContentMetadata(relatedDocuments.allDocuments())
        if(fetchedContentMetadata.any { it !is TextContentMetadata }) {
            throw IllegalStateException("The content selection pipeline was initialized with unsupported content." +
                    " Pipeline can only handle text content.")
        }

        contents = HashSet(
            fetchedContentMetadata.filterIsInstance<TextContentMetadata>()
        )
        initialBundle = associateContentToBundle(relatedDocuments, contents!!)
        steps = null
        selectedDocuments = null
    }

    fun execute() {
        val execBundle = initialBundle ?: throw IllegalStateException("Executing non-initialized pipeline")
        steps = mutableListOf()

        steps!!.add(PipelineStep(BLACKLIST, execBundle.applyBlacklist(blacklist)))
        steps!!.add(PipelineStep(SCORE, steps!!.last().bundle.filter(::hasScoreBetweenThresholdAndCeiling)))
        steps!!.add(PipelineStep(FRESHNESS, steps!!.last().bundle.applyFreshnessCoeff(relatedEditors, Instant.now(clock))))
        steps!!.add(PipelineStep(EXCLUDE_FRESHNESS, steps!!.last().bundle.filter(::hasScoreAboveFreshnessThreshold)))

        selectContents()
    }
    fun isSuccess() : Boolean {
        return selectedDocuments?.let {
            it.size >= 2
        } ?: false
    }
    fun getFinalContents(): List<ContentMetadata> {
        val selectedDocumentsCopy = selectedDocuments ?: throw IllegalStateException("Cannot get content on a pipeline that hasn't been executed")

        return selectedDocumentsCopy.map(ContentAndDocumentMetadataWithScore::contentMetadata)
    }

    private fun hasScoreBetweenThresholdAndCeiling(doc: ContentAndDocumentMetadataWithScore): Boolean =
        doc.score > parameters.scoreThreshold && doc.score < parameters.scoreCeiling

    private fun hasScoreAboveFreshnessThreshold(doc: ContentAndDocumentMetadataWithScore): Boolean =
        doc.score > parameters.freshnessScoreThreshold

    private fun selectContents() {
        selectedDocuments = mutableListOf()
        val finalBundle = steps!!.last().bundle

        // Select the first item : priority to the querying editor
        selectedDocuments!!.add(
            finalBundle.longestContentFromCollection(queryingEditor.collection)
                ?: finalBundle.longestContent()
                ?: return
        )

        // select the second and third item : priority to another editor
        selectedDocuments!!.add(
            finalBundle.longestContent(
                excludeCollections = getCollectionsFromMetadata(selectedDocuments!!),
                excludeDocuments = selectedDocuments!!
            )
                ?: finalBundle.longestContent(excludeDocuments = selectedDocuments!!)
                ?: return
        )
        selectedDocuments!!.add(
            finalBundle.longestContent(
                excludeCollections = getCollectionsFromMetadata(selectedDocuments!!),
                excludeDocuments = selectedDocuments!!
            )
                ?: finalBundle.longestContent(excludeDocuments = selectedDocuments!!)
                ?: return
        )
    }

    fun getExecutionSummary(): PipelineExecutionSummary {
        if(selectedDocuments == null) throw IllegalStateException("Cannot get summary of a pipeline that hasn't been executed")

        return PipelineExecutionSummary(
            initialBundle!!,
            steps!!,
            getFinalContents(),
        )
    }

    private fun getCollectionsFromMetadata(content: List<ContentAndDocumentMetadataWithScore>): List<Collection> {
        val editorsTids = content.map { it.documentIndexMetadata.id.editorTid }
        return relatedEditors.filter { editorsTids.contains(it.tid) }.map(Editor::collection)
    }
}

class PipelineStep(
val name: PipelineStepName,
val bundle: RelatedDocumentsBundleWithContentMetadata,
)

enum class PipelineStepName {
BLACKLIST, SCORE, FRESHNESS, EXCLUDE_FRESHNESS
}

Les tests générés avec le mauvais prompt

package fr.sipaof.flink.domain.content.selection

class IaContentSelectionPipelineTests {

    private lateinit var parameters: ContentSelectionParameters
    private lateinit var blacklist: Blacklist
    private lateinit var queryingEditor: Editor
    private lateinit var relatedEditors: List<Editor>
    private lateinit var fetchContentMetadata: suspend (List<DocumentIndexMetadataWithScore>) -> List<ContentMetadata>
    private lateinit var clock: Clock
    private lateinit var contentSelectionPipeline: ContentSelectionPipeline

    @BeforeEach
    fun setUp() {
        parameters = mockk<ContentSelectionParameters>()
        blacklist = mockk()
        queryingEditor = mockk()
        relatedEditors = listOf()
        fetchContentMetadata = mockk()
        clock = mockk()

        contentSelectionPipeline = ContentSelectionPipeline(
            parameters,
            blacklist,
            queryingEditor,
            relatedEditors,
            fetchContentMetadata,
            clock
        )
    }

    @Test
    fun `initializeForDocuments should initialize contents and initialBundle`() = runBlocking {
        val relatedDocumentsBundle = mockk<RelatedDocumentsBundle>()
        val textContentMetadata = mockk<TextContentMetadata>()
        val documentIndexMetadataWithScore = mockk<DocumentIndexMetadataWithScore>()

        every { relatedDocumentsBundle.allDocuments() } returns listOf(documentIndexMetadataWithScore)
        coEvery { fetchContentMetadata(any()) } returns listOf(textContentMetadata)

        contentSelectionPipeline.initializeForDocuments(relatedDocumentsBundle)

        assertNotNull(contentSelectionPipeline.contents)
        assertNotNull(contentSelectionPipeline.initialBundle)
        assertNull(contentSelectionPipeline.steps)
        assertNull(contentSelectionPipeline.selectedDocuments)
    }

    @Test
    fun `initializeForDocuments should throw IllegalStateException for unsupported content`(): Unit = runBlocking {
        val relatedDocumentsBundle = mockk<RelatedDocumentsBundle>()
        val contentMetadata = mockk<ContentMetadata>() // Not TextContentMetadata

        every { relatedDocumentsBundle.allDocuments() } returns listOf(mockk())
        coEvery { fetchContentMetadata(any()) } returns listOf(contentMetadata)

        assertThrows<IllegalStateException> {
            runBlocking {
                contentSelectionPipeline.initializeForDocuments(relatedDocumentsBundle)
            }
        }
    }

    @Test
    fun `execute should populate steps and selectedDocuments`() = runBlocking {
        val relatedDocumentsBundle = mockk<RelatedDocumentsBundle>()
        val textContentMetadata = mockk<TextContentMetadata>()
        val documentIndexMetadataWithScore = mockk<DocumentIndexMetadataWithScore>()

        every { relatedDocumentsBundle.allDocuments() } returns listOf(documentIndexMetadataWithScore)
        coEvery { fetchContentMetadata(any()) } returns listOf(textContentMetadata)

        contentSelectionPipeline.initializeForDocuments(relatedDocumentsBundle)

//        every { blacklist.filter(any()) } returns mockk()
        every { clock.instant() } returns Instant.now()
//        every { relatedDocumentsBundle.longestContentFromCollection(any()) } returns mockk()
//        every { relatedDocumentsBundle.longestContent(excludeCollections = any(), excludeDocuments = any()) } returns mockk()
        every { queryingEditor.collection } returns mockk()

        contentSelectionPipeline.execute()

        assertNotNull(contentSelectionPipeline.steps)
        assertNotNull(contentSelectionPipeline.selectedDocuments)
    }

    @Test
    fun `isSuccess should return true when at least 2 documents are selected`() = runBlocking {
        val relatedDocumentsBundle = mockk<RelatedDocumentsBundle>()
        val textContentMetadata = mockk<TextContentMetadata>()
        val documentIndexMetadataWithScore = mockk<DocumentIndexMetadataWithScore>()

        every { relatedDocumentsBundle.allDocuments() } returns listOf(documentIndexMetadataWithScore)
        coEvery { fetchContentMetadata(any()) } returns listOf(textContentMetadata)

        contentSelectionPipeline.initializeForDocuments(relatedDocumentsBundle)
        contentSelectionPipeline.execute()

        // Assume the pipeline selects 2 documents
        contentSelectionPipeline.selectedDocuments?.add(mockk())
        contentSelectionPipeline.selectedDocuments?.add(mockk())

        assertTrue(contentSelectionPipeline.isSuccess())
    }

    @Test
    fun `isSuccess should return false when less than 2 documents are selected`() = runBlocking {
        val relatedDocumentsBundle = mockk<RelatedDocumentsBundle>()
        val textContentMetadata = mockk<TextContentMetadata>()
        val documentIndexMetadataWithScore = mockk<DocumentIndexMetadataWithScore>()

        every { relatedDocumentsBundle.allDocuments() } returns listOf(documentIndexMetadataWithScore)
        coEvery { fetchContentMetadata(any()) } returns listOf(textContentMetadata)

        contentSelectionPipeline.initializeForDocuments(relatedDocumentsBundle)
        contentSelectionPipeline.execute()

        // Assume the pipeline selects 1 document
        contentSelectionPipeline.selectedDocuments?.add(mockk())

        assertFalse(contentSelectionPipeline.isSuccess())
    }

    @Test
    fun `getFinalContents should return selected content metadata`() = runBlocking {
        val relatedDocumentsBundle = mockk<RelatedDocumentsBundle>()
        val textContentMetadata = mockk<TextContentMetadata>()
        val documentIndexMetadataWithScore = mockk<DocumentIndexMetadataWithScore>()
        val contentAndDocumentMetadataWithScore = mockk<ContentAndDocumentMetadataWithScore>()

        every { relatedDocumentsBundle.allDocuments() } returns listOf(documentIndexMetadataWithScore)
        coEvery { fetchContentMetadata(any()) } returns listOf(textContentMetadata)
        every { contentAndDocumentMetadataWithScore.contentMetadata } returns textContentMetadata

        contentSelectionPipeline.initializeForDocuments(relatedDocumentsBundle)
        contentSelectionPipeline.execute()

        // Assume the pipeline selects 1 document
        contentSelectionPipeline.selectedDocuments?.add(contentAndDocumentMetadataWithScore)

        val finalContents = contentSelectionPipeline.getFinalContents()
        assertEquals(listOf(textContentMetadata), finalContents)
    }

    @Test
    fun `getExecutionSummary should return pipeline execution summary`() = runBlocking {
        val relatedDocumentsBundle = mockk<RelatedDocumentsBundle>()
        val textContentMetadata = mockk<TextContentMetadata>()
        val documentIndexMetadataWithScore = mockk<DocumentIndexMetadataWithScore>()
        val contentAndDocumentMetadataWithScore = mockk<ContentAndDocumentMetadataWithScore>()
        val pipelineStep = mockk<PipelineStep>()

        every { relatedDocumentsBundle.allDocuments() } returns listOf(documentIndexMetadataWithScore)
        coEvery { fetchContentMetadata(any()) } returns listOf(textContentMetadata)
        every { contentAndDocumentMetadataWithScore.contentMetadata } returns textContentMetadata
        every { pipelineStep.bundle } returns mockk()

        contentSelectionPipeline.initializeForDocuments(relatedDocumentsBundle)
        contentSelectionPipeline.execute()

        // Assume the pipeline selects 1 document and steps are populated
        contentSelectionPipeline.selectedDocuments?.add(contentAndDocumentMetadataWithScore)
        contentSelectionPipeline.steps?.add(pipelineStep)

        val summary = contentSelectionPipeline.getExecutionSummary()
        assertNotNull(summary)
    }

}

  1. Par souci d’uniformité dans l’article, j’ai retranscrit les prompts en français mais j’ai conversé exclusivement en anglais avec l’IA. Le résultat serait certainement similaire en lui soumettant des prompts directement en français. 2

  2. Je dois dire d’ailleurs que l’IA peut sans doute aider à cette tâche, même si les rapides tests que j’ai faits ne sont pas très convaincants : https://chatgpt.com/share/67083a01-e200-8000-bfa2-37b94113fa4c