Ricerca

Testing Rails con autopilot: creare un agent che scrive ciò che gli sviluppatori non scrivono

March 11, 2026

By Maxime Langelier & Mathis Grosmaitre - Applied AI - Proto team

Nella maggior parte dei grandi monoliti Rails, le organizzazioni danno priorità allo sviluppo di nuove funzionalità rispetto alla scrittura dei relativi test. Nel tempo, sempre più codice resta non testato, costringendo i team a dedicare più tempo al debug di bug complessi.

Abbiamo creato un agente autonomo che colma questo divario. Legge i file sorgente Rails, genera o migliora i test RSpec, li convalida rispetto alle regole di stile e agli obiettivi di copertura, ed esegue tutto all’interno di una pipeline CI/CD senza intervento umano. Per operare su codebase di questa scala, lavora in parallelo: più istanze intervengono simultaneamente su file diversi.

RSpec visto dagli occhi di un agente

Ruby è tipizzato dinamicamente: non esiste una fase di compilazione, quindi gli errori emergono a runtime. Per il nostro agente, questo significa che l’unico modo per verificare la sintassi dei test è eseguirli. RSpec, il framework di test standard per Rails, rende i test espressivi e leggibili, ma il suo linguaggio specifico di dominio è facile da usare in modo errato.

Quando l’agente legge una codebase Ruby on Rails, analizza cinque tipi principali di file (model, serializer, controller, mailer, helper), ciascuno strutturato in modo diverso e quindi testato in modo diverso. L’agente richiede istruzioni distinte per ogni tipo.

Un vantaggio: la mappatura dal file sorgente al file spec è quasi 1:1. La convenzione generale è:

Standard Mapping

Esistono tuttavia alcune eccezioni a questa regola: ad esempio app/controllers/ viene talvolta mappato a spec/requests/, oppure un singolo file sorgente può avere più file spec; in quel caso la convenzione è:

Edge Cases

Questa mappatura lineare rende semplice individuare i test per qualsiasi file specifico, oppure identificare i file completamente privi di test.

La parte più complessa per il nostro agente è che, per evitare duplicazioni di codice, RSpec fa largo uso di contesto condiviso: factory, fixture, schemi di database...

  • Factory: modelli riutilizzabili per creare oggetti di test con attributi predefiniti, semplificando la generazione di dati di test coerenti.

  • Fixture: file di dati statici che precaricano record nel database di test, fornendo una baseline fissa per i test.

Se un file di factory non esiste, l’agente lo crea; se esiste, lo riutilizza. Poiché le factory sono condivise tra molti test, a differenza dei file spec, modifiche poco attente possono facilmente interrompere test altrove; per questo gli aggiornamenti a questi file devono essere eseguiti con cautela.

Costruire l’agente con Vibe

Abbiamo costruito l’agente sopra Vibe, l’assistente di coding open-source di Mistral. Il prompt di sistema predefinito era sufficiente per questo progetto, quindi ci siamo concentrati su tre leve: contesto a livello di repository, skill specializzati e tool personalizzati.

Context engineering

Il context engineering è stato centrale nel nostro approccio. Vibe supporta un file AGENTS.md a livello di repository: quando viene eseguito su un repository con questo file nella root, il suo contenuto viene automaticamente aggiunto al prompt di sistema.

Il file AGENTS.md che abbiamo usato forniva dettagli di base sui repository target, ma soprattutto forniva all’agente un piano di esecuzione passo per passo:

1. Read the source file
2. Read the documentation (if it exists)
3. Check if a spec already exists
4. Choose and read exactly one skill based on the source file location
5. Find existing patterns, factories, and helpers
6. Execute the skill (Extract → Factory → Generate tests)
7. Validate with Rubocop tool
8. Validate with SimpleCov tool

Ogni passaggio includeva dettagli su cosa fare e sui criteri di successo. Abbiamo incluso anche alcune best practice di RSpec nelle aree in cui ritenevamo importante orientare l’agente. Esempio:

- **NEVER** use `be_present`, `be_truthy`, `be_between`, or `include(:key)`
These are vague. Use `eq(exact_value)` always

Abbiamo rilevato che l’agente talvolta saltava metodi o lasciava edge case non testati: generava una spec che sembrava completa, ma ignorava silenziosamente alcuni metodi pubblici del file sorgente. Per contrastare questo comportamento, AGENTS.md termina con un’autorevisione obbligatoria: l’agente deve rileggere il file sorgente e chiedersi esplicitamente "Ho testato ogni metodo pubblico? Contali." prima di concludere. Se manca qualcosa, torna indietro.

Con questo file AGENTS.md generico che obbligava l’agente a seguire una pianificazione rigorosa, il nostro punteggio di qualità è passato da 0,68 a 0,74, tutto grazie a un singolo file markdown con istruzioni a livello di framework.

Uso dei file SKILLS:

Riprendiamo il passaggio 4 del nostro AGENTS.md:

4. Scegliere e leggere esattamente uno skill in base alla posizione del file sorgente

Un singolo skill generico produrrebbe risultati mediocri: le istruzioni abbastanza precise per testare un file model sono istruzioni sbagliate per un file controller.

La soluzione efficace è stata creare un file skill separato per ogni categoria, più uno per i file Ruby semplici.

Ecco un esempio di file skill di base per testare i controller:

---
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: true
require '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
end
end
## 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!`

Aggiunta di tool personalizzati

Vibe include tool integrati per leggere e scrivere file, eseguire comandi bash e modificare codice. Supporta però anche tool personalizzati e MCP per ampliare il proprio spazio d’azione. Per questo progetto, i tool personalizzati sono stati fondamentali.

Dopo alcune sperimentazioni, abbiamo scelto:

  • Un tool di linting RuboCop che esegue il comando rubocop per rilevare violazioni di stile, così che l’agente torni sul codice e le corregga.

  • Un tool SimpleCov integrato con RSpec che verifica sia la copertura del codice sia la correttezza dei test. Collocarlo come passaggio finale è stato essenziale: garantisce che ogni test scritto dall’agente venga effettivamente eseguito.

Di seguito una versione semplificata di come abbiamo implementato il tool di linting Rubocop:

import asyncio
from pathlib import Path
from pydantic import BaseModel
from 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
)

Il tool SimpleCov segue lo stesso pattern, ma fa di più: esegue il file spec con RSpec, raccoglie i risultati pass/fail per ogni test e riporta la percentuale di copertura del file sorgente. L’agente riceve quali test sono falliti, con i relativi messaggi di errore, e quali righe del file sorgente non sono mai state esercitate. Questo gli offre elementi sufficienti per autocorreggersi quando necessario.

Quando abbiamo collegato questo tool per la prima volta, solo circa un terzo dei test generati superava la prima esecuzione. L’agente ha corretto autonomamente tutti gli errori entro poche iterazioni. Senza questo passaggio, alcuni di quei test non sarebbero stati eseguiti.


Misurare la qualità dei test

Un buon unit test segue il pattern Arrange-Act-Assert: preparare lo stato, eseguire un comportamento, verificare il risultato. Dovrebbe usare asserzioni precise, coprire sia i percorsi positivi sia quelli di errore e produrre failure che identifichino chiaramente ciò che si è rotto.

Good Testing

Metriche basate sui tool

Gli strumenti esistenti per Ruby on Rails ci permettono di ottenere facilmente diverse metriche:

  • RSpec: il framework di test più popolare per Ruby. Sintassi espressiva per scrivere test leggibili e ben strutturati.

  • RuboCop: un analizzatore statico e formatter di codice Ruby, che aiuta ad applicare linee guida di stile e a mantenere il codice coerente

  • SimpleCov: un tool di code coverage per Ruby, che misura quanto codice viene eseguito dalla test suite.

Da questi tool possiamo estrarre diverse metriche quantitative utili: la percentuale di test che passano, il numero di violazioni RuboCop per file, la percentuale di codice coperta dalla suite di unit test e la velocità di esecuzione. Queste metriche forniscono una baseline solida per misurare le prestazioni complessive di una test suite.

Ma questi numeri da soli non possono determinare se un file di test sia effettivamente valido. Come dovremmo confrontare due file che hanno entrambi zero violazioni RuboCop, copertura al 100% e tutti i test superati? Le metriche quantitative possono apparire identiche, ma possono comunque esistere differenze qualitative nella qualità dei test.

LLM-as-a-judge

L’idea alla base di LLM-as-a-judge è chiedere a un LLM: “Tutte le condizioni di errore sono testate?” oppure “Questo è un buon test?” e ottenere un punteggio da 0 a 1 basato su criteri chiari.

Prompt di esempio:

## 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 sezione "Scoring Rules" qui è particolarmente importante, perché impone una maggiore coerenza del punteggio tra le esecuzioni. Senza di essa, all’agente mancherebbe una definizione chiara di quanto debba essere rigoroso o permissivo lo scoring.

Un punteggio di questo tipo è meno autorevole dell’output di un tool o di una revisione approfondita da parte di uno sviluppatore. È però una buona indicazione di quanto bene stia lavorando la versione attuale del nostro agente, soprattutto quando viene eseguita su larga scala su ogni file di un repository.

Limitazioni della valutazione LLM-as-a-judge:

Qualsiasi cosa possa essere espressa in testo può essere valutata con LLM-as-a-judge, il che lo rende versatile. Tuttavia, l’output del punteggio non è deterministico. Anche con temperature=0, ci sarà varianza dovuta all’ordine delle operazioni del kernel. In pratica, gli output per la stessa valutazione sono sempre vicini, ma questa mancanza di determinismo può essere un problema per alcuni casi d’uso.

Per il nostro caso d’uso, dato che non valutiamo un singolo test ma solitamente decine o centinaia alla volta, questa limitazione era accettabile. Il punteggio aggregato è rimasto coerente.

Il problema della parentesi mancante

Esisteva un altro problema fondamentale con LLM-as-a-judge: un test può essere di grande qualità ma non essere eseguito, oppure avere una logica sottostante completamente errata.

Esempio:

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")
end
end

Questo test sembra ottimo. Testa un comportamento, ha una descrizione chiara che si legge come documentazione, segue Arrange-Act-Assert: e fallirebbe con un messaggio utile se si rompesse.

Abbiamo eseguito questo test su più modelli usando il prompt di scoring definito in precedenza. Il punteggio medio assegnato è stato 0,75. Una persona sviluppatrice che lo esaminasse rapidamente probabilmente sarebbe d’accordo.

Ma questo test ha un difetto fondamentale: non verrà eseguito. Tutto a causa di una parentesi di chiusura mancante ) in User.new(). È un difetto critico che rende questo test decisamente non idoneo.

Questo esempio è facile da mitigare. Ma ci sono molte altre possibilità che possono portare a un errore di sintassi:

  • Errori di versioning: uso di metodi o sintassi di una versione Ruby o Rails incompatibile

  • Metodi inesistenti: alcuni metodi potrebbero essere stati rimossi, rinominati o non essere mai esistiti.

  • Errori di factory/fixture: riferimento a factory mancanti, attributi errati o associazioni mancanti

  • Dipendenze mancanti: uso di gem o moduli non inclusi nell’ambiente di test

L’esperimento

Abbiamo condotto un esperimento per verificare che l’agente potesse reggere il confronto con una codebase reale. Il repository target conteneva 275 file sorgente. Metà di essi aveva già copertura di test. L’altra metà non ne aveva alcuna.

Abbiamo indirizzato l’agente su ogni file, che avesse già test o meno. Per i file non coperti, ha generato spec da zero con successo. Per i file che avevano già test, li ha riscritti e migliorati.

Abbiamo valutato ogni file di spec prima e dopo usando LLM-as-a-judge. Il punteggio aggregato per i file testati è passato da 0,49 a 0,74 e la coverage ha raggiunto il 100%. C’è ancora margine di miglioramento, ma i risultati hanno convalidato l’approccio.

Metrica

Risultato

File elaborati

275

Test superati

100%

Coverage media delle righe (SimpleCov)

100%

Violazioni RuboCop dopo l’auto-correzione

0

Punteggio LLM-as-a-judge

0,74

Per tipo di file

Non tutti i tipi di file erano facili da testare. I Model hanno ottenuto il punteggio più alto: la loro logica di business è autonoma e i pattern di test sono prevedibili. I Controller sono stati più difficili: la gestione delle richieste HTTP ha introdotto più margine di errore.

Tipo di file

LLM-as-a-judge

Model

0,81

Controller

0,67

Serializer

0,80

Viene eseguito?

Integrare SimpleCov nell’esecuzione di RSpec come passaggio finale, obbligando l’agent a eseguire ogni test scritto, è stata la decisione di maggior impatto. Solo un terzo dei test è stato superato alla prima esecuzione. L’agent ha corretto autonomamente tutti gli errori in poche iterazioni.

Senza questo passaggio, LLM-as-a-judge avrebbe valutato come di alta qualità questi test non eseguibili: il problema della parentesi mancante su larga scala.


Vibe è open source. L’agent, gli strumenti e le skill descritti qui vengono eseguiti tutti su Vibe. Lo provi ora.