Property-based testing : Un contrat d'interface en béton

La compréhension de cet article est facilitée par des connaissances sur l'architecture hexagonale (Clean Archi) et le Domain-Driven Design.

Lorsque vous développez un produit en vous basant sur les principes du Domain-Driven Design (DDD) et que vous vous efforcez de respecter les principes de Clean Archi, vous vous retrouvez alors probablement avec une catégorie particulière d'interfaces appelées Repository. Nous allons voir ici qu'une stratégie de test des implémentations se reposant uniquement sur les méthodes de l'interface peut s'avérer très utile pour itérer sur notre implémentation sans influencer notre code métier. Nous allons prendre un Repository en exemple mais cette approche s'adapte à toutes les interfaces.

Que fait un repository ?

Pour les besoins de notre article, nous allons nous focaliser sur la forme la plus simple d'un repository. Cette forme est tout de même pertinente car dans une optique de Clean Architecture, on essaiera de localiser au maximum la complexité dans le code métier, c'est à dire les méthodes de l'agrégat ou les Use Cases. En général, le repository se retrouve alors à ne gérer que l'enregistrement unitaire d'un agrégat, les agrégats étant indépendants entre eux. On considère alors un agrégat comme une unité de persistence. Si on se dirige vers une approche CQRS, alors les repositories peuvent également perdre en complexité de requêtage. Toutes ces approches visent justement à simplifier l'implementation des interfaces qui sont spécifiques à l'infrastructure afin de localiser la complexité métier dans du code qui ne dépend pas d'autre systèmes et qu'on pourra alors tester unitairement plutôt qu'avec des tests d'intégration.

J'utiliserai des exemples en TypeScript, mais les principes s'appliquent à beaucoup de langages. Imaginons un repository pour un agrégat Machin.

interface MachinRepository {
  get (id: Machin.Id): Promise<Machin | undefined>
  save (machin: Machin): Promise<void>
  remove (id: Machin.Id): Promise<void>
}

Ici, nous avons un genre de forme canonique de repository. Notre typage nous informe déjà de quelques indices importants pour l'implémentation de notre interface :

  • les méthodes exposées sont asynchrones, c'est à dire qu'elle font des entrées / sorties. L'univers javascript nous force à préciser ceci, mais il peut être utile aussi dans votre langage de prédilection de différencier par leur type les fonctions qui font appel à d'autres systèmes (disque, réseau, etc.)
  • Enregistrer avec save() ne nécessite aucune valeur de retour car le stockage n'a pas d'influence sur le contenu ou l'état de l'agrégat
  • l'identifiant de notre agrégat id fait partie de son contenu, il n'est donc pas nécessaire de le préciser à la sauvegarde
  • Lire un agrégat par son id peut avoir deux résultats distincts (hors erreurs).

Ces indices sont déjà très utile à un type checker lorsque vous utiliserez l'interface. Ils vous indiquent aussi certaines choses à garder en tête lors de l'implémentation, mais pas toutes ! Voici des éléments qui n'apparaissent pas dans la déclaration de l'interface et qui sont pourtant indispensable au bon fonctionnement d'un repository :

  • Avec aucun contenu enregistré, get() renvoie toujours undefined
  • Si j'appelle save(machin1) et ensuite machin2 = get(machin1.id), alors machin1 == machin2
  • Si j'appelle save(machin), puis remove(machin.id) et enfin get(machin.id), alors j'obtiens toujours undefined
  • l'ordre d'appel de save(a) et save(b) n'a pas de conséquence sur les valeurs stockées de a et b
  • La liste continue…

Ces comportements peuvent nous paraître évident parce que nous baragouinons l'anglais et nous n'en sommes pas à notre premier repository, mais il faut reconnaître que ces attentes vis-à-vis de MachinRepository sont tout à fait implicites. En développeurs expérimentés, nous comprenons alors que des attentes implicites sont le terreau fertile des incompréhensions, et que les incompréhensions, ça tend les relations. Les relations tendues, c'est mauvais pour la santé. Il nous faut préserver notre santé en limitant les attentes implicites !

Un autre enjeu de rendre ces propriétés explicites, c'est de transformer l'essai sur la définitions d'interfaces pour nos classes d'infrastructure. En effet, si toutes les propriétés de notre repository sont connues, alors il sera plus simple d'en changer l'implementation. On pourra passer d'une implementation in-memory pour les toutes premières itérations à un implémentation avec Sqlite puis ensuite avec une base de données Oracle™ par exemple. On voudra que chacune de ces générations d'implémentations possède ces mêmes propriétés qui garantissent sa bonne utilisation depuis le code métier.

Tester le comportement de l'interface

Pour rendre explicite des comportements implicites, rien de tel que des tests automatisés ! Voyons ici avec une syntaxe Mocha / Chai

describe('MachinRepository', () => {
  let repository: MachinRepository
  beforeEach(async () => {
    repository = await setupMachinRepository()
  })
  afterEach(() => teardownMachinRepository(repository))

  context("quand rien n'est enregistré", () => {
    describe('.get(id)', () => {
      it('résoud undefined', async () => {
        // Given
        const id = '123'
        // When
        const actual = await repository.get(id)
        // Then
        expect(actual).to.equal(undefined)
      })
    })
  })

  context('quand un machin est enregistré', () => {
    const machin = unMachin()
    beforeEach(() => repository.save(machin))

    describe('.get(machin.id)', () => {
      it('résoud le même machin', async () => {
        // When
        const actual = await repository.get(machin.id)
        // Then
        expect(actual).to.deep.equal(machin)
      })
    })

    describe('.get(autreId)', () => {
      it('résoud undefined', async () => {
        // When
        const actual = await repository.get('456')
        // Then
        expect(actual).to.equal(undefined)
      })
    })

    describe("et qu'on appelle .remove(machin.id)", () => {
      beforeEach(() => repository.remove(machin.id))

      describe('.get(machin.id)', () => {
        it('résoud undefined', async () => {
          // When
          const actual = await repository.get(machin.id)
          // Then
          expect(actual).to.equal(undefined)
        })
      })
    })
  })
})

Ce qui donne cette magnifique éxecution :

MachinRepository
  quand rien n'est enregistré
    .get(id)
      résoud undefined ✓
  quand un machin est enregistré
    .get(machin.id)
      résoud le même machin ✓
    .get(autreId)
      résoud undefined ✓
    et qu'on appelle .remove(machin.id)
      .get(machin.id)
        résoud undefined ✓

L'exemple n'est pas exhaustif, mais ces quelques tests explicitent très bien quelques points clés du comportement de notre repository :

  • On lit exactement ce qu'on vient d'y écrire
  • Pas de distinction possible entre juste supprimé et jamais enregistré
  • Les agrégats sont indépendants entre eux dans leur stockage

On peut imaginer d'autres propriétés qu'il serait intéressant de vérifier, mais j'essaye de garder les exemples courts. La valeur de ces quelques tests est que nous pourrons alors vérifier ces propriétés pour toutes les implementations de notre interface.

Voici quelques particularités que l'on peut remarquer au sujet de la stratégie de test

Injection du System Under Test

Le sujet de nos tests ici s'appelle repository et il n'est typé que par son interface MachinRepository.

let repository: MachinRepository
beforeEach(async () => {
  repository = await setupMachinRepository()
})
afterEach(() => teardownMachinRepository(repository))

j'ai omis la définition des fonctions setupMachinRepository() et teardownMachinRepository() parce que leur implémentation dépend de la classe que vous voulez instancier et sur une instance de laquelle vous voulez lancer ces tests. Par exemple, avec une implémentation sur système de fichier :

async function setupMachinRepository () {
  const directory = await fs.mkdtemp()
  return new FileSystemMachinRepository(directory)
}

async function teardownMachinRepository (repository: FileSystemMachinRepository) {
  await repository.cleanDirectory()
}

L'idée sous-jacente est que l'initialisation et le nettoyage du system under test soit injectable et qu'aucune autre information ne soit nécessaire à son utilisation que l'implémentation de la bonne interface.

En forme complète, on peut imaginer que ce test ressemble à ceci :

describeMachinRepository(setupFileRepository, teardownFileRepository)

function describeMachinRepository (setup, teardown) {
  describe('MachinRepository', () => {
    let repository: MachinRepository
    beforeEach(async () => {
      repository = await setup()
    })
    afterEach(() => teardown(repository))

    // ... tests ...
  })
}

Sacrilège ! des méthodes de l'interface en setup

En effet, nos testent mélangent l'utilisation des différentes méthodes de l'interface plutôt que de se focaliser sur une méthode en particulier. Cela peut paraître iconoclaste mais c'est nécessaire dans notre cas. En effet, la fonction principale de notre repository est de gérer des effets de bords : l'enregistrement. Ce que l'on cherche à tester au niveau des propriétés de notre interface, c'est bien la gestion de ces effets de bords et comment les différentes méthodes s'influencent les unes et les autres. Notamment, on cherche à décrire comment se comporte notre repository en fonction de l'ordre d'appel de ses méthodes.

await repository.save(machin)
await repository.remove(machin.id)

N'a pas les mêmes conséquences que

await repository.remove(machin.id)
await repository.save(machin)

dans les exemples précédents, j'ai utilisé certaines méthodes de l'interface dans beforeEach() (c'est à dire la partie Given de mon test). Ceci résulte de la manière dont j'ai structuré mes tests avec describecontext et it qui sont taillés principalement pour le test unitaire d'une méthode en particulier. Ce que l'on cherche réellement à tester ici, c'est les conséquences d'une série d'appels de notre repository. C'est donc normal que nous appelions plusieurs méthodes à la fois dans nos tests.

Assertions sur la lecture

En conséquence du point précédent, on remarque aussi que les assertions se font uniquement sur le résultat de notre méthode get(id). Du point de vue de notre interface, la méthode get(id) est la seule qui n'a pas d'effet de bord (ce qui ne sera pas forcément vrai pour son implémentation). Appeler get(id) n'a aucun effet sur les agrégats déjà enregistrés et on peut l'appeler plusieurs fois de suite sans résultat différent. C'est également la seule chose que l'on peut observer, les autres méthodes renvoyant Promise<void>.

Dans des tests d'intégration classiques, sur la classe d'implémentation, on vérifiera par exemple qu'un fichier a bien été écrit, qu'une insertion SQL a bien été lancée ou même qu'un requête HTTP est partie. Tout ceci relève bien de l'implémentation mais ce n'est pas le comportement de l'interface, ce n'est pas l'attente de l'utilisateur de l'interface. C'est une manière parmis d'autre d'atteindre ce comportement, mais ce n'est pas par cela qu'on le décrit. Ici, donc, on décrit les propriétés de l'interface en ne considérant aucun système externe, réellement en isolation.

Le comportement attendu de l'interface, quelque soit sont implémentation, c'est qu'on pourra par exemple y lire ce qu'on vient d'y écrire. Si on ne veut tester que les propriétés propres de l'interface, alors on teste en utilisant get() !

Des tests complémentaires, spécifiques à l'implémentation, seront toujours de mise. En effet, il est intéressant de tester comment une implémentation se comporte avec le système de fichiers, comment elle s'authentifie auprès d'un autre service, etc.

J'utilise volontairement un langage dont le typage est limité, j'aurais pu utiliser Java, Kotlin ou C++ qui sont également des langages qui échouent à décrire la relation que l'on peut avoir entre save() et get(). L'objet de cet article est d'annexer à la déclaration de l'interface une suite de tests pour compléter sa description. Notez qu'avec un langage qui ne peut déclarer une interface, comme Javascript par exemple, vous pourrez alors toujours vous reposer uniquement sur une suite de tests pour la décrire. Cette discipline de décrire les contrats interfaces par des tests se retrouve dans la littérature sous le nom de Contract Testing ou Abstract Test Case.

En route pour du Property-Based Testing

Si vous avez l'œil, vous aurez remarqué que j'ai volontairement mis en exergue jusqu'à maintenant le mot propriété. Car ce que nous sommes en train de décrire s'en rapproche étrangement. Dans les exemples précédents j'ai utilisé une méthode que je n'ai pas détaillée unMachin() qui retourne un agrégat de type Machin. Comme tous les tests que nous avons l'habitude d'écrire, ils sont construits sur en ensemble d'exemplesunMachin() nous fabrique un exemple ou un exemplaire de Machin que nous pouvons ensuite utiliser dans des exemples de scénarios d'utilisation.

Lorsque l'on parle de propriétés d'un système, on s'attend en revanche à ce que son comportement et ses effets soient vrais pour toutes les valeurs possibles de son domaine. Dans notre cas, cela voudra dire qu'on veut tester que le code suivant est vrai pour toutes les variantes possibles d'un Machin et pas seulement celles fabriquées par unMachin().

describe('MachinRepository', () => {
  context('quand on appelle .save(machin)', () => {
    describe('.get(machin.id)', () => {
      it('résoud le même machin', async () => {
        // Given
        const machin = unMachin()
        await repository.save(machin)
        // When
        const actual = await repository.get(machin.id)
        // Then
        expect(actual).to.deep.equal(machin)
      })
    })
  })
})

Le principe du property-based testing, c'est de décrire le domaine des variantes possibles de Machin et de s'assurer que nos attentes, les propriétés, sont respectées pour l'ensemble de ce domaine. On parle parfois également d'invariants.

En pratique, on va plutôt génerer un grand nombre d'exemples représentatifs de Machin et executer un même test pour chacun d'entre eux. Allons-y petit à petit :

describe('MachinRepository', () => {
  const exemples = [unMachin(), unMachinAvecUnTruc(), unMachinAvec2Trucs(), unMachinVide()]
  for (const machin of exemples) {
    context('quand on appelle .save(machin)', () => {
      describe('.get(machin.id)', () => {
        it('résoud le même machin', async () => {
          // Given
          await repository.save(machin)
          // When
          const actual = await repository.get(machin.id)
          // Then
          expect(actual).to.deep.equal(machin)
        })
      })
    }
  })
})

Dans ce code, on génère des exemples avec des fonctions utilitaires et pour chacun d'entre eux on execute notre test. On vérifie donc que, quelque soit la forme de notre machin, le repository enregistre exactement ce machin et il est capable de le restituer à l'identique. C'est tout ce qu'on attendait de notre repository !

Écrire ces fonctions utilitaires peut être fastidieux. Gardez en tête que rien ne vous oblige à générer exactement toutes les valeurs possibles. L'intuition du TDD reste de trouver intelligemment des exemples qui font avancer la conception. Le property-based testing en revanche vise une description plus formelle du fonctionnement de notre code, elle permet principalement d'éviter des conversations. Dosez donc votre évitement et restez pragramatique !

Si vous voulez tout de même utiliser un grand nombre d'exemples, alors il vous faudra automatiser la génération de ces exemples, en gardant un minimum de cohérence. Dans cet exemple, je vais utiliser le module faker, et surtout je vais pour la première fois supposer ce que contient un Machin

function *exemples (count=10) {
  while (0 < count--) {
    const nombreDeTrucs = Math.floor(Math.random() * 5)
    const machin: Machin = {
      id: faker.random.uuid(),
      nom: faker.name.title(),
      trucs: count(nombreDeTrucs).map(() => ({
        couleur: faker.commerce.color()
      }))
    }
    yield machin
  }
}

describe('MachinRepository', () => {
  for (const machin of exemples(100)) {
    context('quand on appelle .save(machin)', () => {
      describe('.get(machin.id)', () => {
        it('résoud le même machin', async () => {
          await repository.save(machin)
          const actual = await repository.get(machin.id)
          expect(actual).to.deep.equal(machin)
        })
      })
    }
  })
})

Voici qui devrait faire ! En générant une centaine d'exemples et en vérifiant le comportement de save() et get(), nous pouvons être certains que toutes les implémentations qui valident ces tests respectent bien la promesse de notre repository.

Vous pouvez adopter cette approche si vous vous attendez à de plusieurs implémentations de votre interface, des versions incrémentales d'une même implémentation ou des implémentations par des personnes encore inconnues, avec lesquelles vous n'êtes pas certains de pouvoir avoir les conversations nécessaires à la bonne compréhension de notre interface.

Le Property-Based Testing ne se limite évidemment pas aux repositories ni mêmes aux interfaces. Même si ces notions proviennent de la programmation fonctionnelle et plutôt formelle, les principes sont transposables à des langages impératifs, typés ou non.

Conclusion

Nous avons vu que la plupart des langages ne permettent qu'une description lacunaire de nos interfaces, notamment en ce qui concerne les Repositories. En particulier, les interactions entre les différentes méthodes d'un repository et leurs effets de bord n'y sont pas décrites. Il peut être alors utile d'annexer à la déclaration du type une suite de tests qui permet de préciser les attentes à son sujet. La ou les implémentations devront alors, en plus de leur suite de tests propre si nécessaire, valider cette suite de tests formelle. Ceci s'appelle du Contract Testing

Par exemple, j'ai pu utiliser cette approche sur une base de code en développant pour certains repositories une implémentation qui vise PostgreSQL pour la production et une implémentation en mémoire pour le développement. Cela nous a permis de tester nos Use Cases et notre domaine sans mocker ou stubber nos repositories. Les assertions se faisant alors sur le contenu du repository à la fin du test plutôt que sur les appels faits à celui-ci. Nos tests étaient alors bien moins dépendants de l'implémentation et surtout plus faciles à comprendre. Ces 2 implémentations, SQL et en mémoire, valident chacune la suite de tests basée sur les méthodes de l'interface uniquement. Cette suite de tests à été construite en TDD sur l'implémentation en mémoire d'abord, qui a d'ailleurs été déployée dans un premier temps avant que l'implémentation SQL ne viennent prendre le relai.

En poussant le raisonnement plus loin et en constatant que nos attentes sont des propriétés de notre repository, on peut alors s'orienter vers du property-based testing pour couvrir un maximum de bases. Les tests ainsi réalisés sur une implémentation valideront qu'elle possède les propriétés attendues et ses tests d'intégration spécifiques pourront alors se focaliser sur le détail de son interaction avec un système externe (fichiers, base de données, index, etc.)

Ces approches permettent in fine de s'assurer que l'utilisation que nous faisons de nos repositories dans notre code métier, notre domaine, est garantie et indépendante de leur implémentation. Ceci nous permet de changer plus facilement nos implémentations et donc de commencer avec des implémentations simplistes au début, pour valider les principales hypothèses métier, puis plus complexes ensuite pour industrialiser notre produit. C'est un des aspects de la méthode Accelerate.

Pour aller plus loin…

Contrat Testing

Property-Based Testing