TDD und BDD in Ruby on Rails: ohne Angst ausliefern, und wie KI die Arbeit verändert

Kurz gesagt

Niveau: Fortgeschritten

  • TDD heißt: zuerst einen kleinen scheiternden Test schreiben, dann den wenigsten Code, der besteht, dann aufräumen. In einer Studie mit vier Industrieteams sank die Fehlerdichte vor dem Release um 40 % bis 90 %, bei 15 % bis 35 % mehr Zeit für die erste Entwicklung.
  • BDD beschreibt das Verhalten in schlichter Sprache mit Given, When und Then, die das Business lesen kann, und führt es als Test aus.
  • In Rails: RSpec für Unit und Request Specs, Cucumber für die kritischen Wege der Nutzer, Minitest, wenn du den Standard ohne zusätzliche Gems willst.
  • Teste den kritischen Pfad (Login, Kernlogik, Zahlungen, Daten, Compliance, APIs) bei jedem Release, und lass die Pipeline alles Rote blockieren.
  • Nutz KI, um Randfälle zu finden und Gerüste zu entwerfen. Die Strategie gehört einem Menschen, und ein Mensch prüft jeden Test.

Die meiste Software wird immer noch erst geschrieben und später geprüft, und die Rechnung kommt in der Produktion. Dieser Artikel erklärt TDD und BDD von Grund auf, vergleicht RSpec, Minitest und Cucumber mit Code, der läuft, und zeigt, wie ich KI nutze, um Tests schneller zu schreiben, ohne sie entscheiden zu lassen, was richtig heißt.

Die teuerste Codezeile, die ich je gesehen habe

Sie war nicht kompliziert. Sie war nicht raffiniert. Es war ein Rundungsfehler in einer Funktion zur Währungsumrechnung: drei Nachkommastellen statt zwei. In der Testumgebung fiel es niemandem auf. In der Produktion, bei Tausenden Transaktionen am Tag für einen Fintech Kunden, entstand eine Differenz, deren Entwirrung zwei Wochen dauerte. Die Korrektur dauerte vier Minuten. Das Aufräumen vierhundert Stunden.

Das hat verändert, wie ich Software schreibe. Nicht, weil ich gelernt habe, sorgfältiger zu sein (auch das), sondern weil ich gelernt habe, dass die übliche Reihenfolge der Arbeit das Problem ist. Code schreiben, dann testen, dann Fehler beheben, dann wieder testen, dann weitere Fehler finden, dann ausliefern und hoffen. Das ist keine Methode. Das ist eine Wette.

Es gibt zwei bessere Wege, Test Driven Development (TDD) und Behaviour Driven Development (BDD), und Ruby on Rails ist einer der besten Orte, um beide zu üben. Dieser Artikel erklärt, was das jeweils ist, welches Rails Werkzeug welche Aufgabe übernimmt, was vor jedem Release getestet sein muss und wie du KI nutzt, um Tests schneller zu schreiben, ohne ihr das Urteil zu überlassen.

Erst der Code, dann der Test, und warum das immer wieder scheitert

Jahrzehntelang lief es meist so. Jemand schreibt eine Spezifikation. Die Entwickler lesen sie (manchmal). Die Entwickler schreiben Code. Jemand testet, meist von Hand, meist unter Zeitdruck. Fehler werden gefunden und behoben. Die Korrekturen bringen neue Fehler. Irgendwann sagt jemand "passt schon so", und es geht raus.

Die Rechnung für diese Gewohnheit ist hoch. Das Consortium for Information and Software Quality (CISQ) schätzt, dass schlechte Softwarequalität die Vereinigten Staaten 2022 mindestens 2,41 Billionen US Dollar gekostet hat.1 Das ist eine grobe Schätzung über eine ganze Volkswirtschaft, aber die einzelnen Fälle zeigen, woraus sie besteht.

Am 1. August 2012 spielte Knight Capital neuen Handelscode auf sieben seiner acht Server. Ein Techniker kopierte ihn nicht auf den achten, und keine zweite Person prüfte das Deployment. Alter, ruhender Code auf diesem Server erwachte, schickte in rund 45 Minuten Millionen Orders in den Markt, und Knight verlor mehr als 460 Millionen US Dollar.2

Im Mai 2015 stürzte ein Airbus A400M bei einem Testflug nahe Sevilla ab, vier der sechs Besatzungsmitglieder starben. Die damals berichtete Hauptspur der Ermittlungen war, dass bei einer Softwareinstallation versehentlich Kalibrierungsdaten der Triebwerke gelöscht worden waren.3

Am 19. Juli 2024 lieferte CrowdStrike ein Inhaltsupdate aus, das weltweit Windows Rechner mit seinem Sensor abstürzen ließ. Die eigene Ursachenanalyse nennt unter den Gründen "the lack of a specific test", also das Fehlen eines bestimmten Tests für die Eingabe, die den Absturz auslöste.4

Das sind keine exotischen Sonderfälle. So sieht es aus, wenn das Prüfen zuletzt kommt, oder gar nicht.

TDD: zuerst der Test

Test Driven Development dreht die Reihenfolge um. Du schreibst einen Test für ein kleines Verhalten, siehst ihn scheitern, schreibst den wenigsten Code, der ihn bestehen lässt, und räumst dann auf. Das ist der Zyklus aus Rot, Grün und Refactoring.

  • Rot. Schreib einen Test, der das gewünschte Verhalten beschreibt. Lass ihn laufen. Er scheitert, weil der Code noch nicht existiert.
  • Grün. Schreib den einfachsten Code, mit dem der Test besteht. Nicht mehr. Lass ihn wieder laufen. Er besteht.
  • Refactoring. Räum die Umsetzung auf. Lass die Tests wieder laufen. Immer noch grün? Weiter.

Der Zyklus aus Rot, Grün und RefactoringTest Driven Development als Schleife: einen kleinen scheiternden Test schreiben, ihn mit dem wenigsten Code bestehen lassen, aufräumen, solange die Tests grün bleiben, dann mit dem nächsten Verhalten von vorn beginnen.Schreib einen kleinen Test für dasgewünschte VerhaltenLass ihn laufen und sieh ihn scheitern(rot)Schreib den wenigsten Code, mit dem erbestehtBesteht jeder Test?neinNein: den Code korrigieren, nie denTest, und neu laufen lassenjaJa: den Code aufräumen, dann jeden Testerneut laufen lassenMit dem nächsten kleinen Verhaltenbeginnen

Das Diagramm liest sich von oben nach unten als eine Runde der Schleife. Es zeigt, dass der Test vor dem Code existiert und dass die Tests nach jeder Änderung laufen, auch nach dem Aufräumen. Jede Runde ist klein, oft ein paar Minuten, und ein Arbeitstag besteht aus Dutzenden davon. Am Ende hast du ein Feature und eine Sammlung von Tests, die beweist, dass es funktioniert.

Lohnt sich das? Die bekannteste Studie aus der Industrie, von Nagappan und Kollegen, begleitete drei Teams bei Microsoft und eines bei IBM, die TDD eingeführt hatten. Ihre Fehlerdichte vor dem Release sank um 40 % bis 90 % gegenüber vergleichbaren Projekten ohne TDD. Die Teams empfanden zugleich, dass die erste Entwicklung 15 % bis 35 % länger dauerte.5 Das ist der ehrliche Tausch: mehr Zeit vorne, weit weniger Fehler später.

Durchgerechnetes Beispiel: ein Währungsumrechner für Auenpay

Auenpay ist eine erfundene Zahlungsapp aus Leipzig. Ihre Kundinnen und Kunden bekommen Zahlungen von britischen Auftraggebern in Pfund und wollen sie in Euro, also braucht die App einen Umrechner. Zuerst die Tests, in RSpec:

require "rails_helper"

RSpec.describe CurrencyConverter do
  describe "#convert" do
    it "converts pounds to euros at the given rate" do
      converter = CurrencyConverter.new(rate: BigDecimal("1.15")) # Kurs als BigDecimal, nie als Float
      expect(converter.convert(BigDecimal("100.00"))).to eq(BigDecimal("115.00"))
    end

    it "rounds to exactly two decimal places" do
      converter = CurrencyConverter.new(rate: BigDecimal("1.0735"))
      expect(converter.convert(BigDecimal("33.33"))).to eq(BigDecimal("35.78")) # 35,779755 gerundet
    end
  end
end

Lass sie laufen. Sie scheitern, weil es CurrencyConverter nicht gibt. Das ist der rote Schritt. Jetzt der wenigste Code, mit dem sie bestehen:

require "bigdecimal"

class CurrencyConverter
  def initialize(rate:)
    @rate = BigDecimal(rate.to_s)
  end

  def convert(amount)
    (BigDecimal(amount.to_s) * @rate).round(2, BigDecimal::ROUND_HALF_UP) # kaufmännisch auf zwei Stellen
  end
end

Prüf den zweiten Test von Hand. 33,33 £ mal 1,0735 sind 33,33 plus 33,33 mal 0,0735, also 33,33 plus 2,449755, also 35,779755. Auf zwei Stellen gerundet sind das 35,78 €. Grün.

Zwei Entscheidungen in diesem Code zählen. Geld wird als BigDecimal gehalten, nie als Float, denn Floats speichern binäre Brüche: In Ruby ist 0.1 + 0.2 == 0.3 falsch. Und die Rundungsregel steht im Code, statt einem Standard überlassen zu werden. Jetzt kann ich aufräumen und Tests für die Randfälle ergänzen (ein Kurs von null, ein negativer Betrag, eine nicht unterstützte Währung), einen nach dem anderen. Genau dieser Rundungstest hätte den Fehler mit den drei Nachkommastellen vom Anfang gefangen.

BDD: beschreib das Verhalten, das der Nutzer sieht

Behaviour Driven Development geht einen Schritt über TDD hinaus. TDD fragt, ob eine Methode den richtigen Wert liefert. BDD fragt, ob ein Mensch erledigen kann, wofür er gekommen ist.

Dan North entwickelte BDD ab Ende 2003, als er JBehave als Ersatz für JUnit zu schreiben begann, weil Entwickler ihn immer wieder fragten, wo sie mit TDD anfangen sollen, was sie testen sollen und wie sie ihre Tests nennen sollen.6 Seine Antwort war, von Verhalten statt von Tests zu sprechen, in Worten, die auch das Business lesen kann.

In Rails ist das übliche Werkzeug dafür Cucumber. Seine Szenarien sind in Gherkin geschrieben, jeder Schritt beginnt mit Given, When, Then, And oder But, und die ganze Datei ist eine ausführbare Spezifikation.7

Durchgerechnetes Beispiel: Transporter suchen bei Hafenkante Transporter

Hafenkante Transporter ist eine erfundene Transportervermietung aus Hamburg, mit einem Shop auf Rails. Die Suche, in Gherkin:

Feature: Van search
  As someone hiring a van in Hamburg
  I want to search by make and model
  So that I only see the vans I am interested in

  Scenario: Searching for a make and model
    Given there are 10 Ford Transit vans
    And there are 5 Opel Vivaro vans
    When I search for "Ford Transit"
    Then I should see 10 results
    And I should not see "Opel"

Lies das laut vor. Die Inhaberin kann folgen. Ein Designer kann folgen. Dein Geschäftspartner, der noch nie einen Codeeditor geöffnet hat, kann folgen. Das Szenario ist Dokumentation, die nicht veralten kann, denn sobald es nicht mehr stimmt, scheitert es. Gherkin ist in über 70 Sprachen übersetzt, auch ins Deutsche, und eine Kopfzeile # language: in der ersten Zeile der Datei sagt Cucumber, welche Sprache gilt. Ohne sie gilt Englisch, und dabei bleiben die Beispiele hier.7

Hinter jeder Zeile steht eine Step Definition in Ruby, die die Worte in Aktionen übersetzt:

Given("there are {int} {word} {word} vans") do |count, make, model|
  create_list(:van, count, make: make, model: model) # FactoryBot legt die Datensätze an
end

When("I search for {string}") do |query|
  visit vans_path
  fill_in "Search", with: query
  click_button "Find vans"
end

Then("I should see {int} results") do |count|
  expect(page).to have_css(".van-card", count: count)
end

Then("I should not see {string}") do |text|
  expect(page).not_to have_content(text)
end

Drei Details sorgen dafür, dass das läuft. create_list kommt aus FactoryBot, und Cucumber sieht es erst, wenn World(FactoryBot::Syntax::Methods) in einer Datei unter features/support steht.8 visit, fill_in und click_button kommen aus Capybara, das cucumber-rails für dich einrichtet.910 Und der erste Schritt nutzt {word} zweimal, damit dieselbe Definition für "Ford Transit" und "Opel Vivaro" passt. Tiefer ins Schreiben solcher Szenarien gehe ich in Cucumber BDD, die Geheimwaffe hinter jedem Projekt, das ich liefere.

RSpec, Minitest und Cucumber: wofür was da ist

Rails nimmt Tests von Haus aus ernst. Eine neue App bekommt ein Verzeichnis test und eine test_helper.rb, und die Generatoren schreiben für jedes Modell, das sie anlegen, eine Testdatei. Die Standardbibliothek ist Minitest.11 Warum diese Disziplin zu dem gehört, was Rapid Development Frameworks besser machen als rohes PHP, habe ich schon beschrieben. Es gibt drei Hauptwerkzeuge zur Auswahl.

RSpec ist die wichtigste Alternative zum Standard, und die, zu der ich greife. Es liest sich wie eine Spezifikation, mit Blöcken für describe, context und it. Über das Gem rspec-rails unterstützt es in den Versionen 8.x Rails 7.2, 8.0 und 8.1, und du installierst es mit rails generate rspec:install.12 Es passt zu mittleren und großen Codebasen und zu jedem Team, dem lesbare Tests wichtig sind (also jedem Team).

Minitest kommt mit Rails. Es ist schlichtes Ruby mit sehr wenig zu lernen: Wer Ruby kann, kann Minitest. Es passt zu kleineren Projekten und zu allen, die weniger bewegliche Teile mögen.

Cucumber ist für Akzeptanztests da: die Wege der Nutzer, in Gherkin geschrieben, die eine nicht technische Inhaberin lesen und abnehmen kann.

Mein ehrlicher Vergleich:

RSpecMinitestCucumber
Lesbarkeit für EntwicklerSehr hochHoch, knappHoch
Lesbarkeit für das BusinessNiedrigNiedrigSehr hoch
Was du zuerst lernstEine DSL (eine kleine eigene Sprache)Nur RubyGherkin plus Step Definitions
ÖkosystemAm größtenKleiner, Rails Standards eingebautFunktioniert mit beiden
TempoSchnellAm schnellstenAm langsamsten, es steuert die ganze App

Die Tabelle ist mein Urteil aus der Arbeit mit allen dreien, kein Benchmark. In der Praxis entscheidet der Tempounterschied zwischen RSpec und Minitest selten etwas. Entscheidend ist, ob dein Team die Tests wirklich liest und pflegt.

Meine Empfehlung: RSpec für Unit und Request Specs, Cucumber für die kritischen Wege der Nutzer. RSpec trägt die Masse (Modelle, Services, Requests), Cucumber deckt die durchgehenden Abläufe ab, die dem Business am wichtigsten sind. Ein Tipp zu Request Specs: Sowohl das Rails Team als auch das RSpec Team raten davon ab, Controller direkt zu testen, und empfehlen Request Specs, die mit einer HTTP Anfrage beginnen und mit der Antwort enden.12

Durchgerechnetes Beispiel: dieselbe Regel in RSpec und in Minitest

Die Transporter von Hafenkante brauchen eine Marke, ein Modell und einen positiven Tagespreis, dazu einen Titel für die Anzeige. In RSpec, mit dem Gem shoulda-matchers, das Rails 7.2 und neuer unterstützt:13

require "rails_helper"

RSpec.describe Van, type: :model do
  describe "validations" do
    it { is_expected.to validate_presence_of(:make) }
    it { is_expected.to validate_presence_of(:model) }
    it { is_expected.to validate_numericality_of(:daily_rate).is_greater_than(0) } # Tagespreis muss positiv sein
  end

  describe "#full_title" do
    it "joins year, make and model" do
      van = build(:van, make: "Ford", model: "Transit", year: 2024)
      expect(van.full_title).to eq("2024 Ford Transit")
    end
  end
end

Dasselbe in Minitest, mit einer Fixture namens transit_2024 in test/fixtures/vans.yml:

require "test_helper"

class VanTest < ActiveSupport::TestCase
  test "full title joins year, make and model" do
    van = vans(:transit_2024) # Fixture aus test/fixtures/vans.yml
    assert_equal "2024 Ford Transit", van.full_title
  end

  test "daily rate must be positive" do
    van = Van.new(daily_rate: -10)
    assert_not van.valid?
    assert_includes van.errors[:daily_rate], "must be greater than 0"
  end
end

Beide beweisen dieselben Regeln. Die RSpec Fassung liest sich näher an einem Satz. Die Minitest Fassung braucht keine zusätzlichen Gems. Beides ist eine gute Wahl, beides in einer App zu mischen nicht.

Branchen, in denen ein Fehler ein rechtliches Problem ist

Nicht alle Fehler sind gleich. Ein verrutschter Button nervt. Ein Rundungsfehler in einer Zahlung ist ein Compliance Problem. Ein Fehler in medizinischer Software kann jemanden verletzen.

Zahlungen und Banken. Zahlungssoftware bewegt sich im Recht der Zahlungsdienste, in Deutschland im Zahlungsdiensteaufsichtsgesetz (ZAG) und auf EU Ebene in der zweiten Zahlungsdiensterichtlinie (PSD2),1415 dazu kommen Regeln gegen Geldwäsche und das Datenschutzrecht. Jede Transaktion muss auf den Cent stimmen und jeder Prüfpfad vollständig sein. Knight Capital ist die Geschichte, an die sich die ganze Branche erinnert.2

Gesundheit. Nach der EU Medizinprodukteverordnung kann eigenständige Software mit medizinischer Zweckbestimmung selbst ein Medizinprodukt sein.16 Testen heißt dort Patientensicherheit, nicht nur Funktion.

Luftfahrt. Software an Bord wird nach RTCA DO-178C gebaut, "Software Considerations in Airborne Systems and Equipment Certification", dem wichtigsten Weg zur Zulassung von Software in zivilen Flugzeugen.17

eCommerce. Weniger dramatisch, trotzdem teuer. Ein kaputter Checkout im Sale verliert jede Bestellung, die er berührt, und ein falscher Preis kann neben einer Erstattung auch ein rechtliches Problem auslösen. Verkaufst du Software oder vernetzte Produkte in die EU, bringt der Cyber Resilience Act Sicherheitspflichten mit: Er trat am 10. Dezember 2024 in Kraft, seine Meldepflichten gelten ab dem 11. September 2026 und die Hauptpflichten ab dem 11. Dezember 2027.18 Für wen er gilt, steht im Artikel zum Cyber Resilience Act.

Der kritische Pfad: was vor jedem Release bestehen muss

Du kannst nicht alles testen und trotzdem ausliefern. Die Kunst ist, den kritischen Pfad zu finden: die wenigen Dinge, die funktionieren müssen, damit die Anwendung nutzbar, sicher und rechtskonform ist. Für die meisten Webanwendungen heißt das:

  • Authentifizierung und Autorisierung. Können sich Menschen anmelden, und sehen sie nur, was sie sehen sollen? Sind Adminfunktionen geschützt? Ein Fehler hier ist eine Datenpanne.
  • Kern der Geschäftslogik. Was die Anwendung eigentlich tut: die Berechnungen, Abläufe und Datenänderungen in ihrem Herzen.
  • Zahlungen. Jeder Zustand (ausstehend, abgeschlossen, gescheitert, erstattet) und jeder unangenehme Fall (ein Timeout mitten in der Zahlung, ein doppeltes Absenden, eine Teilerstattung).
  • Datenintegrität. Constraints, Validierungen und Migrationen. Wenn Daten kaputtgehen können, werden sie es.
  • Compliance Funktionen. Einwilligung, Datenexport und Löschung, Audit Logs, Betrugsprüfungen. Wenn eine Behörde fragt, muss das funktionieren.
  • API Verträge. Bietest oder nutzt du eine API, kann eine inkompatible Änderung eine Kettenreaktion auslösen, durch jedes System, das davon abhängt.

Jeder dieser Punkte ist durch automatisierte Tests abgedeckt, die vor jedem Deployment laufen. Kein "diesmal teste ich von Hand". Kein "bei mir lief es". Die Pipeline ist der Türsteher. Seit Rails 7.2 bekommt eine neue App sogar einen GitHub Actions Workflow mit, der bei jedem Push die Tests laufen lässt.19

Das Tor vor dem ReleaseJede Änderung geht durch dasselbe Tor: zuerst laufen die schnellen Specs, dann die Wege der Nutzer, und jeder rote Test auf dem kritischen Pfad stoppt das Deployment.Ein Pull Request wird geöffnetModel, Service und Request Specs laufenCucumber prüft die kritischen Wege derNutzerIst irgendein Test rot?jaJa: das Deployment stoppt, bis esbehoben istneinNein: mergen und deployenDieselbe Suite läuft noch einmal aufmain

Lies es von oben nach unten. Auf dem Weg gibt es eine Entscheidung, und nur eine Antwort lässt den Code durch. Niemand muss daran denken, die Tests laufen zu lassen, und niemand kann einen roten Test durchwinken.

Eine Zahlungsapp unter der DSGVO testen

Zurück zu Auenpay. Die App bewegt echtes Geld, echte Transaktionen und personenbezogene Daten, also gilt die Datenschutz Grundverordnung (DSGVO). Drei ihrer Regeln werden zu Tests: Der Verantwortliche muss die Einwilligung nachweisen können (Artikel 7), Menschen können die Löschung verlangen (Artikel 17), und sie können ihre Daten in einem strukturierten, gängigen und maschinenlesbaren Format erhalten (Artikel 20).20 Wie du diese Funktionen in ein SaaS einbaust, beschreibe ich an anderer Stelle ausführlicher.

Durchgerechnetes Beispiel: die Specs für personenbezogene Daten

Das sind Request Specs. auth_headers ist ein kleiner Helfer, den du einmal in spec/support schreibst, und die Factories und Traits sind Annahmen des Beispiels.

require "rails_helper"

RSpec.describe "Personal data", type: :request do
  let(:admin) { create(:user, :admin) }

  describe "data export" do
    it "returns a user's data in a machine readable format" do
      user = create(:user, :with_transactions, :with_profile)
      get api_v1_user_data_export_path(user), headers: auth_headers(user) # Artikel 20 DSGVO
      expect(response).to have_http_status(:ok)
      expect(response.parsed_body.keys).to include("personal_info", "transactions", "consent_records")
    end
  end

  describe "erasure" do
    it "anonymises the person but keeps the transaction records" do
      user = create(:user, :with_transactions)
      transaction_ids = user.transactions.ids
      expect(transaction_ids).not_to be_empty # sonst besteht der Test auf einer leeren Liste
      delete api_v1_user_path(user), headers: auth_headers(admin) # Artikel 17 DSGVO
      expect(response).to have_http_status(:no_content)
      expect(user.reload.email).to end_with("@anonymised.invalid")
      expect(Transaction.where(id: transaction_ids).count).to eq(transaction_ids.size)
    end
  end

  describe "consent" do
    it "records the purpose and the time of consent" do
      user = create(:user)
      post api_v1_consents_path, params: { purpose: "marketing", granted: true }, headers: auth_headers(user) # Artikel 7 DSGVO
      consent = user.consents.last
      expect(consent.purpose).to eq("marketing")
      expect(consent.granted_at).to be_within(1.second).of(Time.current)
    end
  end
end

Der Löschtest behält die Transaktionen mit Absicht: Artikel 17 hat Ausnahmen, unter anderem, wenn das Gesetz die Aufbewahrung von Unterlagen verlangt. Der Test prüft außerdem, dass es überhaupt Transaktionen gab, sonst könnte er auf einer leeren Liste bestehen und nichts beweisen.

Durchgerechnetes Beispiel: Überweisungen, die nicht halb passieren können

Geldbewegungen müssen ganz oder gar nicht stattfinden. Der Service, kurz gehalten:

class TransactionService
  class InsufficientFunds < StandardError
  end

  def transfer(from:, to:, amount:)
    ActiveRecord::Base.transaction do # alles oder nichts
      from.lock! # sperrt die Zeile gegen parallele Überweisungen
      raise InsufficientFunds if from.balance < amount
      from.debit!(amount)
      to.credit!(amount)
      AuditLog.create!(actor_id: from.user_id, action: "transfer", metadata: { "amount" => amount.to_s })
    end
  end
end

Und die Specs, die ihn festnageln, unter der Annahme, dass debit! und credit! kleine Methoden am Modell Wallet sind:

require "rails_helper"

RSpec.describe TransactionService do
  let(:sender)   { create(:wallet, balance: BigDecimal("100.00")) }
  let(:receiver) { create(:wallet, balance: BigDecimal("50.00")) }

  def transfer(amount)
    TransactionService.new.transfer(from: sender, to: receiver, amount: BigDecimal(amount))
  end

  it "debits the sender and credits the receiver" do
    transfer("30.00")
    expect(sender.reload.balance).to eq(BigDecimal("70.00"))
    expect(receiver.reload.balance).to eq(BigDecimal("80.00"))
  end

  it "rolls back both sides if the credit fails" do
    allow(receiver).to receive(:credit!).and_raise(ActiveRecord::RecordInvalid)
    expect { transfer("30.00") }.to raise_error(ActiveRecord::RecordInvalid)
    expect(sender.reload.balance).to eq(BigDecimal("100.00")) # Rollback, Saldo unverändert
    expect(receiver.reload.balance).to eq(BigDecimal("50.00"))
  end

  it "refuses a transfer larger than the balance" do
    expect { transfer("150.00") }.to raise_error(TransactionService::InsufficientFunds)
    expect(sender.reload.balance).to eq(BigDecimal("100.00"))
  end

  it "writes one audit entry with the actor and the amount" do
    expect { transfer("10.00") }.to change(AuditLog, :count).by(1)
    log = AuditLog.last
    expect(log.actor_id).to eq(sender.user_id)
    expect(log.action).to eq("transfer")
    expect(log.metadata).to include("amount" => "10.0") # so schreibt Ruby BigDecimal("10.00") als String
  end
end

Geh die Zahlen durch: 100 € minus 30 € sind 70 €, 50 € plus 30 € sind 80 €. Im Fehlerfall wirft die Gutschrift einen Fehler, die Datenbanktransaktion wird zurückgerollt, und beide Salden stehen da, wo sie angefangen haben. Die letzte Zeile erwartet "10.0", weil Ruby BigDecimal("10.00") so als String schreibt.

Das läuft bei jedem Pull Request, bei jedem Merge auf main und vor jedem Deployment, und die Pipeline blockiert das Deployment, wenn einer davon scheitert. Es gibt keine Übersteuerung von Hand. Im Fintech lieferst du keinen ungetesteten Code aus. Niemals.

Einen eCommerce Shop auf Solidus testen

Der Shop von Hafenkante Transporter ist auf Rails gebaut, und Solidus übernimmt Warenkorb, Checkout und Zahlungen. Der Charakter der Tests ist anders, die Gründlichkeit dieselbe. Der kritische Pfad sind Suche und Filter, Buchung, Zahlung und die Adminansichten, mit denen das Team die Flotte verwaltet.

Durchgerechnetes Beispiel: eine Buchung, vom Weg des Kunden bis zur Preisregel

Der Weg des Kunden, in Gherkin:

Feature: Van hire booking
  As a customer of Hafenkante Transporter
  I want to book a van for specific dates
  So that I know I have transport when I need it

  Scenario: A successful booking
    Given I am a registered customer
    And there is a Ford Transit available to hire
    And the daily rate is 65 €
    When I choose hire dates from 3 March to 7 March 2026
    Then I should see a total of 260.00 € for 4 days
    When I proceed to checkout
    And I pay with a valid card
    Then I should receive a booking confirmation
    And the van should be unavailable for those dates

  Scenario: Preventing a double booking
    Given the Ford Transit is booked from 3 March to 7 March 2026
    When another customer tries to book it from 5 March to 9 March 2026
    Then they should see that the van is not available
    And no payment should be taken

Und darunter die Preisregel, mit den eigenen Annahmen des Beispiels: Der Rückgabetag wird nicht berechnet, eine Miete am selben Tag zählt als ein Tag, und Samstage und Sonntage kosten 15 % mehr.

require "rails_helper"

RSpec.describe RentalPricing do
  def total(from, to)
    RentalPricing.new(daily_rate: BigDecimal("65.00"), start_date: from, end_date: to).total
  end

  it "charges the daily rate for each weekday hired" do
    expect(total(Date.new(2026, 3, 3), Date.new(2026, 3, 7))).to eq(BigDecimal("260.00")) # Di bis Fr, vier Tage
  end

  it "adds 15% on Saturdays and Sundays" do
    expect(total(Date.new(2026, 2, 27), Date.new(2026, 3, 2))).to eq(BigDecimal("214.50")) # Fr, Sa, So
  end

  it "charges one day for a same day hire" do
    expect(total(Date.new(2026, 3, 3), Date.new(2026, 3, 3))).to eq(BigDecimal("65.00"))
  end
end
class RentalPricing
  WEEKEND_SURCHARGE = BigDecimal("1.15") # 15 % Aufschlag am Wochenende

  def initialize(daily_rate:, start_date:, end_date:)
    @daily_rate = daily_rate
    @start_date = start_date
    @end_date = end_date
  end

  def total
    days.sum { |day| rate_for(day) }.round(2)
  end

  private

  def days
    last_charged = [@end_date - 1, @start_date].max # Rückgabetag zählt nicht
    (@start_date..last_charged).to_a
  end

  def rate_for(day)
    day.saturday? || day.sunday? ? @daily_rate * WEEKEND_SURCHARGE : @daily_rate
  end
end

Prüf jeden Schritt. Dienstag, 3. März, bis Samstag, 7. März 2026, berechnet Dienstag, Mittwoch, Donnerstag und Freitag: vier Werktage zu 65 €, also 260,00 €. Freitag, 27. Februar, bis Montag, 2. März, berechnet Freitag zu 65 €, dann Samstag und Sonntag zu 65 € mal 1,15, also jeweils 74,75 €: 65 € plus 74,75 € plus 74,75 € sind 214,50 €. Eine Miete am selben Tag berechnet den einen Tag, 65,00 €.

Die Daten sind kein Zufall. Die frühere Fassung dieses Artikels nutzte den 1. bis 5. März, und der 1. März 2026 ist ein Sonntag, die "einfache" Summe von 260 € hätte also eigentlich einen Aufschlag enthalten. Den Test gegen einen echten Kalender zu prüfen, hat das gefangen. Das ist das ganze Argument fürs Testen in einem Absatz.

Achte darauf, wie die beiden Ebenen zusammenspielen. Das Cucumber Szenario sagt dir, ob der Weg für einen Kunden funktioniert. Die RSpec Specs sagen dir genau, welche Regel gebrochen ist, wenn er es nicht tut.

KI zum Testen nutzen, ohne die Kontrolle zu verlieren

Tests zu schreiben ist unverzichtbar, und es ist mühsam. Eine gründliche Suite für eine Rails Anwendung umfasst Hunderte oder Tausende Fälle, und es gibt immer einen Randfall, an den niemand gedacht hat. Hier helfen KI Assistenten wie Claude wirklich, wenn du sie richtig einsetzt. Was die Forschung zu KI und der Produktivität von Entwicklern sagt, einschließlich der METR Studie, nach der erfahrene Entwickler mit KI langsamer waren, steht in Wird AI wie ChatGPT je Developers ersetzen, und wer die Verantwortung für KI geschriebenen Code trägt, in KI schreibt den Code, wer ist dafür verantwortlich. Die Belege wiederhole ich hier nicht. Dieser Abschnitt zeigt, wie ich KI für Tests nutze.

Der falsche Weg: "schreib Tests für meine Anwendung". Kopier deinen Code hinein, und du bekommst Tests. Vielleicht bestehen sie sogar. Aber aus meiner Sicht sind sie eher oberflächlich, testen die Umsetzung statt des Verhaltens und übersehen die Fälle, die nur dein Business kennt. Ungeprüfte KI Tests sind schlechter als gar keine, weil sie dich glauben lassen, du seist abgedeckt, obwohl du es nicht bist.

Das Werkzeug kennt deine Domäne nicht. Es weiß nicht, dass Auenpay mit einer Umrechnung klarkommen muss, die an einem Sonntag angefragt wird, während der Kursfeed ausgefallen ist. Es weiß nicht, dass Hafenkante eine Miete stoppen muss, wenn die Hauptuntersuchung (HU) eines Transporters mitten in der Buchung abläuft. Es schreibt Tests aus Mustern im Code, nicht aus Wissen über das Geschäft.

Der richtige Weg: vom Menschen gesteuert, von der KI unterstützt. Der Ansatz, den ich im letzten Jahr entwickelt habe, hat fünf Schritte.

  1. Denk die Teststrategie selbst durch. Was sind die kritischen Pfade, die Geschäftsregeln, die rechtlichen Anforderungen? Das ist menschliche Arbeit.
  2. Frag die KI nach den Randfällen, an die du nicht gedacht hast. Beschreib die Logik und frag, was eine Testsuite abdecken sollte.
  3. Lass die KI das Gerüst schreiben. Gib ihr deinen Stil, deine Factories und deine Benennung, und lass sie die wiederholten Teile entwerfen. Dann prüf jeden einzelnen, änder sie, lösch die sinnlosen und ergänz die Prüfungen, die nur du kennst.
  4. Nie vertrauen, immer prüfen. Behandle jeden erzeugten Test als ersten Entwurf. Lies ihn Zeile für Zeile. Würde er scheitern, wenn der Code kaputt wäre? Könnte er aus dem falschen Grund bestehen? Ich habe eine persönliche Regel: Kann ich nicht in einem Satz erklären, was ein Test tut und warum er zählt, wird er neu geschrieben oder gelöscht.
  5. Nutz die KI als zweite Prüferin. Gib ihr die fertigen Tests zurück und frag nach logischen Fehlern, doppelten Tests und zu lockeren Prüfungen. Das fängt Tests, die aus den falschen Gründen bestehen.

Durchgerechnetes Beispiel: der Cent, der verschwindet

Ein Beispiel für Schritt zwei. Die Anfrage:

I have a service that splits a daily marketing budget across several
campaigns in proportion to their performance scores. What edge cases
should my test suite cover?

Die Art Liste, die zurückkommt: alle Scores gleich, ein Score null, alle Scores null, ein negativer Score, ein Budget von null, ein Budget kleiner als die Zahl der Kampagnen, eine einzige Kampagne, sehr viele Kampagnen, Scores, die sich während der Aufteilung ändern, Anteile, die nach dem Runden nicht mehr das Budget ergeben, und ob Geld intern in der kleinsten Einheit gehalten wird. Als ich diese Übung für einen Service zur Budgetverteilung gemacht habe, enthielt die Liste mindestens drei Fälle, die ich nicht ausdrücklich zu testen geplant hatte: die Rundungslücke, das winzige Budget und die interne kleinste Einheit. Das ist der Wert.

Nimm die Rundungslücke mit erfundenen Zahlen. Ein Münchner Shop teilt 1,00 € auf drei Kampagnen mit gleichen Scores auf. Jeder Anteil ist 1,00 € geteilt durch drei, also 0,3333 periodisch, gerundet 0,33 €. Dreimal 0,33 € sind 0,99 €. Ein Cent ist verschwunden. Jeden Tag, bei jedem Budget.

Der Test, der das festnagelt, sagt, dass die Anteile immer das Budget ergeben:

it "allocates every penny of the budget" do
  shares = BudgetSplitter.new(budget_pence: 100, scores: [1, 1, 1]).split # Betrag in der kleinsten Einheit, hier 100 Cent
  expect(shares.sum).to eq(100)
  expect(shares).to contain_exactly(34, 33, 33)
end

Ein Weg, ihn zu bestehen: in ganzen Cent rechnen, jeder Kampagne ihren abgerundeten Anteil geben (33, 33, 33, zusammen 99) und den übrigen Cent der Kampagne mit dem größten Rest geben. Bei gleichen Scores geht der Gleichstand an die erste, das Ergebnis ist also 34, 33 und 33, zusammen 100. Die KI hat den Fall vorgeschlagen. Ein Mensch hat entschieden, was richtig heißt.

Das Prinzip: vom Menschen gesteuert, von der KI unterstützt

Alles davon in sechs Zeilen:

  • Die Strategie gehört dir. Ein Mensch entscheidet, was getestet wird, warum und was als bestanden gilt. Das Werkzeug nie.
  • Die KI findet das Unbekannte. Nutz sie, um Randfälle und Grenzen zu sammeln. Ein Denkpartner, kein Ersatz fürs Denken.
  • Die KI entwirft das Gerüst. Den Standardcode, die Factories, die wiederholte Einrichtung.
  • Du prüfst alles. Kein Test kommt in die Suite, den ein Mensch nicht gelesen und verstanden hat.
  • Die KI gibt eine zweite Meinung. Lass sie die fertigen Tests kritisieren.
  • Die Suite ist deine Verantwortung. Scheitert ein Test, musst du wissen, warum. Rutscht ein Fehler durch, musst du wissen, welcher Test gefehlt hat.

Das hat sich in echten Projekten bewährt. Bei einem Solidus Projekt hat mir dieser Ansatz eine gründliche Abdeckung von Suche, Buchung und Zahlung in ungefähr der Hälfte der Zeit gebracht, die es gedauert hätte, jeden Test von Hand zu schreiben. Wichtiger noch: Die Sammlung von Randfällen hat drei Szenarien gefangen, die echte Fehler in der Produktion geworden wären: ein Fehler in der Datumsrechnung, wenn eine Buchung über die Umstellung auf oder von der Sommerzeit ging, eine Race Condition, wenn zwei Leute gleichzeitig dasselbe Fahrzeug buchten, und ein Fall im Checkout, in dem ein Rabattcode, nach einem Servicepaket hinzugefügt, die falsche Summe ergab.

Bei einem Fintech SaaS war er besonders nützlich für die Datenschutztests. Die Regeln sind detailliert, und das Durchdenken der Regeln durch das Werkzeug hat mir geholfen, eine gründlichere Suite für Einwilligungen zu bauen, als ich sie allein gebaut hätte. Jeder dieser Tests wurde vor dem Commit gegen den Text der Verordnung geprüft.

Testen ist keine Steuer

Ich baue lange genug Software, um mich an die Zeit zu erinnern, in der Tests optional waren. In der "bei mir läuft es" ein akzeptables Kriterium für ein Release war. In der Hotfixes einfach zur Woche gehörten.

Diese Zeit ist vorbei. Der Einsatz ist zu hoch, die Regeln zu streng und die Werkzeuge zu gut. TDD und BDD sind keine Zeremonien. Sie sind eine andere Reihenfolge des Denkens: Du legst fest, was "funktioniert" heißt, schreibst einen Test, der es beweist, und schreibst dann den Code, der besteht.

Rails gibt dir dafür auf jeder Ebene alles, was du brauchst, mit RSpec, Minitest und Cucumber. KI macht die mühsamen Teile schneller, solange das Urteil bei einem Menschen bleibt. Das Ergebnis sind weniger Fehler, schnellere Änderungen und die Sicherheit, auszuliefern, wann immer du bereit bist, weil die Suite dir den Rücken freihält.

Footnotes

  1. Herb Krasner, Cost of Poor Software Quality in the U.S.: A 2022 Report, Consortium for Information and Software Quality (CISQ), 2022, https://www.it-cisq.org/the-cost-of-poor-quality-software-in-the-us-a-2022-report/

  2. U.S. Securities and Exchange Commission, In the Matter of Knight Capital Americas LLC, Administrative Proceeding File No. 3-15570, Release No. 34-70694, 16. Oktober 2013, https://www.sec.gov/litigation/admin/2013/34-70694.pdf

  3. Reuters, via The Irish Times, Spain plane crash may have been due to missing data, 10. Juni 2015, https://www.irishtimes.com/news/world/europe/spain-plane-crash-may-have-been-due-to-missing-data-1.2244727

  4. CrowdStrike, External Technical Root Cause Analysis: Channel File 291, 6. August 2024, https://www.crowdstrike.com/wp-content/uploads/2024/08/Channel-File-291-Incident-Root-Cause-Analysis-08.06.2024.pdf

  5. Nachiappan Nagappan, E. Michael Maximilien, Thirumalesh Bhat und Laurie Williams, Realizing quality improvement through test driven development: results and experiences of four industrial teams, Empirical Software Engineering 13, 289 bis 302, 2008, https://www.microsoft.com/en-us/research/wp-content/uploads/2009/10/Realizing-Quality-Improvement-Through-Test-Driven-Development-Results-and-Experiences-of-Four-Industrial-Teams-nagappan_tdd.pdf

  6. Dan North, Introducing BDD, zuerst erschienen im Magazin Better Software, März 2006, https://dannorth.net/blog/introducing-bdd/

  7. Cucumber, Gherkin reference (Schlüsselwörter und gesprochene Sprachen), https://cucumber.io/docs/gherkin/reference/

  8. thoughtbot, factory_bot Getting Started (Konfiguration für RSpec und Cucumber), https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md

  9. Cucumber, cucumber-rails README, https://github.com/cucumber/cucumber-rails

  10. Capybara, README, https://github.com/teamcapybara/capybara

  11. Ruby on Rails Guides, Testing Rails Applications, https://guides.rubyonrails.org/testing.html

  12. RSpec, rspec-rails README (unterstützte Versionen, Request Specs), https://github.com/rspec/rspec-rails

  13. thoughtbot, shoulda-matchers README (Kompatibilität), https://github.com/thoughtbot/shoulda-matchers

  14. Bundesministerium der Justiz und für Verbraucherschutz, Gesetz über die Beaufsichtigung von Zahlungsdiensten (Zahlungsdiensteaufsichtsgesetz, ZAG), Gesetze im Internet, https://www.gesetze-im-internet.de/zag_2018/

  15. Europäisches Parlament und Rat, Richtlinie (EU) 2015/2366 über Zahlungsdienste im Binnenmarkt, EUR Lex, 2015, https://eur-lex.europa.eu/legal-content/DE/TXT/HTML/?uri=CELEX:32015L2366

  16. Europäisches Parlament und Rat, Verordnung (EU) 2017/745 über Medizinprodukte, Artikel 2 und Erwägungsgrund 19, EUR Lex, 2017, https://eur-lex.europa.eu/legal-content/DE/TXT/HTML/?uri=CELEX:32017R0745

  17. RTCA, DO-178C Software Considerations in Airborne Systems and Equipment Certification, 13. Dezember 2011, https://my.rtca.org/productdetails?id=a1B36000001IcmqEAC

  18. Europäische Kommission, Cyber Resilience Act, Shaping Europe's digital future, https://digital-strategy.ec.europa.eu/en/policies/cyber-resilience-act

  19. Ruby on Rails Guides, Ruby on Rails 7.2 Release Notes, 2024, https://guides.rubyonrails.org/7_2_release_notes.html

  20. Europäisches Parlament und Rat, Verordnung (EU) 2016/679 (Datenschutz Grundverordnung), Artikel 7, 17 und 20, EUR Lex, 2016, https://eur-lex.europa.eu/legal-content/DE/TXT/HTML/?uri=CELEX:32016R0679

Wenn deine Rails App eher auf Hoffnung als auf einer Testsuite ausgeliefert wird, helfe ich dir, den kritischen Pfad zu finden und Tests darum zu legen. Fang mit dem kostenlosen Workshop an, und die ersten zwei Wochen Arbeit gehen auf meine Rechnung.