Source: TesterHome
It’s been quite a while since I last published a technical blog post. This article shares hands-on team experience rolling out Test-Driven Development (TDD) on a live production Ruby on Rails project.
While all practical cases and tooling examples use the Rails tech stack, every core testing principle outlined below transfers seamlessly to software engineering teams working with other programming frameworks.
Our team spent multiple months iterating on a brand-new internal product. As the V1 release neared completion, I documented our complete testing system and actionable takeaways for fellow developers.
This project marks my first full end-to-end product built entirely from scratch with Ruby on Rails. Compared to my previous work at ByteDance, building a Rails project from zero brought distinct new engineering challenges:
I often reference a quote from Miracles of the Namiya General Store to describe this blank-canvas development state: a blank sheet of paper allows you to draw anything you envision.
To stabilize consistent code quality and reduce cross-team QA overhead, we standardized TDD as our primary development workflow. After roughly one month of iteration, our repository contained more than 500 automated test cases — the actual business and stability improvements exceeded all initial team expectations.
When designing automated test suites, engineering teams usually fall into one of two polarised testing strategies: top-down end-to-end testing, or bottom-up exhaustive unit testing. Neither model delivers efficient results for fast-evolving business applications.
Top-down E2E testing simulates complete real-user browser interactions. Its greatest strength lies in replicating full end-to-end user journeys. Automated test scripts replace repetitive manual clicking operations and drastically cut regression testing workloads.
E2E test logic carries heavy redundant overhead for most daily development scenarios, especially simple API validation tasks. Verifying a single API endpoint via E2E automation requires five cumbersome sequential steps:
Running a complete browser environment solely to validate raw API return data creates unnecessary maintenance work with minimal practical value.
Unit tests focus on isolated code logic at the lowest functional layer. They are lightweight and fast to implement. Well-structured unit suites accelerate feature iteration and lock core business logic correctness.
Most developers waste engineering hours chasing 100% test coverage by writing independent assertion cases for every private helper method. This pursuit generates nearly zero tangible business value.
The following simple Ruby class contains one public entry method and four private helper functions:
class A
def end_method
return_a + return_b + return_c + return_d
end
private
def return_a; 'a' end
def return_b; 'b' end
def return_c; 'c' end
def return_d; 'd' end
end
Developers fixated on perfect coverage will write separate validation logic for all private methods:
RSpec.describe A do
describe '#end_method' do
let(:instance) { A.new }
it 'verifies outputs of all private helper functions' do
expect(instance.send(:return_a)).to eq('a')
expect(instance.send(:return_b)).to eq('b')
expect(instance.send(:return_c)).to eq('c')
expect(instance.send(:return_d)).to eq('d')
end
it 'concatenates all helper outputs correctly' do
expect(instance.end_method).to eq('abcd')
end
end
end
Professional engineers only validate public entry points. All underlying private logic gets implicitly covered through public method execution, simplifying the test suite drastically:
RSpec.describe A do
describe '#end_method' do
let(:instance) { A.new }
it 'concatenates all helper outputs correctly' do
expect(instance.end_method).to eq('abcd')
end
end
end
Perfect test coverage metrics only appear impressive on dashboards, without delivering measurable software quality gains. Excessive trivial test cases raise long-term maintenance costs:
If one core utility function links to dozens of individual test cases, team members avoid refactoring related code. Every minor logic adjustment requires updating dozens of matching assertions.Given the clear flaws in both extreme testing models, our team built a balanced, startup-friendly pragmatic testing framework. The following breakdown provides actionable rules for developers hesitant to adopt scalable automated testing.
Our core product is a WeChat Mini Program. The Ruby on Rails backend maintains two core supporting systems:
All code demonstrations below use Rails-specific syntax, yet every testing principle applies universally to all development tech stacks.
As codebases scale, repositories accumulate hundreds of classes and thousands of independent methods. Writing blind test cases for every routine function creates redundant mental overhead and wastes limited engineering bandwidth.
Writing dedicated test suites for trivial one-line helpers delivers zero development value:
def return_true
true
end
# Redundant, low-value test case
RSpec.describe '#return_true' do
it 'returns boolean true' do
expect(return_true).to be(true)
end
end
Avoid standalone test cases targeting private methods unless the private logic contains deeply nested multi-branch conditional code.
Private helper functions exist solely to support public API interfaces. Rigorous boundary testing on public entry points automatically validates all underlying private implementation logic. If a private helper triggers production defects later, add targeted reproduction test cases retroactively to block future regressions.
This workflow directly connects to our second core standard: bug-driven test coverage.
Many junior developers struggle to identify high-value test targets. They fill the entire repository with generic unit tests purely to inflate coverage metrics — this counts as low-value busywork masking poor testing strategy.
This coverage-first mindset only makes sense if team KPIs tie compensation to test line count. For fast-moving startups with tight release cycles, the practice wastes critical engineering resources.
This methodology aligns with core guidance from The Pragmatic Programmer. Our team built hundreds of high-impact test cases through this process, covering three common bug scenarios:
Targeted test cases deliver maximum return on investment with minimal maintenance overhead, and eliminate repeated identical production bugs. Teams retrofitting tests onto legacy code can also adopt this method to build lean, functional test suites instead of padding coverage with meaningless boilerplate assertions.
Frontend page HTML markup and CSS styles change far more frequently than stable backend business logic. Writing exhaustive exact-structure assertions for every UI component will break dozens of unrelated test cases after minor visual adjustments.
The following snippet uses rspec-html-matchers to validate exact CSS class ordering — a pattern unsuitable for production deployment:
RSpec.describe page markup rendering do
it 'validates exact CSS class order on paragraph elements' do
html = '<p class="qwe rty" id="qwerty">Paragraph</p>'
expect(html).to have_tag('p', with: { class: 'qwe rty' })
expect(html).to have_tag('p', with: { class: 'rty qwe' })
expect(html).to have_tag('p', with: { class: ['rty', 'qwe'] })
expect(html).to have_tag('p', with: { class: ['qwe', 'rty'] })
end
end
The rspec-html-matchers library includes this example only to showcase feature functionality, not as production-ready standard code. Renaming a single CSS class will break all linked test cases, requiring tedious manual updates across the full suite.
Minor UI tweaks (adding or removing buttons, adjusting layout spacing) carry negligible business failure risk. Rigid markup assertions create constant unproductive test maintenance work.
Only write UI-specific test cases for confirmed critical production defects. If core workflow action buttons fail to render under specific order states, add targeted validation logic during bug fixes to lock stable UI behaviour permanently.
Nearly all backend REST APIs follow standard CRUD logic, either fetching stored database data or mutating persistent records. Our team standardised a four-step unified API testing workflow for every endpoint:
Our team integrates the rswag gem for all API test suites, fully implementing the “tests as living documentation” development philosophy. Rswag auto-generates complete Swagger/OpenAPI documentation directly from RSpec test definitions, removing redundant manual documentation writing work.
This complete spec defines a blog detail GET endpoint with success and error response validation:
describe 'Blogs API' do
path '/blogs/{id}' do
get 'Fetch a single blog article' do
tags 'Blogs'
produces 'application/json'
parameter name: :id, in: :path, type: :string
response '200', 'Blog article retrieved successfully' do
let(:id) { Blog.create(title: 'foo', content: 'bar').id }
run_test! do |response|
# Insert custom assertions for required response fields and data types
end
end
response '404', 'Target blog article does not exist' do
let(:id) { 'invalid-fake-id' }
run_test!
end
end
end
end
The nested rswag DSL syntax feels unintuitive for new developers, yet its structure directly maps to official OpenAPI schema specifications. Engineers gain familiarity with repeated use. Official rswag documentation remains relatively limited, so cross-referencing standard Swagger OpenAPI specs speeds up parameter and response configuration.
We built dedicated test suites for approximately 55 independent API endpoints following this workflow. Post-implementation production bug volumes dropped significantly. Most API development work now completes entirely within test files — manual browser verification is rarely required, as automated specs catch missing response fields and invalid data states faster than manual QA checks.
Most developers adopt the passive mindset: “We will write supporting tests after feature code completion.” Real production experience proves retrofitting tests onto finished code is an inefficient, labour-intensive anti-pattern.
Retrospective testing requires full re-read of existing code, even for modules written by the same engineer months prior. When test executions fail, developers hesitate to modify stable production code to avoid breaking live functionality. Instead of fixing flawed root logic, developers adjust test assertions to accommodate defective implementations. This back-and-forth adjustment creates massive unproductive engineering overhead.
Unless client contracts explicitly mandate post-hoc coverage requirements or teams have unlimited spare bandwidth, retrofitting tests remains low-efficiency work. For long-term maintainable automated testing pipelines, write test cases alongside feature implementation from day one.
Early project development relied on Rails native fixtures to populate test database records. Fixture execution speed is fast, yet they generate rigid, tightly coupled test datasets that become unmanageable as codebases expand.
The team migrated fully to factory_bot for dynamic test database record generation. Full test suite runtime slows slightly, yet expressive factory syntax drastically accelerates spec writing speed. This tradeoff mirrors core comparisons between dynamic and static programming languages.
Our complete suite of roughly 500 test cases finishes execution in 5–7 minutes on local developer machines, with clear optimisation room via parallel test running configurations.
Rails ships with native Minitest, which delivers all core functionality required for independent API validation: request construction, HTTP call dispatching, status code and payload assertion logic.
The team selected RSpec exclusively due to hard DSL dependency requirements from rswag (our OpenAPI auto-documentation tool). RSpec is not a mandatory standard for all Rails projects. The open-source Homeland forum project hosted on Ruby China operates entirely on Minitest with stable, maintainable test suites. Teams may select frameworks based on personal syntax and documentation preferences.
A mature sustainable development workflow combines mandatory pull request code reviews with automated CI pipeline execution. Our repository enforces a strict merge block rule: all pull requests cannot merge if the complete test suite returns failures. Successful merged commits trigger automatic staging environment deployments.
CI infrastructure offloads full-suite test execution to cloud servers, freeing local developer workstations to run small targeted test subsets during daily feature iteration. Our codebase repository hosts on GitHub, with initial CI execution powered by CircleCI cloud runners. Self-hosted CI runners provide a viable alternative option for larger enterprise engineering teams.
CircleCI’s free tier weekly compute credit allocation exhausted every Wednesday. This forced all team members to execute full local test suites for the remainder of each week. The long-term solution is migrating to self-hosted CI infrastructure to eliminate compute credit limitations.
This article summarises core real-world takeaways from full TDD rollout across a production-grade Ruby on Rails application. A widespread industry misconception claims automated testing reduces development velocity — our team’s practical data shows the exact opposite result.
API development efficiency improves drastically without constant manual browser refreshes for endpoint validation. Automated test suites eliminate human error during manual payload inspection and catch regressions instantly during large-scale refactoring cycles. While new developers face an initial learning curve mastering effective test writing, the long-term business benefits remain unambiguous: dramatic permanent reductions in production defect volume.