DEV Community

Jérémy Chauvin
Jérémy Chauvin

Posted on

3 1 1 1

Mieux tester en optimisant la génération de données de test : Panorama des patterns

Article co-écrit avec @manon_carbonnel

Écrire des tests automatisés efficaces ne se résume pas à vérifier que notre code fonctionne : il faut aussi que les tests soient lisibles, maintenables et rapides à écrire. Pour cela, la manière dont on génère les données de test joue un rôle clé.

Les patterns que nous vous présentons permettent d’éviter de la duplication, de rendre les tests plus clairs et de mieux maîtriser la complexité des objets manipulés.

Dans cet article, nous allons explorer différentes approches, leurs avantages et leurs limites, et cibler la stratégie à adopter selon le contexte.

Pourquoi faire varier ses jeux de données ?

Pour certains tests, il est primordial de rendre visible la variation des données afin d'expliciter la règle métier sous-jacente.

Par exemple, pour déterminer si un utilisateur est majeur, vous devez modifier son âge dans vos tests et créer deux fixtures. Cependant, ce qui vous intéresse réellement, c'est de voir la règle métier apparaître clairement dans vos tests. Il est crucial de bien mettre en évidence l'âge que vous attribuez.

💡 Ces patterns ne sont pas spécifiques au langage TypeScript que nous avons choisi dans nos exemples, et sont implémentables avec n’importe quel langage généraliste.

Utilisation des Fixtures

Les Fixtures sont des ensembles de données préconfigurées utilisées pour initialiser l'état d'un test. Elles permettent de définir des conditions de test spécifiques et de garantir que les tests sont reproductibles.

Exemples de code pour créer et utiliser des Fixtures en TypeScript

// personaFixtures.ts
export const johnDoe: User = {
  id: 1,
  name: 'John Doe',
  email: 'john.doe@example.com'
  // ... your full Persona caracteristics, to be used in several tests
};
Enter fullscreen mode Exit fullscreen mode
// test.ts
import { johnDoe } from './personaFixtures';

test('should create a user', () => {
  const user = createUser(johnDoe);
  // ...code
  expect(user).toEqual(johnDoe);
});
Enter fullscreen mode Exit fullscreen mode

Avantages

  • Simplicité et facilité d'utilisation.
  • Reproductibilité des tests.
  • Utilisation des persona UX

Inconvénients

  • Peuvent devenir difficiles à gérer pour des ensembles de données complexes.
  • Moins flexibles pour des variations de données.

Utilisation des Object Mother

Le pattern Object Mother consiste à créer des objets complexes à partir de méthodes de fabrication centralisées. Cela permet de simplifier la création d'objets de test et de garantir la cohérence des données.

Exemples de code pour créer des objets complexes avec des Object Mothers en TypeScript

// objectMother.ts
export class UserMother {
  static create(): User {
    return {
      id: 1,
      name: 'John Doe',
      email: 'john.doe@example.com'
    };
  }
}
Enter fullscreen mode Exit fullscreen mode
// test.ts
import { UserMother } from './objectMother';

test('should create a user', () => {
  const user = UserMother.create();
  expect(user).toEqual({
    id: 1,
    name: 'John Doe',
    email: 'john.doe@example.com'
  });
});
Enter fullscreen mode Exit fullscreen mode

Avec un objet plus complexe :

Les types

// interfaces.ts
export interface Item {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

export interface Order {
  id: number;
  customerName: string;
  items: Item[];
  total: number;
}
Enter fullscreen mode Exit fullscreen mode

Object Mother pour Order

// orderMother.ts
import {Order} from "./interface";

export class OrderMother {
    static with2Items(): Order {
        const items = [{id: 2, name: 'A very specific item', price: 10, quantity: 2}, {id: 3, name: "Another specific item", price: 300, quantity: 1}];
        return {
            id: 25,
            customerName: "Rich customer",
            items: items,
            total: items.reduce((previousValue, currentValue) => previousValue + (currentValue.price * currentValue.quantity), 0)
        }
    }

    static withoutItems(): Order {
        return {
            id: 25,
            customerName: "Poor customer",
            items: [],
            total: 0
        }
    }

    static johnDoeBuysATable(): Order {
        const items = [{id: 1, name: 'Table', price: 500, quantity: 1}];
        return {
            id: 25,
            customerName: "John Doe",
            items: items,
            total: items.reduce((previousValue, currentValue) => previousValue + (currentValue.price * currentValue.quantity), 0)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Exemple d’utilisation

// test.ts
import {OrderMother} from "./orderMother";

describe("Order Mother", () => {
    it('should create an order with items', () => {
        const order = OrderMother.with2Items();
        expect(order.items).toHaveLength(2)
        expect(order.total).toBe(320)
    });

    it('should create an order without items', () => {
        const order = OrderMother.withoutItems();
        expect(order.items).toHaveLength(0)
        expect(order.total).toBe(0)
    });
});

Enter fullscreen mode Exit fullscreen mode

Avantages

  • Centralisation de la création d'objets.
  • Facilité de maintenance.

Inconvénients

  • Peut devenir complexe pour des objets très variés.
  • Moins flexible pour des variations spécifiques.

Utilisation des Factory Functions

Les Factory Functions sont des fonctions qui retournent des objets. Elles sont simples à utiliser et permettent de créer des objets de manière concise.

Exemples de code pour créer des objets avec des Factory Functions en TypeScript

💡 En TypeScript, l'utilisation du type Partial offre un avantage supplémentaire. Le type Partial permet de créer des objets partiels, ce qui signifie que vous pouvez définir uniquement les propriétés nécessaires pour un test spécifique. Cela rend les tests plus lisibles et plus faciles à maintenir. Vous pouvez déstructurer votre type pour rendre votre test plus lisible et explicite.

type User = {
  id: number;
  name: string;
  email: string;
};

export function createUser({ id = 1, name = 'Jane Doe', email = 'jane.doe@example.com' }: Partial<User>): User {
  return {
    id,
    name,
    email,
  };
}
Enter fullscreen mode Exit fullscreen mode
test('should create a user', () => {
    const user = createUser({ name: 'Jonh Doe', email: 'john.doe@example.com' });
    expect(user).toEqual({
      id: 1,
      name: 'Jonh Doe',
      email: 'john.doe@example.com',
    });
  });
Enter fullscreen mode Exit fullscreen mode

Avantages

  • Simplicité et facilité d'utilisation.
  • Flexibilité pour créer des objets avec des variations spécifiques.

Inconvénients

  • Moins adapté pour des objets très complexes.
  • Peut devenir difficile à gérer pour des ensembles de données importants.

Utilisation des Test Data Builders / Builders

Le pattern Builder permet de construire des objets complexes de manière incrémentale. Il est particulièrement utile pour créer des objets avec des variations spécifiques.

Exemples de code pour créer des objets complexes avec des builders en TypeScript

// userBuilder.ts
export class UserBuilder {
  private user: User = {
    id: 1,
    name: 'John Doe',
    email: 'john.doe@example.com'
  };

  withName(name: string): UserBuilder {
    this.user.name = name;
    return this;
  }

  withEmail(email: string): UserBuilder {
    this.user.email = email;
    return this;
  }

  build(): User {
    return this.user;
  }
}

Enter fullscreen mode Exit fullscreen mode
// test.ts
import { UserBuilder } from './userBuilder';

test('should create a user with custom name', () => {
  const user = new UserBuilder().withName('Jane Doe').build();
  expect(user).toEqual({
    id: 1,
    name: 'Jane Doe',
    email: 'john.doe@example.com'
  });
});

Enter fullscreen mode Exit fullscreen mode

Avec un objet plus complexe:

Les types

// interfaces.ts
export interface Item {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

export interface Order {
  id: number;
  customerName: string;
  items: Item[];
  total: number;
}
Enter fullscreen mode Exit fullscreen mode

Test Data Builder pour Order

// ItemBuilder.ts
import {Item} from "./interface";

export class ItemBuilder {
    private readonly item: Item

    constructor() {
        this.item = {id: 1, name: 'Default Item', price: 100, quantity: 1}
    }

    public withName(name: string): this {
        this.item.name = name
        return this
    }

    public withId(id: number): this {
        this.item.id = id
        return this
    }

    public withPrice(price: number): this {
        this.item.price = price
        return this
    }

    public withQuantity(quantity: number): this {
        this.item.quantity = quantity
        return this
    }

    public build(): Item {
        return this.item
    }
}
Enter fullscreen mode Exit fullscreen mode
import {Item, Order} from "./interface";
import {ItemBuilder} from "./itemBuilder";

export class OrderBuilder {
    private readonly order: Order

    constructor() {
        this.order = {id: 1, items: [new ItemBuilder().withPrice(20).build()], total: 20, customerName: "Alice"}
    }

    public withCustomerName(name: string): this {
        this.order.customerName = name
        return this
    }

    public withItems(...items: Item[]): this {
        this.order.items = items
        this.order.total = items.reduce((previousValue, currentValue) => previousValue + (currentValue.price * currentValue.quantity), 0)
        return this
    }

    public build(): Order {
        return this.order
    }
}
Enter fullscreen mode Exit fullscreen mode

Exemple d’utilisation

describe("Simple test", () => {
    it('should create an order with default builder', () => {
        const order = new OrderBuilder().build()
        expect(order.items).toHaveLength(1)
        expect(order.total).toBe(20)
    });

    it('should create an order with 2 items', () => {
        const order = new OrderBuilder().withItems(
            new ItemBuilder().withPrice(100).build(),
            new ItemBuilder().withPrice(10).withQuantity(5).withId(2).build()
        ).build();
        expect(order.items).toHaveLength(2)
        expect(order.total).toBe(150)
    });

    it("should create a John Doe's order", () => {
        const order = new OrderBuilder().withCustomerName("John Doe").build();
        expect(order.customerName).toBe("John Doe")
    });
});
Enter fullscreen mode Exit fullscreen mode

Avantages

  • Flexibilité pour créer des objets avec des variations spécifiques.
  • Facilité de lecture et de maintenance.

💡 Vous pouvez également créer des builders avec des valeurs par défaut. C'est ce que recommande le livre "Growing Object-Oriented Software, Guided by Tests". Vous aurez ainsi une ou plusieurs méthodes statiques qui vous permettront de configurer vos tests à partir de personas ou de cas métiers spécifiques.

Inconvénients

  • Peut devenir verbeux pour des objets simples.
  • Nécessite une configuration initiale.

Nos préférences pour la création de jeu de données

Pour nous, ce qui est important, c'est d'avoir une série de tests qui soit lisible et facile à écrire. C'est pourquoi nous mettons en avant trois critères pour choisir le pattern que nous voulons utiliser :

  • Visibilité des Variations de Données
  • API Fluide et Lisibilité
  • Simplicité et Flexibilité

C’est pour cela que nous préférons les patterns Factory Function et Test Data Builder.

💡 Nous avons des préférences pour ces patterns, mais parfois nous allons utiliser une fixture ou un object mother, car le nommage métier peut être plus explicite que la simple variation des données, etc.

Pourquoi le pattern Factory Function ?

Pour des objets simples et sans profondeur, le pattern Factory Function est une approche efficace. Elle permet de créer des objets de manière concise.

L'utilisation du type Partial en TypeScript facilite la création d'objets partiels, ce qui est particulièrement utile pour les tests.

Pourquoi le pattern Test Data Builder ?

Le pattern Builder est particulièrement adapté pour les objets avec plusieurs niveaux de profondeur. Il permet de construire des objets complexes de manière incrémentale, en chaînant des méthodes pour définir les différentes propriétés de l'objet. Cette approche offre une grande flexibilité et permet de voir facilement les variations de données, ce qui est crucial pour valider les tests.


Outils et bibliothèques populaires pour la création de jeux de données

faker.js

  • Génération de données fictives réalistes.
  • Idéal pour tester des scénarios avec des données variées.

factory.ts

  • Création structurée d'objets de test.
  • Idéal pour des objets complexes et des variations spécifiques.

faker.js

faker.js est une bibliothèque populaire pour générer des données fictives. Elle permet de créer des données réalistes pour les tests.

// fakerExample.ts
import faker from 'faker';
import type { Email } from "./email.js";

test('should generate a fake user', () => {
  const user = {
    id: 1,
    name: faker.name.findName(),
    email: faker.internet.email()
  };
  expect(user).toEqual({
    id: 1,
    name: expect.any(String),
    email: expect.any(Email)
  });
});

Enter fullscreen mode Exit fullscreen mode

factory.ts

factory.ts est une bibliothèque TypeScript pour créer des objets de test de manière structurée.

// factoryExample.ts
import { Factory } from 'factory.ts';

interface User {
  id: number;
  name: string;
  email: string;
}

const userFactory = Factory.Sync.makeFactory<User>({
  id: Factory.each((i) => i),
  name: 'John Doe',
  email: 'john.doe@example.com'
});

test('should create a user with factory.ts', () => {
  const user = userFactory.build();
  expect(user).toEqual({
    id: expect.any(Number),
    name: 'John Doe',
    email: 'john.doe@example.com'
  });
});

Enter fullscreen mode Exit fullscreen mode

Conclusion

À partir de quoi faire varier les données ?

Nous vous recommandons fortement de faire varier vos données à partir de personas UX ou de cas métiers bien identifiés. Ces éléments définiront vos valeurs par défaut pour la plupart des patterns décrits ci-dessus. L'objectif d'avoir ces valeurs par défaut (personas UX ou cas métiers) est de rendre vos tests lisibles et de les utiliser comme documentation.

Où ranger ces patterns dans le code ?

Ces fichiers servant uniquement aux tests, ils ont leur place à leurs côtés. Vous pouvez l’utiliser à côté du test qui l’utilise, mais s’il y a de la réutilisation ailleurs, il faudra créer un dossier et fichier dédié.

/test
    /order
        /order.ts
    /dataBuilders
        /orderBuilder.ts
Enter fullscreen mode Exit fullscreen mode

La création de jeux de données pour les tests est essentielle pour garantir la qualité et la fiabilité du code. Chaque méthode a ses forces et ses limites, et le choix du bon pattern dépend de la complexité des éléments à générer.

Il n’existe pas de solution universelle : la clé réside dans une réflexion collective et des décisions alignées avec les besoins de votre projet.

Pour aller plus loin

Top comments (0)