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 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:
| RSpec | Minitest | Cucumber | |
|---|---|---|---|
| Readability for developers | Very high | High, terse | High |
| Readability for the business | Low | Low | Very high |
| What you learn first | A DSL (a small language of its own) | Just Ruby | Gherkin plus step definitions |
| Ecosystem | Largest | Smaller, Rails defaults built in | Works with either |
| Speed | Fast | Fastest | Slowest, 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.
Industries where a bug is a legal problem
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
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.
- Think through the test strategy yourself. What are the critical paths, the business rules, the regulatory requirements? This is human work.
- Ask AI for the edge cases you haven't considered. Describe the logic and ask what a test suite should cover.
- 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.
- 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.
- 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
-
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/
-
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
-
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
-
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
-
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
-
Dan North, Introducing BDD, first published in Better Software magazine, March 2006, https://dannorth.net/blog/introducing-bdd/
-
Cucumber, Gherkin reference, https://cucumber.io/docs/gherkin/reference/
-
thoughtbot, factory_bot Getting Started (RSpec and Cucumber configuration), https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md
-
Cucumber, cucumber-rails README, https://github.com/cucumber/cucumber-rails
-
Capybara, README, https://github.com/teamcapybara/capybara
-
Ruby on Rails Guides, Testing Rails Applications, https://guides.rubyonrails.org/testing.html
-
RSpec, rspec-rails README (supported versions, request specs), https://github.com/rspec/rspec-rails
-
thoughtbot, shoulda-matchers README (compatibility), https://github.com/thoughtbot/shoulda-matchers
-
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
-
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
-
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
-
RTCA, DO-178C Software Considerations in Airborne Systems and Equipment Certification, 13 December 2011, https://my.rtca.org/productdetails?id=a1B36000001IcmqEAC
-
European Commission, Cyber Resilience Act, Shaping Europe's digital future, https://digital-strategy.ec.europa.eu/en/policies/cyber-resilience-act
-
Ruby on Rails Guides, Ruby on Rails 7.2 Release Notes, 2024, https://guides.rubyonrails.org/7_2_release_notes.html
-
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
-
Commercial Vehicle Roadworthiness Testing, Vans and jeeps, cvrt.ie, accessed 8 November 2026, https://www.cvrt.ie/Test-Bookings/Vans-and-Jeeps/