TDD and BDD in Ruby on Rails: how to ship without fear, and how AI changes the work

TL;DR

Difficulty: Intermediate

  • TDD means writing a small failing test first, then the least code that passes, then tidying up. In one study of four industrial teams it cut pre release defects by 40% to 90%, at a cost of 15% to 35% more initial development time.
  • BDD writes the behaviour in plain Given, When, Then language a business can read, and runs it as a test.
  • In Rails: RSpec for unit and request specs, Cucumber for the critical journeys, Minitest if you want the default with no extra gems.
  • Test the critical path (login, core logic, payments, data, compliance, APIs) on every release, and let the pipeline block anything red.
  • Use AI to find edge cases and draft scaffolding. A person owns the strategy and reviews every test.

Most software is still written first and checked later, and the bill arrives in production. This article explains TDD and BDD from the ground up, compares RSpec, Minitest and Cucumber with code that runs, and shows how I use AI to write tests faster without letting it decide what correct means.

The most expensive line of code I have seen

It wasn't complicated. It wasn't clever. It was a rounding error in a currency conversion function: three decimal places instead of two. In the test environment nobody noticed. In production, processing thousands of transactions a day for a fintech client, it created a discrepancy that took two weeks to untangle. The fix took four minutes. The cleanup took four hundred hours.

That changed how I write software. Not because I learned to be more careful (although that too), but because I learned that the usual order of work is the problem. Write the code, then test it, then fix the bugs, then test again, then find more bugs, then ship it and hope. That isn't a method. It's a bet.

There are two better ways, Test Driven Development (TDD) and Behaviour Driven Development (BDD), and Ruby on Rails is one of the best places to practise both. This article explains what each one is, which Rails tools do which job, what has to be tested before every release, and how to use AI to write tests faster without handing it the judgement.

Code first, test later, and why it keeps failing

For decades the usual approach went like this. Someone writes a specification. Developers read it (sometimes). Developers write code. Someone tests it, usually by hand, usually under time pressure. Bugs get found and fixed. The fixes introduce new bugs. Eventually someone says "good enough" and it ships.

The bill for that habit is large. The Consortium for Information and Software Quality (CISQ) estimates that poor software quality cost the United States at least $2.41 trillion in 2022.1 That is a broad estimate across a whole economy, but the individual cases show what it is made of.

On 1 August 2012 Knight Capital deployed new trading code to seven of its eight servers. A technician did not copy it to the eighth, and no second person reviewed the deployment. Old, dormant code on that server woke up, sent millions of orders into the market in about 45 minutes, and Knight lost more than $460 million.2

In May 2015 an Airbus A400M crashed near Seville on a test flight and four of the six crew died. The main line of investigation reported at the time was that engine calibration data had been accidentally wiped during a software installation.3

On 19 July 2024 CrowdStrike shipped a content update that crashed Windows machines running its sensor around the world. Its own root cause analysis lists, among the causes, "the lack of a specific test" for the input that triggered the crash.4

These are not exotic edge cases. They are what happens when checking comes last, or not at all.

TDD: write the test first

Test Driven Development reverses the order. You write a test for one small behaviour, watch it fail, write the least code that makes it pass, and then tidy up. This is the red, green, refactor cycle.

  • Red. Write a test that describes the behaviour you want. Run it. It fails, because the code doesn't exist yet.
  • Green. Write the simplest code that makes the test pass. Nothing more. Run it again. It passes.
  • Refactor. Clean up the implementation. Run the tests again. Still green? Move on.

The red, green, refactor cycleTest Driven Development as a loop: write a small failing test, make it pass with the least code, refactor while the tests stay green, then start again with the next behaviour.Write one small test for the behaviouryou wantRun it and watch it fail (red)Write the least code that makes it passDoes every test pass?noNo: fix the code, never the test, andrun againyesYes: refactor the code, then run everytest againStart the next small behaviour

The diagram reads top to bottom as one turn of the loop. The point it makes is that the test exists before the code, and the tests are run after every change, including the tidy up. Each turn is small, often a few minutes, and a day of work is dozens of them. At the end you have a feature and a suite of tests that proves it works.

Does it pay? The best known industrial study, by Nagappan and colleagues, followed three teams at Microsoft and one at IBM that adopted TDD. Their pre release defect density fell by between 40% and 90% compared with similar projects that did not use it. The teams also felt their initial development took 15% to 35% longer.5 That is the honest trade: more time up front, far fewer bugs later.

Worked example: a currency converter for Abbey Pay

Abbey Pay is an invented payments app in Limerick. Its customers hold euro and sometimes pay suppliers in US dollars, so it needs a converter. First, the tests, in RSpec:

require "rails_helper"

RSpec.describe CurrencyConverter do
  describe "#convert" do
    it "converts euro to US dollars at the given rate" do
      converter = CurrencyConverter.new(rate: BigDecimal("1.15"))
      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"))
    end
  end
end

Run them. They fail, because CurrencyConverter doesn't exist. That is the red step. Now the least code that passes:

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

Check the second test by hand. €33.33 times 1.0735 is 33.33 plus 33.33 times 0.0735, which is 33.33 plus 2.449755, which is 35.779755. Rounded to two places, that is $35.78. Green.

Two choices in that code matter. Money is held as BigDecimal, never as a float, because floats store binary fractions: in Ruby, 0.1 + 0.2 == 0.3 is false. And the rounding rule is written down rather than left to a default. Now I can refactor and add tests for the edge cases (a zero rate, a negative amount, an unsupported currency) one at a time. That rounding test is exactly the kind that would have caught the three decimal place bug I opened with.

BDD: describe the behaviour the user sees

Behaviour Driven Development takes TDD a step further. TDD asks whether a method returns the right value. BDD asks whether a person can complete what they came to do.

Dan North developed BDD from the end of 2003 onwards, when he started writing JBehave as a replacement for JUnit, because developers kept asking him where to start with TDD, what to test and what to call their tests.6 His answer was to talk about behaviour instead of tests, in words a business can read.

In Rails, the usual tool for this is Cucumber. Its scenarios are written in Gherkin, where each step starts with Given, When, Then, And or But, and the whole file is an executable specification.7

Worked example: searching vans at a Galway van hire firm

Lough Atalia Van Hire is an invented van rental business in Galway, with its shop built on Rails. The search feature, in Gherkin:

Feature: Van search
  As someone hiring a van in Galway
  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"

Read that out loud. The owner can follow it. A designer can follow it. Your business partner who has never opened a code editor can follow it. The scenario is documentation that cannot go stale, because the moment it stops being true, it fails.

Behind each line sits a step definition in Ruby that turns the words into actions:

Given("there are {int} {word} {word} vans") do |count, make, model|
  create_list(:van, count, make: make, model: model)
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

Three details make this run. create_list comes from FactoryBot, and Cucumber only sees it once World(FactoryBot::Syntax::Methods) is in a file under features/support.8 visit, fill_in and click_button come from Capybara, which cucumber-rails sets up for you.910 And the first step uses {word} twice, so the same definition serves both "Ford Transit" and "Opel Vivaro". I go deeper into writing scenarios like these in Cucumber BDD, the secret weapon behind every project I deliver.

RSpec, Minitest and Cucumber: what each is for

Rails takes testing seriously out of the box. A new app gets a test directory and a test_helper.rb, and the generators write a test file for each model they create. The default library is Minitest.11 I have written before about why that discipline is part of what rapid development frameworks get right over raw PHP. There are three main tools to choose from.

RSpec is the main alternative to the default, and the one I reach for. It reads like a specification, with describe, context and it blocks. Through the rspec-rails gem it supports Rails 7.2, 8.0 and 8.1 in its 8.x versions, and you install it with rails generate rspec:install.12 It suits medium and large codebases and any team that values readable tests (which should be every team).

Minitest ships with Rails. It is plain Ruby with very little to learn: if you know Ruby, you know Minitest. It suits smaller projects and people who prefer fewer moving parts.

Cucumber is for acceptance tests: the user journeys, written in Gherkin, that a non technical owner can read and sign off.

My honest comparison:

RSpecMinitestCucumber
Readability for developersVery highHigh, terseHigh
Readability for the businessLowLowVery high
What you learn firstA DSL (a small language of its own)Just RubyGherkin plus step definitions
EcosystemLargestSmaller, Rails defaults built inWorks with either
SpeedFastFastestSlowest, it drives the whole app

The table is my judgement from using all three, not a benchmark. In practice the speed difference between RSpec and Minitest rarely decides anything. What decides it is whether your team will actually read and maintain the tests.

My recommendation: RSpec for unit and request specs, Cucumber for the critical user journeys. RSpec carries the bulk (models, services, requests), Cucumber covers the end to end flows that matter most to the business. One tip on request specs: both the Rails and RSpec teams discourage testing controllers directly, and recommend request specs, which start with an HTTP request and end with the response.12

Worked example: the same rule in RSpec and in Minitest

Lough Atalia's vans need a make, a model and a positive daily rate, and a title for the listing. In RSpec, with the shoulda-matchers gem, which supports Rails 7.2 and later: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) }
  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

The same in Minitest, using a fixture called 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)
    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

Both prove the same rules. The RSpec version reads closer to a sentence. The Minitest version needs no extra gems. Either is a fine choice, and mixing them in one app is not.

Not all bugs are equal. A misaligned button is annoying. A rounding error in a payment is a compliance problem. A fault in medical software can hurt someone.

Payments and banking. Payment software operates under payment services law, in Ireland the European Union (Payment Services) Regulations 2018 (S.I. No. 6 of 2018), which give effect to the second Payment Services Directive (PSD2),1415 plus anti money laundering rules and data protection law. Every transaction must be right to the cent and every audit trail complete. Knight Capital is the story the whole industry remembers.2

Healthcare. Under the EU Medical Device Regulation, standalone software intended for a medical purpose can itself be a medical device.16 Testing there is about patient safety, not only function.

Aviation. Airborne software is built to RTCA DO-178C, "Software Considerations in Airborne Systems and Equipment Certification", the main route to approval for software in civil aircraft.17

eCommerce. Less dramatic, still expensive. A broken checkout during a sale loses every order it touches, and a wrong price can create a legal problem as well as a refund. If you sell software or connected products into the EU, the Cyber Resilience Act adds security duties: it entered into force on 10 December 2024, its reporting obligations apply from 11 September 2026, and the main obligations from 11 December 2027.18 I cover who it applies to in the Cyber Resilience Act article.

The critical path: what must pass before every release

You can't test everything and still ship. The skill is finding the critical path: the few things that must work for the application to be usable, secure and compliant. For most web applications that means:

  • Authentication and authorisation. Can people log in, and only see what they should? Are admin functions restricted? A failure here is a breach.
  • Core business logic. Whatever the application actually does: the calculations, workflows and data changes at its heart.
  • Payments. Every state (pending, completed, failed, refunded) and every awkward case (a timeout mid payment, a double submission, a partial refund).
  • Data integrity. Constraints, validations and migrations. If data can get corrupted, it will.
  • Compliance features. Consent, data export and erasure, audit logs, fraud checks. If a regulator asks, these must work.
  • API contracts. If you expose or consume an API, a breaking change can cascade through every system that depends on it.

Every one of these is covered by automated tests that run before every deployment. No "I'll test it by hand this time". No "it worked on my machine". The pipeline is the gatekeeper. Since Rails 7.2, a new app even comes with a GitHub Actions workflow that runs the tests on every push.19

The release gateEvery change goes through the same gate: the fast specs run first, then the user journeys, and any failing critical path test stops the deployment.A pull request is openedModel, service and request specs runCucumber runs the critical userjourneysIs any test red?yesYes: the deployment stops until it isfixednoNo: merge and deployThe same suite runs again on main

Read it from top to bottom. There is one decision on the path, and only one answer lets the code through. Nobody has to remember to run the tests, and nobody can wave a red one through.

Testing a payments app under the GDPR

Back to Abbey Pay. It handles real money, real transactions and personal data, so the GDPR applies. Three of its rights become tests: the controller must be able to demonstrate consent (Article 7), people can ask for erasure (Article 17), and they can receive their data in a structured, commonly used, machine readable format (Article 20).20 I go deeper into building these features into a SaaS elsewhere.

Worked example: the personal data specs

These are request specs. auth_headers is a small helper you write once in spec/support, and the factories and traits are the example's assumptions.

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)
      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
      delete api_v1_user_path(user), headers: auth_headers(admin)
      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)
      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

The erasure test keeps the transactions on purpose: Article 17 has exceptions, including where the law requires records to be kept. The test also checks that there were transactions to begin with, otherwise it could pass on an empty list and prove nothing.

Worked example: transfers that cannot half happen

Money moves need to be all or nothing. The service, kept short:

class TransactionService
  class InsufficientFunds < StandardError
  end

  def transfer(from:, to:, amount:)
    ActiveRecord::Base.transaction do
      from.lock!
      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

And the specs that pin it down, assuming debit! and credit! are small methods on the Wallet model:

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

Walk through the numbers: €100 minus €30 is €70, €50 plus €30 is €80. In the failure case the credit raises, the database transaction rolls back, and both balances are where they started. The last line expects "10.0" because that is how Ruby writes BigDecimal("10.00") as a string.

These run on every pull request, every merge to main and before every deployment, and the pipeline blocks deployment if any of them fails. There is no manual override. In fintech, you don't ship untested code. Ever.

Testing an eCommerce shop on Solidus

Lough Atalia Van Hire's shop is built on Rails, with Solidus handling the cart, checkout and payments. The character of the testing is different, the rigour is the same. The critical path is search and filtering, booking, payment, and the admin screens the staff use to manage the fleet.

Worked example: a booking, from journey to pricing rule

The journey, in Gherkin:

Feature: Van hire booking
  As a customer of Lough Atalia Van Hire
  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

And the pricing rule underneath, with the example's own assumptions: the return day is not charged, a same day hire counts as one day, and Saturdays and Sundays cost 15% more.

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

  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
    (@start_date..last_charged).to_a
  end

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

Check every step. Tuesday 3 March to Saturday 7 March 2026 charges Tuesday, Wednesday, Thursday and Friday: four weekdays at €65, which is €260.00. Friday 27 February to Monday 2 March charges Friday at €65, then Saturday and Sunday at €65 times 1.15, which is €74.75 each: €65 plus €74.75 plus €74.75 is €214.50. A same day hire charges the one day, €65.00.

The dates are not an accident. The earlier version of this article used 1 to 5 March, and 1 March 2026 is a Sunday, so the "simple" total of €260 would really have carried a surcharge. Checking the test against a real calendar caught it. That is the whole argument for testing in one paragraph.

Notice how the two layers work together. The Cucumber scenario tells you whether the journey works for a customer. The RSpec specs tell you exactly which rule broke when it doesn't.

Using AI to write tests without losing control

Writing tests is essential, and it is also tedious. A thorough suite for a Rails application runs to hundreds or thousands of cases, and there is always an edge case nobody thought of. This is where AI assistants such as Claude genuinely help, if you use them the right way. What the research says about AI and developer productivity, including the METR study that found experienced developers slower with AI, is set out in will AI like ChatGPT ever replace developers, and who carries the responsibility for AI written code is in AI writes the code, who is responsible for it. I won't repeat the evidence here. This section is how I use it for tests.

The wrong way: "write tests for my application". Paste your code in and you'll get tests. They might even pass. But in my view they'll tend to be shallow, test the implementation rather than the behaviour, and miss the cases that only your business knows about. Unreviewed AI tests are worse than none, because they make you think you're covered when you're not.

The tool doesn't know your domain. It doesn't know that Abbey Pay must cope with a conversion requested on a Sunday when the rate feed is down. It doesn't know that Lough Atalia has to stop a hire when a van's certificate of roadworthiness, renewed at the yearly CVRT, runs out halfway through the booking.21 It writes tests from code patterns, not from business knowledge.

The right way: human directed, AI assisted. The approach I've developed over the past year has five steps.

  1. Think through the test strategy yourself. What are the critical paths, the business rules, the regulatory requirements? This is human work.
  2. Ask AI for the edge cases you haven't considered. Describe the logic and ask what a test suite should cover.
  3. Let AI write the scaffolding. Give it your style, your factories and your naming, and let it draft the repetitive parts. Then review every one, change them, delete the ones that make no sense, and add the assertions only you know to make.
  4. Never trust, always verify. Treat every generated test as a first draft. Read it line by line. Would it fail if the code were broken? Could it pass for the wrong reason? I have a personal rule: if I can't explain what a test does and why it matters in one sentence, it gets rewritten or deleted.
  5. Use AI as a second reviewer. Paste the finished tests back and ask for logical flaws, redundant tests and assertions that are too loose. This catches tests that pass for the wrong reasons.

Worked example: the cent that goes missing

An example of step two. The prompt:

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?

The kind of list you get back: all scores equal, one score zero, every score zero, a negative score, a budget of zero, a budget smaller than the number of campaigns, a single campaign, a very large number of campaigns, scores that change during the split, the shares not adding up to the budget after rounding, and whether to hold money internally in cents. When I ran this exercise on a budget allocation service, the list held at least three cases I hadn't explicitly planned to test: the rounding gap, the tiny budget and the internal cents. That is the value.

Take the rounding gap with invented numbers. A Kilkenny shop splits €1.00 between three campaigns with equal scores. Each share is €1.00 divided by three, which is €0.3333 recurring, rounded to €0.33. Three times €0.33 is €0.99. A cent has vanished. Every day, on every budget.

The test that pins it down says the shares always add up to the budget:

it "allocates every cent of the budget" do
  shares = BudgetSplitter.new(budget_cents: 100, scores: [1, 1, 1]).split
  expect(shares.sum).to eq(100)
  expect(shares).to contain_exactly(34, 33, 33)
end

One way to pass it: work in whole cents, give each campaign its share rounded down (33, 33, 33, which is 99), then hand the leftover cent to the campaign with the largest remainder. With equal scores the tie goes to the first, so the result is 34, 33 and 33, which adds up to 100. The AI suggested the case. A person decided what correct means.

The framework: human directed, AI assisted

All of that in six lines:

  • You own the strategy. A person decides what to test, why, and what counts as passing. The tool never does.
  • AI finds the unknowns. Use it to brainstorm edge cases and boundaries. A thinking partner, not a replacement for thinking.
  • AI drafts the scaffolding. The boilerplate, the factories, the repetitive setup.
  • You review everything. No test enters the suite that a person hasn't read and understood.
  • AI gives a second opinion. Ask it to critique the finished tests.
  • The suite is your responsibility. When a test fails you must know why. When a bug slips through you must know which test was missing.

This has held up on real projects. On one Solidus build, this approach got me thorough coverage of search, booking and payment in roughly half the time it would have taken to write every test by hand. More importantly, the edge case brainstorm caught three scenarios that would have been real production bugs: a date calculation error when a booking spanned a change to or from daylight saving time, a race condition when two people booked the same vehicle at once, and a checkout case where a discount code applied after adding a service package produced the wrong total.

On a fintech SaaS it was particularly useful for the data protection tests. The rules are detailed, and the tool's reasoning about them helped me build a more thorough consent test suite than I would have built alone. Every one of those tests was checked against the text of the regulation before it was committed.

Testing is not a tax

I've been building software long enough to remember when testing was optional. When "it works on my machine" was an acceptable release criterion. When hotfixes were simply part of the week.

That era is over. The stakes are too high, the rules too strict and the tools too good. TDD and BDD are not ceremonies. They are a different order of thinking: you define what "works" means, write a test that proves it, and then write the code that passes.

Rails gives you everything you need for this at every level, with RSpec, Minitest and Cucumber. AI makes the tedious parts faster, as long as the judgement stays with a person. The result is fewer bugs, faster changes and the confidence to ship whenever you're ready, because the suite has your back.

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 October 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 June 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 and Laurie Williams, Realizing quality improvement through test driven development: results and experiences of four industrial teams, Empirical Software Engineering 13, 289 to 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, first published in Better Software magazine, March 2006, https://dannorth.net/blog/introducing-bdd/

  7. Cucumber, Gherkin reference, https://cucumber.io/docs/gherkin/reference/

  8. thoughtbot, factory_bot Getting Started (RSpec and Cucumber configuration), 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 (supported versions, request specs), https://github.com/rspec/rspec-rails

  13. thoughtbot, shoulda-matchers README (compatibility), https://github.com/thoughtbot/shoulda-matchers

  14. Minister for Finance, European Union (Payment Services) Regulations 2018, S.I. No. 6 of 2018, regulation 1 and preamble, Irish Statute Book, 2018, https://www.irishstatutebook.ie/eli/2018/si/6/made/en/print

  15. Directive (EU) 2015/2366 on payment services in the internal market, EUR-Lex, 2015, https://eur-lex.europa.eu/eli/dir/2015/2366/oj/eng

  16. Regulation (EU) 2017/745 on medical devices, Article 2 and recital 19, EUR-Lex, 2017, https://eur-lex.europa.eu/eli/reg/2017/745/oj/eng

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

  18. European Commission, 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. European Parliament and Council, Regulation (EU) 2016/679 (General Data Protection Regulation), Articles 7, 17 and 20, EUR-Lex, 2016, https://eur-lex.europa.eu/eli/reg/2016/679/oj/eng

  21. Commercial Vehicle Roadworthiness Testing, Vans and jeeps, cvrt.ie, accessed 8 November 2026, https://www.cvrt.ie/Test-Bookings/Vans-and-Jeeps/

If your Rails app ships on hope rather than on a test suite, I can help you find the critical path and put tests around it. Start with the free workshop, and the first two weeks of work are on me.