Statut : stable Public cible : contributeur Dernière vérification : 2026-05-01 Sources de vérité :
spec/,bin/test,bin/test_fast,spec/rails_helper.rb,.rspec.
Vocabulaire : voir
../glossary.md. Les exemples de code utilisent le vocabulaire canoniqueContributionFormula/Contribution.
Ce document remplace l'ancien trio docs/TDD_GUIDE.md + docs/TESTING_GUIDE.md + docs/CHANGELOG_TDD_SETUP.md qui s'étaient mis à diverger. Pour la priorisation par zones (Zone 1 / 2 / 3), se référer à ../architecture/models.md.
- Philosophie TDD
- Setup et outillage
- Stratégie de tests (3 types)
- Workflow TDD au quotidien
- Structure et conventions des tests
- Outils et commandes
- Couverture (SimpleCov)
- Gaps connus (snapshot)
- CI/CD
- Troubleshooting
- Red : écrire un test qui échoue.
- Green : écrire le minimum de code pour le faire passer.
- Refactor : améliorer le code en gardant les tests verts.
Tester le comportement métier, PAS l'implémentation.
Mauvais (teste l'implémentation interne) :
it "updates status to active using update!" do
membership.update!(status: :active)
expect(membership.status).to eq("active")
endBon (teste le comportement attendu) :
it "activates a pending membership when payment succeeds" do
membership = create(:membership, status: :pending)
payment = create(:payment, membership: membership, status: :success)
People::PaymentCreator.new(
person: payment.person,
amount_cents: payment.total_cents,
payment_method: payment.payment_method,
recorded_by_id: payment.recorded_by_id,
item_type: "Donation",
item_id: payment.person_id
).call
expect(membership.reload.status).to eq("active")
endrspec-rails— framework de tests.factory_bot_rails— fabriques de données.simplecov— rapport de couverture.shoulda-matchers— matchers concis pour validations / associations.
spec/spec_helper.rb— config RSpec + activation SimpleCov, groupes (Models, Controllers, Services, Helpers, Jobs, Mailers).spec/rails_helper.rb— config Rails + Shoulda Matchers.bin/rspec— wrapper RSpec sérialisé pour SQLite viaflock.
- Seuil minimum CI : 12 % (objectif progressif vers 60 % sur le cœur métier, +5 % par itération majeure).
Règles métier core qui ne changeront jamais.
it "prevents overlapping active memberships" do
person = create(:person)
create(:membership, person: person, status: :active,
started_at: Date.current, ended_at: 1.year.from_now)
overlapping = build(:membership, person: person, status: :active,
started_at: 6.months.from_now, ended_at: 18.months.from_now)
expect(overlapping).not_to be_valid
end
it "requires price_cents to be greater than 0" do
formula = build(:contribution_formula, price_cents: 0)
expect(formula).not_to be_valid
endCes tests sont sacrés : si tu changes la logique métier et qu'ils échouent, stop — tu as cassé une règle immuable.
Les APIs publiques exposées doivent rester stables.
describe "Contract: Response format" do
it "always returns a result object" do
payment = create(:payment)
result = People::PaymentCreator.new(
person: payment.person,
amount_cents: payment.total_cents,
payment_method: payment.payment_method,
recorded_by_id: payment.recorded_by_id,
item_type: "Donation",
item_id: payment.person_id
).call
expect(result).to respond_to(:success?)
expect(result).to respond_to(:failure?)
expect(result).to respond_to(:errors)
end
endDocumentent le comportement actuel, pas l'idéal.
# Comportement ACTUEL : Pack 10 n'expire jamais
it "never expires for pack10 contributions" do
contribution = create(:contribution, :pack10, expires_at: 1.year.ago)
expect(contribution.expired?).to be false
endSi dans 6 mois tu changes cette règle :
- Le test échoue.
- Tu décides : régression ou nouvelle feature ?
- Si nouvelle feature → mettre à jour
../domain/business_logic.md+ le test. - Si régression → rejeter le changement.
Tous les services People::* retournent un objet avec success?, errors, message et un payload spécifique :
result = People::MembershipCreator.new(...).call
expect(result).to respond_to(:success?)
expect(result).to respond_to(:errors)
expect(result).to respond_to(:message)
expect(result).to respond_to(:membership)Les services émettent des événements (payment.created, membership.created, membership.upgraded, contribution.created, etc.) :
captured = []
subscriber = ActiveSupport::Notifications.subscribe("membership.created") do |_n, _s, _f, _i, payload|
captured << payload
end
People::MembershipCreator.new(person: person, membership_type_id: type.id, recorded_by_id: admin.id).call
ActiveSupport::Notifications.unsubscribe(subscriber)
expect(captured).not_to be_empty
expect(captured.first[:person_id]).to eq(person.id)post path, params: params, headers: { "Accept" => "text/vnd.turbo-stream.html" }
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
expect(response.body).to include("turbo-stream")Les contrôleurs admin simplifiés se testent comme des flows intégrés :
Admin::EventsController— création avectitle, mise à jour partielle (viacompact_blank), redirections et notices.Admin::MembershipTypesController—create/update/destroyet validations modèle.Admin::ContributionFormulasController—update/destroyinline ;createdélègue àPeople::ContributionCreator.
- Reproduire le bug dans un test (Red).
- Fixer (Green).
- Refactor.
- Commit.
Avec SQLite, un seul writer peut modifier tmp/test.sqlite3 à la fois. Deux processus RSpec parallèles provoquent des erreurs de type SQLite3::BusyException: database is locked.
Bon usage :
bin/rspec spec/models/payment_line_spec.rb
bin/test
bin/test_fast
bin/test_watchÉviter :
bundle exec rspec ...
bundle exec guarden parallèle d'une autre suite de tests, car cela contourne le lockfile projet.
- Documenter la règle dans
../domain/business_logic.md. - Écrire le test (Red).
- Implémenter (Green).
- Refactor.
- Commit.
- Tests existants échouent (Red).
- Décider : bug ou feature ?
- Si bug → fixer (Green).
- Si feature → mettre à jour tests + doc (Green).
- Refactor.
- Commit.
spec/
models/ # Validations, associations, scopes
requests/ # Tests d'intégration des contrôleurs
admin/
public/
services/ # Service objects
helpers/
factories/
support/ # Helpers partagés
rails_helper.rb
spec_helper.rb
Les controllers/ purs sont dépréciés au profit de requests/.
RSpec.describe User, type: :model do
describe "validations" do
it { should validate_presence_of(:email) }
end
describe "associations" do
it { should have_many(:memberships) }
end
describe "#some_method" do
context "when condition" do
it "returns expected result" do
# ...
end
end
end
endbin/test— suite complète avec couverture.bin/test_fast— models + services uniquement.bin/test_watch— watch mode (requiert Guard, gem optionnelle).
Avant une PR : pas de refactor métier ni renommage de concepts domaine sans accord — voir le glossaire.
bundle exec rubocop --format simple --force-exclusion
bundle exec rspec- CI : le job
lintdans.github/workflows/ci-dev.ymlexécutebin/rubocop --format github --force-exclusion(mêmes règles, format pour les annotations GitHub). - Ciblage :
bundle exec rubocop --only NomDuCop chemins… --force-exclusion
-
Synthèse (compte par cop) :
bundle exec rubocop --format offenses --force-exclusion -
Liste détaillée fichier/ligne (lisible vite) :
bundle exec rubocop --format clang --force-exclusion -
Limiter à un dossier ou un fichier :
bundle exec rubocop app/helpers --force-exclusion bundle exec rubocop app/models/user.rb --force-exclusion
-
Tester un ou plusieurs cops précis (utile avant d’activer quelque chose dans
.rubocop.yml) :bundle exec rubocop app --only Rails/HelperInstanceVariable --force-exclusion -
Prévisualiser un lot de cops sans écrire :
bundle exec rubocop --only NomDuCop chemins... --force-exclusionPuis corriger avec
bundle exec rubocop -a(safe) ou-A(plus agressif — à utiliser avec prudence).
bundle exec rspec
bundle exec rspec --format documentation
bundle exec rspec spec/models/user_spec.rb
bundle exec rspec --tag focus
bundle exec rspec --only-failures
bundle exec rspec --seed 12345FactoryBot.define do
factory :user do
email { "user@example.com" }
password { "password123" }
system_role { :member }
end
enduser = create(:user)
user = create(:user, email: "custom@example.com")
user = build(:user)it { should validate_presence_of(:email) }
it { should validate_uniqueness_of(:email) }
it { should validate_length_of(:password).is_at_least(8) }
it { should have_many(:memberships) }
it { should belong_to(:person) }La couverture dit quelles lignes sont exécutées par les tests, pas si elles sont bien testées. 100 % n'égale pas 100 % de confiance.
Bonnes questions :
- Tous les invariants métier sont-ils couverts ?
- Les edge cases critiques sont-ils couverts ?
- Les chemins d'erreur sont-ils testés ?
bin/test
# ou ciblé :
bundle exec rspec spec/models/contribution_formula_spec.rbRapport : coverage/index.html.
- Ouvrir
coverage/index.html. - Cliquer sur un fichier (ex.
app/models/contribution_formula.rb). - Couleurs :
- Vert : ligne couverte.
- Rouge : ligne jamais exécutée.
- Gris : code mort / non exécutable.
- Minimum CI : 52 % (actuel, suite complète).
- Acceptable : 30–40 %.
- Bon : 50–60 %.
- Excellent : 70 %+ avec qualité.
- Tous les invariants métier testés.
- Tous les edge cases critiques testés.
- Tous les services
People::*testés. - Tous les contrôleurs Zone 1 testés.
Ces listes sont un instantané et doivent être recroisées avec ../internal/todo.md et ../architecture/models.md avant action.
User, Person, Membership, Payment, PaymentLine, MembershipType, Contribution (couverture business complète), Event (basique).
ContributionFormula, AccountClaim, Attendance, AttendanceList, Blog, Tag, TagBlog, PriceCatalog, PriceEntry, PaymentAuditLog, MemberNumberHistory, EventAttendee, Session, UserService.
Admin::UsersController, Admin::PaymentsController, Admin::MembershipsController, Admin::EventsController, Admin::DashboardController, SessionsController, RegistrationsController, CheckoutController.
L'ensemble des services People::*, AccountClaimManagement::*, AttendanceManagement::*, AttendanceListManagement::*, BlogManagement::*, OpeningHoursManagement::*, NewsletterManagement::*, UserManagement::* (Updater, Deleter), EventManagement::*, MemberNumberManagement::*.
OpeningHoursController(mise à jour via cache).- Newsletter (flow authentifié) via
NewsletterManagement::NewsletterUpdater. UserDeleter(suppression / archivage sécurisé).- Observabilité : tests d'événements (
ActiveSupport::Notifications) surPeople::*. - Request specs CRUD inline :
Events,MembershipTypes,ContributionFormulas. - Edge cases modèles (dates, enums, scopes).
-
Baseline :
rubocop-rails-omakasedans.rubocop.ymlcomme seule base héritée ; exclusions projet documentées (permanent / temporaire) + override EzamLayout/EndOfLine: lf. Le projet est en mode RSpec-only et le dossier legacytest/est retire. -
Lot B2 (historique) — le rollout
Rails/*a ete fait par petits lots. Aujourd'hui, conserver la meme approche: petites PR, puisbundle exec rubocop+bundle exec rspecverts. -
Specs et locale :
config.i18n.default_localeest:fr; les messages ActiveRecord /number_to_currencysuiventrails-i18n(fr). Dans les tests de validations, préférerI18n.t('errors.messages.blank'),I18n.t('errors.messages.required'), etc., plutôt que du texte anglais codé en dur ; pour les flashes ou sujets de mail, utiliserI18n.t('…')avec la même clé que l’application. -
Parite FR/EN :
config/locales/en.ymlest maintenu en parite de cles avecconfig/locales/fr.yml. Utiliserbundle exec rake i18n:check_keysavant merge. -
Auth : la stack d'authentification reste le systeme natif Rails 8 ; ne pas introduire Devise.
-
Fallback transitoire :
config.i18n.fallbacks = { en: %i[fr] }est volontairement temporaire pendant la complétion deconfig/locales/en.yml. Objectif long terme : paritéfr/ensans fallback implicite pour l’UX anglaise. -
Parité des clés locales : utiliser
bundle exec rake i18n:check_keyspour lister les clés manquantes entrefr.ymleten.ymlet prioriser les traductions manquantes. -
Jobs :
.github/workflows/ci-dev.yml(lint,security,test) et.github/workflows/ci-auto-lint.yml— RuboCop est bloquant lorsque la baseline est verte (bin/rubocop --format github --force-exclusion). -
Élargissement : activer les cops par petits lots, une PR par lot ; pas de refactors métier ni renommage de vocabulaire domaine sans accord (glossaire).
-
Lots suivants suggérés : après vérif des offenses — LOW (
Style/TrailingCommaInArrayLiteral/HashLiteralsi pertinent), puis MEDIUM (Performance/*,Rails/*au cas par cas), puis HIGH (Lint/*sur flux,Metrics/*, fichiersapp/models/app/servicessensibles) en revue manuelle uniquement. -
Commandes locales : même séquence recommandée qu’avant CI — RuboCop puis RSpec (section 6, Workflow lint local) ; ciblage :
bundle exec rubocop --only NomDuCop chemins… --force-exclusion.
- Suite RSpec complète.
- Génération SimpleCov + check seuil (bloquant si < 12 %).
- Linting + security audit (
bundle audit).
- Trigger après CI réussi sur
dev. - Merge
dev→stagingpuis déploiement automatique.
- Suite complète avant déploiement (pas juste smoke tests).
- Vérification du seuil de couverture.
- Blocage si tests échouent ou couverture trop basse.
- Manuelle via le workflow
04-promote-to-main(staging → main).
- Vérifier en local :
open coverage/index.html. - Ajouter les tests manquants si la suite complète passe sous le seuil.
- Les runs ciblés (
bundle exec rspec spec/models/payment_spec.rb) génèrent un rapport mais n'appliquent pas le seuil global.
- Utiliser
bin/test_fastpendant l'itération. - Ou
bin/test --no-coveragepour gagner ~30 % sur le run. - Optimiser les fabriques (préférer
build_stubbedquand pas de persistence requise).
- Lancer
bin/testaprès chaque feature. - Mettre à jour le badge couverture du
README.mdlors d'augmentations significatives.
../domain/business_logic.md— règles métier.../architecture/services.md— catalogue de services.../architecture/controllers.md— état des contrôleurs.../architecture/models.md— modèles, concerns, zones de stabilité.
risky_bypass(priorité traitée):app/models/user.rb(anonymisation), suivi cibléapp/models/payment.rb.intentional_batch(gardé sous transaction + commentaire explicite):app/services/people/account_merger.rb,app/models/concerns/versionable.rb,app/models/concerns/duplicatable.rb.legacy/archive(exclusion ciblée possible):docs/rake_archive/migrate_to_person_architecture.rake.consolidated_path: la fusion applicative doit passer parPeople::AccountMerger(éviter les duplications de logique dans d'autres services).identity_invariant: en cible projet, unUserest toujours rattaché à unePerson(phase de verrouillage en cours).