Introduction

Quand vous faites du TDD ou juste des features complètement testées, c'est bien votre fichier de test qui sert de documentation pour votre fonctionnalité, en effet, il recense de manière exhaustive l'ensemble de ses comportements. Si un collègue doit faire évoluer votre fonctionnalité, il va naturellement lire votre fichier de test pour comprendre ce que fait votre code. Dans ce contexte, c'est bien la clarté de votre fichier de test qui est la plus importante !

Ici je vous propose d'utiliser des test data builders pour rendre ces fichiers de tests expressifs, concis et lisibles 😉

Exemple concret

Contexte

Imaginons que vous écriviez une commande qui permet de planter des arbres sur votre terrain, vous allez naturellement écrire plusieurs tests dans lesquels vous allez fournir à votre store un objet que vous allez demander à votre commande de modifier. Vous allez probablement répeter des instanciations de votre objet pour “arrange” vos tests.

Si j'ai une telle classe PlantedTree:

export class PlantedTree {
  type: string;
  position: Position;
  plantedAt: Date;
 
  constructor({ typeof, position, plantedAt }: PlantedTreeArgs) {
    this.type = type;
    this.position = position;
    this.plantedAt = plantedAt;
  }
}
 
export type PlantedTreeArgs = {
  type: String;
  position: Position;
  plantedAt: Date;
};
 
export type Position = {
  x: number;
  y: number;
};

Vous allez probablement écrire plusieurs tests qui pourraient ressembler à ça:

describe('PlantTreeCommand', () => {
  let plantTreeCommandHandler: PlantTreeCommandHandler;
  let treeStore: TreeStubStore;
 
  beforeEach(() => {
    treeStore = new TreeStubStore();
    plantTreeCommandHandler = new PlantTreeCommandHandler(treeStore);
  });
 
  it('should plant a tree', async () => {
    const command = new PlantTreeCommand('palm tree', {x: 0, y: 1}, new Date('2024-07-01'));
 
    await plantTreeCommandHandler.handle(command);
 
    expect(treeStore.trees).toEqual([new PlantedTree({ command.type, command.position, command.plantedAt })]);
  });
 
  it('should throw if there is another tree closer than 2 units', async () => {
    const existingTree = new PlantedTree({
      name: 'pine tree',
      position: { x: 0, y: 0 },
      plantedAt: new Date('2024-07-01'),
    });
    await treeStore.save(existingTree);
 
    const command = new PlantTreeCommand('palm tree', { x: 1, y: 1 }, new Date('2024-07-01'));
    await expect(async () => await plantTreeCommandHandler.handle(command)).toThrow(
      'Tree closer than 2 units',
    );
  });
 
  it('should throw if another tree of the same type was already planted this day', async () => {
    const existingTree = new PlantedTree({
      name: 'pine tree',
      position: { x: 0, y: 0 },
      plantedAt: new Date('2024-07-01'),
    });
    await treeStore.save(existingTree);
 
    const command = new PlantTreeCommand('pine tree', { x: 2, y: 0 }, new Date('2024-07-01'));
    await expect(async () => await plantTreeCommandHandler.handle(command)).toThrow(
      'Another tree of the same type was already planted this day',
    );
  });
 
  it('should throw if trees have been planted for 4 consecutive days', async () => {
    const existingTreeDay1 = new PlantedTree({
      name: 'pine tree',
      position: { x: 0, y: 0 },
      plantedAt: new Date('2024-07-01'),
    });
    const existingTreeDay2 = new PlantedTree({
      name: 'pine tree',
      position: { x: 2, y: 0 },
      plantedAt: new Date('2024-07-02'),
    });
    const existingTreeDay3 = new PlantedTree({
      name: 'pine tree',
      position: { x: 0, y: 2 },
      plantedAt: new Date('2024-07-03'),
    });
 
    await treeStore.save(existingTreeDay1);
    await treeStore.save(existingTreeDay2);
    await treeStore.save(existingTreeDay3);
 
    const command = new PlantTreeCommand('palm tree', { x: 2, y: 2 }, new Date('2024-07-04'));
    await expect(async () => await plantTreeCommandHandler.handle(command)).toThrow(
      'Trees have been planted for 4 consecutive days',
    );
  });
});

Ces instanciations sont problématiques pour plusieurs raisons:

  • ces répétitions peuvent coûter un nombre de lignes de code non négligeable qui alourdissent la lecture de votre fichier de test

  • vous précisez chacun des attributs ce qui ne rend pas explicite l'attribut ou les attributs de votre objet qui vous servent dans le use case. Par exemple, dans le test should throw if there is another tree closer than 2 units, seule la position de l'arbre existant est utile à la compréhension du test

  • vous sautez les 2 pieds joints dans le piège new is glue: vous instanciez un objet directement via le constructeur de la classe, cela rend votre test dépendant de l'implémentation de votre objet et vous empêche de changer l'implémentation de votre objet sans changer vos tests.

Le test data builder 💪🏼

C'est là que vous utilisez un test data builder pour instancier tous ces objets ! Le test data builder est l'implémentation du builder pattern pour des usages de testing

En gros c'est un constructeur d'objet qui dispose de valeurs par défaut pour tous les attributs de votre objet et que vous pouvez écraser au besoin.

Par exemple:

export class PlantedTreeBuilder {
  type: string = 'palm tree';
  position: Position = { x: 0, y: 0 };
  plantedAt: Date = new Date('2000-01-01');
 
  withType(type: string) {
    this.type = type;
    return this;
  }
 
  withPosition(position: Position) {
    this.position = position;
    return this;
  }
 
  withPlantedAt(plantedAt: Date) {
    this.plantedAt = plantedAt;
    return this;
  }
 
  build() {
    return new PlantedTree({ type: this.type, position: this.position, plantedAt: this.plantedAt });
  }
}

Ici, le builder nous fournit par défaut un palmier planté le 1er janvier 2000 en position (0, 0).

Et nos tests s'écrivent alors:

describe('PlantTreeCommand', () => {
  //[...]
 
  it('should plant a tree', async () => {
    const command = new PlantTreeCommand('palm tree', {x: 0, y: 1}, new Date('2024-07-01'));
 
    await plantTreeCommandHandler.handle(command);
 
    expect(treeStore.trees).toEqual([new PlantedTree({ command.type, command.position, command.plantedAt })]);
  });
 
  it('should throw if there is another tree closer than 2 units', async () => {
    const existingTree = new PlantedTreeBuilder().withPosition({ x: 0, y: 0 }).build();
    await treeStore.save(existingTree);
 
    const command = new PlantTreeCommand('palm tree', { x: 1, y: 1 }, new Date('2024-07-01'));
    await expect(async () => await plantTreeCommandHandler.handle(command)).toThrow(
      'Tree closer than 2 units',
    );
  });
 
  it('should throw if another tree of the same type was already planted this day', async () => {
    const duplicatedPlantingDate = new Date('2024-07-01');
    const existingTree = new PlantedTreeBuilder().withType('pine tree').withPlantedAt(duplicatedPlantingDate).build();
    await treeStore.save(existingTree);
 
    const command = new PlantTreeCommand('pine tree', { x: 2, y: 0 }, duplicatedPlantingDate);
    await expect(async () => await plantTreeCommandHandler.handle(command)).toThrow(
      'Another tree of the same type was already planted this day',
    );
  });
 
  it('should throw if trees have been planted for 4 consecutive days', async () => {
    const existingTreeDay1 = new PlantedTreeBuilder().withPlantedAt(new Date('2024-07-01')).build();
    const existingTreeDay2 = new PlantedTreeBuilder().withPlantedAt(new Date('2024-07-02')).build();
    const existingTreeDay3 = new PlantedTreeBuilder().withPlantedAt(new Date('2024-07-03')).build();
 
    await treeStore.save(existingTreeDay1);
    await treeStore.save(existingTreeDay2);
    await treeStore.save(existingTreeDay3);
 
    const command = new PlantTreeCommand('palm tree', { x: 2, y: 2 }, new Date('2024-07-04'));
    await expect(async () => await plantTreeCommandHandler.handle(command)).toThrow(
      'Trees have been planted for 4 consecutive days',
    );
  });
});

Ici nos objets sont assez simples donc le gain de volume n'est pas étourdissant (même si on réduit déjà le nombre de lignes de plus de 35%) mais dans des cas métiers rééls avec des objets plus complexes, le gain de lisibilité est souvent énorme.

Par ailleurs, le principal avantage est que vous rendez explicites les attributs de votre objet qui vous intéressent quand vous construisez votre objets, les autres attributs prenant des valeurs par défaut de manière insignifiante pour votre test. Et votre lecteur de test n'a plus à se demander pourquoi vous avez choisi cette date dans un test qui traite de la position de l'arbre.

Remarque

Dans des cas simples, vous pouvez obtenir un résultat similaire avec un objet: DEFAULT_PLANTED_TREE_ARGS

const DEFAULT_PLANTED_TREE_ARGS = {
  type: 'palm tree',
  position: { x: 0, y: 0 },
  plantedAt: new Date('2000-01-01'),
};
 
const myTree = new PlantedTree({ ...DEFAULT_PLANTED_TREE_ARGS, type: 'pine tree' });

Disclaimer

Dans test data builder, il y a test, ce qui signifie que vous ne pouvez pas utiliser ces builders pour instancier vos objets dans votre code de production. La raison est assez évidente, ces builders proposent des valeurs par défaut pour tous les attributs de votre objet, ce qui serait dangereux et juste inepte dans votre code de production.

Si vous avez besoin de créer en production des objets avec certaines valeurs par défaut, vous pouvez créer une méthode static qui ne prend en argument que les valeurs des attributs que vous voulez spécifier. Par exemple:

static createInitialTree(type: string, position: Position, plantedAt: Date) {
  return new PlantedTree({ type, position, plantedAt, size: 2 });
}

Ici on considère que la taille initiale de l'arbre est fixée à 2 mais on force malgrè tout l'utilisateur à spécifier les valeurs des autres attributs.

Composition

Pour finir, vous pouvez composer vos builders pour créer des objets plus complexes. Imaginons que nos arbres puissent aussi héberger des oiseaux qui sont eux aussi des entités domaine, on pourrait alors créer un arbre contenant un perroquet de façon très explicite:

const myTreeWithBirds = new PlantedTreeBuilder()
  .withBirds([new BirdBuilder().withType('parrot').build()])
  .build();

Et en un clin d'oeil, vous avez un objet complet et complexe pour vos tests !

Conclusion

Les test data builders permettent de rendre vos tests plus expressifs, concis et lisibles. Ils permettent de ne pas se répéter dans vos tests et de rendre explicites les attributs de vos objets qui vous intéressent pour votre test.

Ici nos tests data builder ont permis d'optimiser la partie arrange de nos tests, cependant, ce n'est pas la seule méthode. En effet, on peut aussi utiliser des fixtures (objets déjà instanciés avec des caractéristiques précises, qui, nommées de manière explicite, permettent de comprendre rapidement ce qu'elles représentent) ou des instanciations communes dans le beforeEach dont l'inconvénient est qu'elles n'apparaissent pas explicitement dans les it

Par ailleurs, il est aussi possible d'appliquer des optimisations sur les sections act ou assert. En particulier pour la partie assert, si notre aggrégat est gros et qu'on veut de manière répétée en tester une partie précise, on peut par exemple en extraire une méthode checkIfEveryBirdInTreeAreAlive:

checkIfEveryBirdInTreeAreAlive(tree: PlantedTree) {
  tree.birds.forEach(bird => expect(bird.isAlive).toBe(true));
}

Happy testing ! 🧪