Warum Coverage-Tracking in Rails-Projekten scheitert
Die meisten Rails-Teams installieren SimpleCov, sehen 85% Coverage und denken, alles sei gut getestet. Das ist ein Trugschluss. Coverage misst, welche Zeilen ausgeführt wurden, nicht ob die Tests tatsächlich etwas prüfen. Eine Test-Suite mit 95% Line Coverage und null Assertions ist technisch möglich und komplett wertlos.
SimpleCov ist trotzdem das beste Werkzeug, um Coverage in Ruby-Projekten zu messen. Der Schlüssel liegt in der richtigen Konfiguration und der ehrlichen Interpretation der Zahlen.
Installation: Die drei Minuten, die jeder falsch macht
Gemfile und die require-Falle
# Gemfile
group :test do
gem 'simplecov', require: false
end
require: false ist nicht optional. Ohne diesen Parameter lädt Bundler SimpleCov beim Applikationsstart. Das führt dazu, dass Code, der vor dem Test-Runner geladen wird, nicht erfasst wird. Die Coverage-Zahlen sind dann zu niedrig, ohne dass es einen Hinweis darauf gibt.
bundle install
Der häufigste Konfigurationsfehler
SimpleCov muss vor allem anderen Code geladen werden. Das bedeutet: Zeile 1 in spec_helper.rb bzw. test_helper.rb.
RSpec:
# spec/spec_helper.rb - ERSTE Zeilen
require 'simplecov'
SimpleCov.start 'rails'
# Erst danach:
require 'rails_helper'
Minitest:
# test/test_helper.rb - ERSTE Zeilen
require 'simplecov'
SimpleCov.start 'rails'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
Warum scheitert das so oft? Weil Entwickler SimpleCov.start nach require 'rails_helper' platzieren. Rails lädt dann die gesamte Applikation, bevor SimpleCov das Coverage-Tracking startet. Ergebnis: Models und Services zeigen 0% Coverage, obwohl Tests existieren.
Spring: Der stille Saboteur
Rails nutzt Spring als Preloader. Spring hält die Applikation zwischen Test-Runs im Speicher. Wenn SimpleCov nicht vor Spring initialisiert wird, fehlen Coverage-Daten für den gesamten vorgeladenen Code.
Pragmatische Lösung: Spring in der CI-Umgebung deaktivieren.
# In spec_helper.rb
require 'simplecov'
SimpleCov.start 'rails' unless ENV['SPRING_TMP_PATH']
Oder besser: Spring komplett deaktivieren, wenn Coverage gemessen wird.
DISABLE_SPRING=1 bundle exec rspec
Produktionsreife Konfiguration
Das Rails-Profil richtig nutzen
SimpleCov.start 'rails' gruppiert Coverage-Daten automatisch nach Rails-Konventionen: Controllers, Models, Helpers, Mailers, Jobs. Das reicht für kleine Projekte. Für alles andere braucht es eine angepasste Konfiguration.
# spec/spec_helper.rb
require 'simplecov'
SimpleCov.start 'rails' do
# Eigene Gruppen für typische Rails-Projektstruktur
add_group 'Services', 'app/services'
add_group 'Policies', 'app/policies'
add_group 'Serializers', 'app/serializers'
add_group 'Forms', 'app/forms'
add_group 'Queries', 'app/queries'
# Ausschlüsse
add_filter 'vendor'
add_filter 'node_modules'
add_filter %r{^/config/}
add_filter %r{\.rake$}
add_filter 'app/channels' # Nur wenn Action Cable nicht genutzt wird
add_filter 'app/admin' # ActiveAdmin-Views verzerren die Metriken
# Schwellenwerte
minimum_coverage line: 80, branch: 60
# Bericht-Verzeichnis
coverage_dir 'coverage'
end
Was gehört in die Filter und was nicht?
| Verzeichnis | Filtern? | Begründung |
|---|---|---|
app/models | Nein | Kernlogik, muss getestet sein |
app/services | Nein | Business-Logik gehört getestet |
app/controllers | Nein | Request-Handling braucht Tests |
app/views | Ja | Wird indirekt durch System-Tests abgedeckt |
app/helpers | Kommt darauf an | Einfache Formatter filtern, komplexe Logik testen |
config/ | Ja | Konfiguration, keine Logik |
db/migrate | Ja | Migrations werden nicht direkt getestet |
vendor/ | Ja | Fremdcode |
Zeilen- vs. Verzweigungsabdeckung
Line Coverage beantwortet: “Wurde diese Zeile ausgeführt?” Branch Coverage beantwortet: “Wurden alle Pfade durch diese Bedingung getestet?”
def process_payment(amount)
if amount > 0
charge(amount) # Line: covered
else
refund(amount) # Line: NOT covered wenn nur positive Beträge getestet
end
end
Ein Test mit process_payment(100) ergibt:
- Line Coverage: 80% (4 von 5 Zeilen)
- Branch Coverage: 50% (1 von 2 Pfaden)
Branch Coverage ist der ehrlichere Wert. Deshalb empfehlen wir, beide zu tracken, aber den Branch-Schwellenwert niedriger anzusetzen (typisch: 15-20 Prozentpunkte unter Line Coverage).
USEO’s Take: Welche Coverage-Zahlen wir in Projekten anstreben
Bei USEO setzen wir in Rails-Projekten folgende Schwellenwerte:
| Projekttyp | Line Coverage | Branch Coverage | Begründung |
|---|---|---|---|
| Neues Greenfield-Projekt | 90% | 75% | Hohe Abdeckung von Anfang an realistisch |
| Legacy-Modernisierung (Phase 1) | 60% | 40% | Bestehender Code ohne Tests, Fokus auf neue Features |
| Legacy-Modernisierung (Phase 2+) | 80% | 60% | Schrittweise Erhöhung, Ratchet-Prinzip |
| API-only App | 85% | 70% | Kein View-Layer, höhere Coverage erreichbar |
Wichtig: 100% Coverage ist kein Ziel. Wir haben Projekte gesehen, in denen Teams Coverage-Zahlen optimiert haben, statt sinnvolle Tests zu schreiben. Das Ergebnis waren Tests, die expect(true).to eq(true) prüften, um Zeilen abzudecken. Die Coverage stieg, die Qualität nicht.
Wann Coverage-Metriken irreführend sind
Coverage sagt nichts über:
- Assertion-Qualität: Code wird ausgeführt, aber nicht überprüft
- Edge Cases: Der Happy Path ist getestet, Fehlerfälle nicht
- Integrations-Verhalten: Einzelne Units sind abgedeckt, das Zusammenspiel nicht
- Race Conditions: Coverage erfasst keine Timing-Probleme
Ein konkretes Beispiel aus einem unserer Projekte: Ein Payment-Service hatte 92% Line Coverage. Trotzdem gab es einen Bug bei doppelten Webhook-Aufrufen. Kein einziger Test simulierte, was passiert, wenn Stripe denselben Webhook zweimal sendet. Coverage war hoch, der Test-Suite fehlte trotzdem ein kritischer Testfall.
Das Ratchet-Prinzip für Legacy-Projekte
Statt einen festen Schwellenwert zu setzen, der sofort scheitert, nutzen wir in Legacy-Projekten einen Ratchet-Ansatz: Coverage darf nie sinken, muss aber nicht sofort steigen.
# spec/spec_helper.rb
require 'simplecov'
SimpleCov.start 'rails' do
# Aktuellen Stand als Minimum setzen
# und bei jedem Sprint um 1-2% erhöhen
minimum_coverage line: 72, branch: 48
refuse_coverage_drop
end
refuse_coverage_drop ist der Schlüssel: Jeder neue Code muss mindestens so gut getestet sein, dass die Gesamtabdeckung nicht fällt. Das erzwingt Tests für neue Features, ohne das Team mit historischen Lücken zu blockieren.
CI-Integration: Coverage automatisch durchsetzen
GitHub Actions
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Setup DB
run: bin/rails db:test:prepare
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
- name: Run tests with coverage
run: |
DISABLE_SPRING=1 bundle exec rspec
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
RAILS_ENV: test
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
if: always()
GitLab CI
# .gitlab-ci.yml
test:
stage: test
script:
- DISABLE_SPRING=1 bundle exec rspec
artifacts:
paths:
- coverage/
expire_in: 30 days
coverage: '/\(\d+.\d+\%\) covered/'
Die coverage-Zeile in GitLab extrahiert den Coverage-Wert aus der SimpleCov-Ausgabe und zeigt ihn direkt in Merge Requests an.
Coverage-Kommentare in Pull Requests
Für automatische Coverage-Kommentare in PRs empfehlen wir simplecov-cobertura zusammen mit einem CI-Plugin:
# Gemfile
group :test do
gem 'simplecov', require: false
gem 'simplecov-cobertura', require: false
end
# spec/spec_helper.rb
require 'simplecov'
require 'simplecov-cobertura'
SimpleCov.formatters = SimpleCov::Formatter::MultiFormatter.new([
SimpleCov::Formatter::HTMLFormatter,
SimpleCov::Formatter::CoberturaFormatter
])
SimpleCov.start 'rails' do
minimum_coverage line: 80, branch: 60
end
Das Cobertura-Format wird von den meisten CI-Plattformen nativ unterstützt und zeigt Coverage-Änderungen direkt im Diff an.
Docker: Coverage-Berichte aus Containern holen
# docker-compose.test.yml
services:
test:
build: .
command: bundle exec rspec
volumes:
- ./coverage:/app/coverage # Coverage-Bericht auf Host mappen
environment:
- RAILS_ENV=test
- DISABLE_SPRING=1
Ohne Volume-Mapping bleiben die Berichte im Container und gehen beim Aufräumen verloren.
Wichtig: SimpleCov nur in der Test-Gruppe des Gemfiles halten. Es hat in Produktions-Images nichts zu suchen.
Codeabdeckung in Rails mit SimpleCov überprüfen
Coverage-Berichte lesen: Worauf es wirklich ankommt
Die häufigste Fehlinterpretation
Teams schauen auf die Gesamtzahl und fühlen sich sicher. “87% Coverage, alles gut.” Das ist, als würde man den Durchschnitt der Körpertemperatur aller Mitarbeitenden messen und sagen: “Im Schnitt sind alle gesund.”
Die Gesamtzahl ist nur der Einstiegspunkt. Die eigentliche Analyse beginnt in der Datei-Ansicht:
- Rot markierte Zeilen: Komplett ungetestet
- Gelb markierte Zeilen: Teilweise abgedeckt (typisch bei
if/else) - Grün markierte Zeilen: Mindestens einmal ausgeführt
Prioritäten richtig setzen
Nicht jede rote Zeile ist gleich kritisch. Priorisierung nach Risiko:
- Payment/Billing-Logik mit niedriger Coverage: Sofort beheben
- Authentifizierung/Autorisierung: Sicherheitskritisch
- Daten-Transformationen in Services/Models: Geschäftslogik
- Controller-Actions: Mindestens Happy Path + Fehlerfall
- Mailers/Jobs: Wichtig, aber weniger kritisch
- View Helpers: Niedrigste Priorität
Versteckte Lücken in “grünem” Code
Eine Zeile kann grün sein und trotzdem schlecht getestet. Beispiel:
def calculate_discount(user, amount)
return 0 if amount < 10
user.premium? ? amount * 0.2 : amount * 0.1
end
Ein einziger Test mit calculate_discount(premium_user, 100) ergibt 100% Line Coverage. Aber getestet wurde nur ein Pfad. Es fehlen Tests für:
amount < 10(Early Return)- Nicht-Premium-User
- Grenzwerte (
amount = 10,amount = 9) - Negative Beträge
Deshalb ist Branch Coverage der bessere Indikator. Und deshalb reicht Coverage allein nie aus.
Häufige Probleme und deren Lösung
SimpleCov zeigt 0% an
Fast immer ein Lade-Reihenfolge-Problem. Checkliste:
- Steht
require 'simplecov'vorrequire 'rails_helper'? - Ist
require: falseim Gemfile gesetzt? - Läuft Spring? Versuch mit
DISABLE_SPRING=1 - Wird
SimpleCov.startvor dem Laden der Rails-Umgebung aufgerufen?
Coverage unterscheidet sich lokal und in CI
Ursachen:
- Spring ist lokal aktiv, in CI nicht
- Unterschiedliche Ruby-Versionen
- Parallele Tests ohne Merge der Ergebnisse
Lösung für parallele Tests:
SimpleCov.start 'rails' do
if ENV['CI']
command_name "rspec-#{ENV['CI_NODE_INDEX']}"
end
end
/coverage im Git-Repository
Gehört in .gitignore. Coverage-Berichte sind Build-Artefakte, keine Quellcode-Dateien.
# .gitignore
/coverage/
Zusammenfassung
SimpleCov einzurichten dauert fünf Minuten. Es richtig zu nutzen, erfordert Disziplin. Die Konfiguration muss vor allem anderen Code geladen werden, Filter sollten bewusst gesetzt werden, und Schwellenwerte gehören in die CI-Pipeline.
Coverage-Zahlen sind ein Werkzeug, kein Ziel. Ein Projekt mit 80% Coverage und durchdachten Tests ist besser als eines mit 95% und oberflächlichen Assertions. Nutzen Sie SimpleCov, um Lücken zu finden, nicht um Metriken zu optimieren.
FAQs
Wie integriere ich SimpleCov mit Docker und CI/CD?
SimpleCov wird im Gemfile in der :test-Gruppe hinzugefügt und in spec_helper.rb konfiguriert. Für Docker muss das /coverage-Verzeichnis per Volume auf den Host gemappt werden. In CI/CD-Pipelines (GitHub Actions, GitLab CI) wird SimpleCov automatisch bei jedem Test-Run ausgeführt. Das Cobertura-Format eignet sich besonders gut für automatische Coverage-Kommentare in Pull Requests.
Welche Coverage-Schwellenwerte sind sinnvoll?
Das hängt vom Projekttyp ab. Greenfield-Projekte starten bei 90% Line / 75% Branch Coverage. Legacy-Projekte beginnen realistisch bei 60-70% und steigern schrittweise. Der wichtigste Mechanismus ist refuse_coverage_drop: Coverage darf nicht sinken, wenn neuer Code hinzukommt. Feste 100%-Ziele führen erfahrungsgemäss zu schlechten Tests statt zu besserer Software.
Wie passe ich SimpleCov an meine Projektstruktur an?
Über den SimpleCov.start-Block in der Test-Helper-Datei. add_group erstellt Kategorien für eigene Verzeichnisse (Services, Policies, Queries). add_filter schliesst irrelevante Pfade aus (Views, Vendor, Config). Für mehrere Ausgabeformate (HTML + Cobertura/LCOV) nutzen Sie SimpleCov::Formatter::MultiFormatter.