Données de test lisibles avec `trait` et `transient` de FactoryBot
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
Notre utilisation avancée de FactoryBot ne s’arrête pas là. Découvrez bientôt comment nous l’utilisons pour fabriquer des réponses d’API externes en JSON.
infoPiiaf