Les test-data factories ont remplacé depuis longtemps les données de test figées dans des fichiers de configuration difficiles à garder cohérents. FactoryBot est une bibliothèque plébiscitée dans le monde Ruby par la légèreté de sa syntaxe, que l’on peut rendre encore plus expressive en combinant traits et attributs transient. Attention, cet article n’est pas une introduction à FactoryBot, loin de là…

Mais mettons-nous en situation. Disons que votre application permet de définir une programmation Schedule d’événements hebdomadaires Event, par exemple tous les mardis de 15h30 à 17h, et les jeudis de 10h30 à 12h. Est appelée occurrence Occurrence l’instance concrète d’une semaine donnée, par exemple le mardi 17 février de 15h30 à 17h pour l’événement du mardi en semaine 2026W08 (la semaine ISO8601 qui va du lundi 16 au dimanche 22 février 2026).

Pour administrer ces événements et occurrences, certaines personnes ont le rôle de gestionnaire Manager sur la programmation, qui peut être limité dans le temps. En effet, une personne gestionnaire en 2025 ne le sera peut-être pas l’année suivante, et ne l’était peut-être pas l’année précédente. Nos tests doivent pouvoir créer facilement des personnes gestionnaires autorisées ou non pour une occurrence donnée.

Version spontanée avec FactoryBot

Partons de ce cas de test :

describe "Cancel an occurrence" do
  let(:manager) { create(:manager) } # FactoryBot crée le compte avec le bon rôle
  let(:occurrence) { build(:occurrence) } # FactoryBot crée toute la chaîne
                                          # de l’occurrence à la programmation

  before do
    ScheduleManagement.create(schedule: occurrence.event.schedule, role: manager,
      enabled_at: management_starts_at, disabled_at: management_ends_at)
  end

  subject { visit_as(manager) }

  describe "when I’m a manager" do
    let(:management_starts_at) { 1.minute.before(occurrence.ends_at) }
    let(:management_ends_at) { nil }

    it { is_expected.to have_css("button", text: "Cancel") }
  end

  describe "when I’m not a manager yet" do
    let(:management_starts_at) { 1.minute.after(occurrence.ends_at) }
    let(:management_ends_at) { nil }

    it { is_expected.not_to have_css("button", text: "Cancel") }
  end

  describe "when I’m not a manager anymore" do
    let(:management_starts_at) { nil }
    let(:management_ends_at) { 1.minute.before(occurrence.starts_at) }

    it { is_expected.not_to have_css("button", text: "Cancel") }
  end
end

Ce test reste assez lisible, même s’il y a encore beaucoup de détails du domaine-métier qui créent plus de bruit que de signal. Sans compter que tout ce bruit sera à répéter dans les autres cas de test, quand nous voudrons vérifier la présence ou non d’actions pour envoyer les courriels de présence, faire l’appel, etc. Nous devons trouver le moyen de rendre ces tests moins verbeux et la logique plus facile à partager.

trait et transient pour nous servir

Nous préférons effectivement lire quelque chose comme cela :

describe "Cancel an occurrence" do
  let(:occurrence) { build(:occurrence) } # FactoryBot crée toute la chaîne
                                          # de l’occurrence à la programmation

  subject { visit_as(manager) }

  describe "when I’m a manager" do
    let(:manager) { create(:manager, :enabled_before, occurrence:) }

    it { is_expected.to have_css("button", text: "Cancel") }
  end

  describe "when I’m not a manager yet" do
    let(:manager) { create(:manager, :enabled_after, occurrence:) }

    it { is_expected.not_to have_css("button", text: "Cancel") }
  end

  describe "when I’m not a manager anymore" do
    let(:manager) { create(:manager, :disabled_before, occurrence:) }

    it { is_expected.not_to have_css("button", text: "Cancel") }
  end
end

Mais quelle est cette sorcellerie ? Ou se cache la magie ? Nous vous le donnons en mille : dans la définition de nos factories.

Premier mécanisme, les attributs transient

Ces attributs transient permettent de passer des arguments à la création de nos factories, comme nous le faisons pour les attributs persistés ou les relations. Ils sont disponibles au sein de la définition de la factory, dans les blocs d’initialisation des attributs persistés ou les callbacks. Et c’est justement dans ces derniers que nous allons nous en servir.

FactoryBot.define do
  factory :manager do
    transient do
      occurrence { nil }
    end

    after(:create) do |role, context|
      if context.occurrence
        create(:schedule_management, role:, schedule: context.occurrence.event.schedule)
      end
    end
  end
end

Désormais, nous pouvons create(:manager, occurrence: some_occurrence) et la relation entre la personne gestionnaire et la programmation dont l’occurrence est issue sera établie automagiquement.

Deuxième mécanisme, les trait

Maintenant que la relation est créée, il va falloir en personnaliser les attributs pour gérer les périodes d’activation/désactivation de la personne gestionnaire. Pour ce faire, nous allons définir des variantes de notre factory à l’aide de trait.

FactoryBot.define do
  factory :manager do
    # […]
    trait :enabled_before do
      management_starts_at { 1.minute.before(occurrence.ends_at) }
    end

    trait :enabled_after do
      management_starts_at { 1.minute.after(occurrence.ends_at) }
    end

    trait :disabled_before do
      management_ends_at { 1.minute.before(occurrence.ends_at) }
    end

    trait :disabled_after do
      management_ends_at { 1.minute.after(occurrence.ends_at) }
    end
    # […]
  end
end

Les attributs management_starts_at et management_ends_at ne sont pas des attributs persistés de Manager, mais bien deux nouveaux attributs transient. Ils nous permettent de nommer une notion importante de cette factory et de mutualiser le code de notre callback. En voici la version finale :

FactoryBot.define do
  factory :manager do
    transient do
      occurrence { nil }
      management_starts_at { nil }
      management_ends_at { nil }
    end

    trait :enabled_before do
      management_starts_at { 1.minute.before(occurrence.ends_at) }
    end

    trait :enabled_after do
      management_starts_at { 1.minute.after(occurrence.ends_at) }
    end

    trait :disabled_before do
      management_ends_at { 1.minute.before(occurrence.ends_at) }
    end

    trait :disabled_after do
      management_ends_at { 1.minute.after(occurrence.ends_at) }
    end

    after(:create) do |role, context|
      if context.occurrence
        create(:schedule_management, role:,
          schedule: context.occurrence.event.schedule,
          enabled_at: context.management_starts_at,
          disabled_at: context.management_ends_at,
        )
      end
    end
  end
end

Une API de test à l’image de votre domaine-métier

Traiter son code de test avec le même soin que son code de production procure les mêmes atouts : plus facile à lire, plus facile à comprendre, plus facile à corriger et faire évoluer. Vous tirez des bénéfices à l’écriture de nouveaux tests, surtout quand votre métier devient plus complexe. Imaginons un test qui vérifie la liste des destinataires d’un courriel de relance ; constatez par vous-même sa limpidité :

describe "Send a reminder" do
  let(:occurrence) { build(:occurrence) }

  let!(:managers) { active_managers + [disabled_before, enabled_after] }
  let(:active_managers) { create_list(:manager, 3, :enabled_before, occurrence:) }
  let(:disabled_before) { create(:manager, :disabled_before, occurrence:) }
  let(:enabled_after) { create(:manager, :enabled_after, occurrence:) }

  subject { send_reminder(occurrence) }

  it { is_expected.to have_been_sent_to(active_managers) }
end