Dans la plupart des grands monolithes Rails, les organisations privilégient le développement de nouvelles fonctionnalités plutôt que l'écriture des tests correspondants. Avec le temps, de plus en plus de code reste non testé, ce qui oblige les équipes à passer davantage de temps à corriger des bugs pénibles.
Nous avons construit un agent autonome qui comble cet écart. Il lit les fichiers source Rails, génère ou améliore des tests RSpec, les valide par rapport aux règles de style et aux objectifs de couverture, puis s'exécute dans un pipeline CI/CD sans intervention humaine. Pour travailler sur des bases de code de cette taille, il s'exécute en parallèle : plusieurs instances travaillent simultanément sur différents fichiers.
RSpec vu par un agent
Ruby est typé dynamiquement : il n'y a pas d'étape de compilation, les erreurs apparaissent donc à l'exécution. Pour notre agent, cela signifie que le seul moyen de vérifier la syntaxe d'un test est de l'exécuter. RSpec, le framework de test standard de Rails, rend les tests expressifs et lisibles, mais son langage propre au domaine est facile à mal utiliser.
Lorsque l'agent lit une base de code Ruby on Rails, il lit cinq grands types de fichiers (models, serializers, controllers, mailers, helpers), chacun structuré différemment, et donc testé de manière différente. L'agent a besoin d'instructions distinctes pour chaque type.
Un avantage : la correspondance entre fichier source et fichier spec est presque de 1:1. La convention générale est la suivante :

Il existe toutefois quelques exceptions à cette règle, par exemple lorsque app/controllers/ est parfois associé à spec/requests/, ou lorsqu'un même fichier source peut avoir plusieurs fichiers spec. Dans ce cas, la convention est la suivante :

Cette correspondance directe permet de trouver facilement les tests d'un fichier donné, ou d'identifier les fichiers qui n'ont aucun test.
La difficulté, pour notre agent, vient du fait que RSpec s'appuie fortement sur du contexte partagé pour éviter de dupliquer le code : factories, fixtures, schémas de base de données...
Factories : modèles réutilisables pour créer des objets de test avec des attributs prédéfinis, ce qui permet de générer facilement des données de test cohérentes.
Fixtures : fichiers de données statiques qui préchargent des enregistrements dans la base de données de test et fournissent une base fixe pour les tests.
Si un fichier de factory n'existe pas, l'agent le crée ; s'il existe, l'agent le réutilise. Comme les factories sont partagées entre de nombreux tests, contrairement aux fichiers spec, des modifications imprudentes peuvent facilement casser des tests ailleurs. Ces fichiers doivent donc être mis à jour avec prudence.
Construire l'agent avec Vibe
Nous avons construit l'agent au-dessus de Vibe, l'assistant de codage open-source de Mistral. Le prompt système par défaut suffisait pour ce Projet ; nous nous sommes donc concentrés sur trois leviers : le contexte au niveau du dépôt, des Skills spécialisés et des outils personnalisés.
Ingénierie du Contexte
L'ingénierie du Contexte était centrale dans notre approche. Vibe prend en charge un fichier AGENTS.md au niveau du dépôt : lorsqu'il s'exécute sur un dépôt qui contient ce fichier à sa racine, son contenu est automatiquement ajouté au prompt système.
Le fichier AGENTS.md que nous avons utilisé fournissait des détails de base sur les dépôts cibles, mais surtout, il donnait à l'agent un plan d'exécution étape par étape :
1. Read the source file2. Read the documentation (if it exists)3. Check if a spec already exists4. Choose and read exactly one skill based on the source file location5. Find existing patterns, factories, and helpers6. Execute the skill (Extract → Factory → Generate tests)7. Validate with Rubocop tool8. Validate with SimpleCov toolChaque étape précisait ce qu'il fallait faire et quels étaient les critères de réussite. Nous avons aussi inclus quelques bonnes pratiques RSpec sur les points où il nous semblait important d'orienter l'agent. Exemple :
- **NEVER** use `be_present`, `be_truthy`, `be_between`, or `include(:key)`These are vague. Use `eq(exact_value)` alwaysNous avons constaté que l'agent ignorait parfois des méthodes ou laissait des cas limites sans test : il générait une spec qui semblait complète, mais ignorait discrètement quelques méthodes publiques du fichier source. Pour éviter cela, AGENTS.md se termine par une auto-relecture imposée : l'agent doit relire le fichier source et se demander explicitement « Ai-je testé chaque méthode publique ? Compte-les. » avant de terminer. S'il manque quelque chose, il revient en arrière.
Avec ce fichier AGENTS.md générique qui oblige l'agent à suivre une planification stricte, notre score de qualité est passé de 0,68 à 0,74, uniquement grâce à un fichier Markdown contenant des instructions au niveau du framework.
Utiliser des fichiers SKILLS :
Reprenons l'étape 4 de notre fichier AGENTS.md :
4. Choisir et lire exactement un Skill selon l'emplacement du fichier source
Un seul Skill générique donnerait des résultats médiocres : les instructions suffisamment précises pour tester un fichier de modèle ne sont pas les bonnes pour un fichier de controller.
Ce qui a fonctionné : créer un fichier Skills distinct pour chaque catégorie, plus un autre pour les fichiers Ruby simples.
Exemple de fichier Skills de base pour tester des controllers :
---name: "Generate Request Spec"description: "Generate RSpec request tests for a Rails controller. Use when the source file is in app/controllers/."---
# Generate Request Spec
## File Scope
- `spec/requests//_spec.rb` — drop `_controller` from the filename- `spec/factories/.rb` — create or update if needed
## Example tests for Controllers
# frozen_string_literal: truerequire 'rails_helper'
describe 'Admin::Users', type: :request do let(:user) { create(:user, :admin) } before { sign_in user }
# Unauthorized access — one test per action describe '#authorized?' do let(:user) { create(:user) } it 'GET /admin/users redirects' do get '/admin/users' expect(response).to have_http_status(:redirect) end end
# Each action: happy + sad paths describe 'POST /admin/users' do let(:valid_params) { { user: attributes_for(:user) } } let(:invalid_params) { { user: { email: '' } } }
context 'with valid params' do it 'creates a record' do expect { post '/admin/users', params: valid_params }.to change(User, :count).by(1) expect(response).to have_http_status(:created) end end
context 'with invalid params' do it 'returns unprocessable entity with errors' do post '/admin/users', params: invalid_params expect(response).to have_http_status(:unprocessable_entity) json = JSON.parse(response.body, symbolize_names: true) expect(json[:errors]).to include("Email can't be blank") end end endend
## Critical Rules
- **Assert content, not just status:** always parse JSON and verify exact values- **Test exact error messages:** `include("Email can't be blank")`, not `be_present`- **Verify state changes:** use `.by(1)` and check the created record's attributes- **Cover all actions:** index, show, create, update, destroy, and any custom actions- **Test auth on every action:** verify unauthorized users get the correct status code- Use `let` only, never `@instance_variables`; `sign_in user` again before `follow_redirect!`Ajouter des outils personnalisés
Vibe intègre des outils pour lire et écrire des fichiers, exécuter des commandes bash et modifier du code. Il prend aussi en charge des outils personnalisés et des MCP pour étendre son espace d'action. Pour ce Projet, les outils personnalisés ont été essentiels.
Après quelques essais, nous avons retenu :
Un outil de lint RuboCop qui exécute la commande
rubocoppour détecter les violations de style, afin que l'agent revienne au code et les corrige.Un outil SimpleCov intégré à RSpec, qui vérifie à la fois la couverture du code et la validité des tests. Le placer en dernière étape était essentiel : cela garantit que chaque test écrit par l'agent s'exécute réellement.
Ci-dessous, une version simplifiée de notre implémentation de l'outil de lint Rubocop :
import asynciofrom pathlib import Path
from pydantic import BaseModelfrom vibe.core.tools.base import BaseTool, ToolError
class RubocopLintResult(BaseModel): command: str output: str success: bool exit_code: int | None = None
class RubocopLint(BaseTool): description = "Lint spec files using Rubocop."
async def run(self, spec_path: str): spec_path = Path(spec_path)
cmd = ["rubocop", "--format", "simple", str(spec_path)] proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE )
try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60) except TimeoutError: proc.kill() await proc.wait() raise ToolError("Rubocop timed out after 60s")
output = stdout.decode("utf-8", errors="ignore")
yield RubocopLintResult( command=" ".join(cmd), output=output, success=proc.returncode == 0, exit_code=proc.returncode )L'outil SimpleCov suit le même schéma, mais va plus loin : il exécute le fichier spec avec RSpec, collecte les résultats réussite/échec pour chaque test et indique le pourcentage de couverture du fichier source. L'agent reçoit les tests qui ont échoué, avec les messages d'erreur, ainsi que les lignes du fichier source qui n'ont jamais été exercées. Cela lui donne assez d'informations pour s'autocorriger si nécessaire.
Lorsque nous avons intégré cet outil pour la première fois, seuls environ un tiers des tests générés réussissaient à la première exécution. L'agent a corrigé lui-même tous les échecs en quelques itérations. Sans cette étape, certains de ces tests ne se seraient jamais exécutés.
Mesurer la qualité des tests
Un bon test unitaire suit le schéma Arrange-Act-Assert : préparer l'état, exécuter un comportement, vérifier le résultat. Il doit utiliser des assertions précises, couvrir les chemins nominaux et les chemins d'erreur, et produire des échecs qui indiquent clairement ce qui a cassé.

Métriques fondées sur les outils
Les outils Ruby on Rails existants nous permettent d'obtenir facilement plusieurs métriques :
RSpec : le framework de test le plus populaire pour Ruby. Une syntaxe expressive pour écrire des tests lisibles et bien structurés.
RuboCop : un analyseur statique et formateur de code Ruby, qui aide à appliquer les règles de style et à garder un code cohérent
SimpleCov : un outil de couverture de code pour Ruby, qui mesure la quantité de code exécutée par la suite de tests.
À partir de ces outils, nous pouvons extraire plusieurs métriques quantitatives utiles : la proportion de tests qui réussissent, le nombre de violations RuboCop par fichier, la proportion de code couverte par la suite de tests unitaires et la vitesse d'exécution. Ces métriques fournissent une base solide pour mesurer la performance globale d'une suite de tests.
Mais ces chiffres ne suffisent pas à déterminer si un fichier de test est réellement bon. Comment comparer deux fichiers qui n'ont tous deux aucune violation RuboCop, une couverture de 100 % et tous leurs tests en réussite ? Les métriques quantitatives peuvent sembler identiques, alors que des différences qualitatives de qualité de test existent encore.
LLM-as-a-judge
L'idée derrière LLM-as-a-judge consiste à demander à un LLM : « Toutes les conditions d'erreur sont-elles testées ? » ou « Est-ce un bon test ? », puis à obtenir un score de 0 à 1 selon des critères clairs.
Exemple de prompt :
## Good Tests
Precise values: `eq(100)`, not `be_present` or `be_a(Float)`.State changes: `change { User.count }.by(1)`.Error paths: `raise_error(ArgumentError, "message")`.Boundary cases: Test exact values at thresholds (e.g., `page(0)`).Public interface: No `.send(:private_method)`.
## Bad Tests
Vague assertions: `be_truthy`, `be_an(Array)`."Doesn't crash" tests: `not_to raise_error`.Testing private methods.Over-mocking (tests mocks, not behavior).No assertions on return values.
## Scoring Rules
- 1.0: Precise assertions on return values/state + all key paths covered (happy, error, boundaries).- 0.8: Precise assertions on all tested paths, but 1-2 edge cases missing entirely.- 0.6: All key paths covered, but 1-2 tests use vague assertions (e.g., `be_truthy` instead of `eq(expected)`) or minor over-mocking.- 0.4: Some tests have precise assertions; others are vague, over-mocked, or skip return value checks.- 0.2: Most tests lack meaningful assertions: vague matchers, stubbed-out logic, no verification of actual output.- 0.0: Tests are structurally broken: testing private methods, no real assertions, or verifying mocks instead of behavior.La section « Scoring Rules » est particulièrement importante ici, car elle rend le score plus cohérent d'une exécution à l'autre. Sans elle, l'agent n'aurait pas de définition claire du niveau d'exigence ou de tolérance de la notation.
Un tel score est moins fiable que la sortie d'un outil ou qu'une relecture approfondie par un développeur. Mais il donne une bonne indication de la performance de la version actuelle de notre agent, en particulier lorsqu'il est exécuté à grande échelle sur chaque fichier d'un dépôt.
Limites de l'évaluation LLM-as-a-judge :
Tout ce qui peut être représenté sous forme de texte peut être noté avec LLM-as-a-judge, ce qui rend l'approche polyvalente. Mais le score produit n'est pas déterministe. Même avec temperature=0, il existe une variance due à l'ordre des opérations du kernel. En pratique, les sorties d'une même évaluation restent toujours proches, mais ce manque de déterminisme peut poser problème pour certains cas d'usage.
Dans notre cas d'usage, comme nous ne notons pas un seul test mais généralement des dizaines à des centaines à la fois, cette limite était acceptable. Le score agrégé restait cohérent.
Le problème de la parenthèse manquante
LLM-as-a-judge posait un autre problème central : un test peut être de grande qualité, mais ne pas s'exécuter, ou reposer sur une logique entièrement incorrecte.
Exemple :
RSpec.describe User, "#full_name" do it "combines first and last name with a space" do user = User.new(first_name: "Ada", last_name: "Lovelace"
expect(user.full_name).to eq("Ada Lovelace") endendCe test semble excellent. Il teste un comportement, possède une description claire qui se lit comme de la documentation, suit Arrange-Act-Assert :, et échouerait avec un message utile en cas de rupture.
Nous avons soumis ce test à plusieurs modèles avec le prompt de notation défini plus haut. Le score moyen obtenu était de 0,75. Un développeur qui le relirait rapidement serait probablement d'accord.
Mais ce test présente un défaut central : il ne s'exécutera pas. Tout cela à cause d'une parenthèse fermante manquante ) sur User.new(). C'est un défaut critique qui rend ce test clairement inadapté.
Cet exemple est facile à corriger. Mais beaucoup d'autres possibilités peuvent provoquer une erreur de syntaxe :
Erreurs de version : utilisation de méthodes ou d'une syntaxe issues d'une version incompatible de Ruby ou Rails
Méthodes inexistantes : certaines méthodes peuvent avoir été supprimées, renommées ou n'avoir jamais existé.
Erreurs de factory/fixture : référence à des factories manquantes, à des attributs incorrects ou à des associations manquantes
Dépendances manquantes : utilisation de gems ou de modules non inclus dans l'environnement de test
L'Experiment
Nous avons mené une Experiment pour vérifier que l'agent tenait face à une vraie base de code. Le dépôt cible contenait 275 fichiers source. La moitié disposait déjà d'une couverture de test. L'autre moitié n'en avait aucune.
Nous avons lancé l'agent sur chaque fichier, qu'il dispose déjà de tests ou non. Pour les fichiers non couverts, il a généré des specs de zéro avec succès. Pour les fichiers qui avaient déjà des tests, il les a réécrits et améliorés.
Nous avons noté chaque fichier de spec avant et après avec LLM-as-a-judge. Le score agrégé des fichiers testés est passé de 0,49 à 0,74, et la couverture a atteint 100 %. Des améliorations restent possibles, mais les résultats ont validé l’approche.
Métrique | Résultat |
|---|---|
Fichiers traités | 275 |
Tests réussis | 100 % |
Couverture de lignes moyenne (SimpleCov) | 100 % |
Violations RuboCop après autocorrection | 0 |
Score LLM-as-a-judge | 0,74 |
Par type de fichier
Tous les types de fichiers n’étaient pas aussi faciles à tester. Les modèles ont obtenu les meilleurs scores : leur logique métier est autonome et les schémas de test sont prévisibles. Les contrôleurs étaient plus difficiles : la gestion des requêtes HTTP introduisait plus de marge d’erreur.
Type de fichier | LLM-as-a-judge |
|---|---|
Modèles | 0,81 |
Contrôleurs | 0,67 |
Sérialiseurs | 0,80 |
L’exécution fonctionne-t-elle ?
L’ajout de SimpleCov à l’exécution RSpec comme étape finale, en obligeant l’agent à exécuter chaque test qu’il avait écrit, a été la décision la plus déterminante. Seul un tiers des tests réussissait à la première exécution. L’agent a corrigé lui-même tous les échecs en quelques itérations.
Sans cette étape, LLM-as-a-judge aurait attribué une note élevée à ces tests qui ne s’exécutaient pas : le problème de la parenthèse manquante à grande échelle.
Vibe est open source. L’agent, les outils et les skills décrits ici s’exécutent tous au-dessus de Vibe. Essayez-le maintenant.




