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?

VerzeichnisFiltern?Begründung
app/modelsNeinKernlogik, muss getestet sein
app/servicesNeinBusiness-Logik gehört getestet
app/controllersNeinRequest-Handling braucht Tests
app/viewsJaWird indirekt durch System-Tests abgedeckt
app/helpersKommt darauf anEinfache Formatter filtern, komplexe Logik testen
config/JaKonfiguration, keine Logik
db/migrateJaMigrations werden nicht direkt getestet
vendor/JaFremdcode

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:

ProjekttypLine CoverageBranch CoverageBegründung
Neues Greenfield-Projekt90%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 App85%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:

  1. Payment/Billing-Logik mit niedriger Coverage: Sofort beheben
  2. Authentifizierung/Autorisierung: Sicherheitskritisch
  3. Daten-Transformationen in Services/Models: Geschäftslogik
  4. Controller-Actions: Mindestens Happy Path + Fehlerfall
  5. Mailers/Jobs: Wichtig, aber weniger kritisch
  6. 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:

  1. Steht require 'simplecov' vor require 'rails_helper'?
  2. Ist require: false im Gemfile gesetzt?
  3. Läuft Spring? Versuch mit DISABLE_SPRING=1
  4. Wird SimpleCov.start vor 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.

Verwandte Artikel