Test-Driven Development (TDD) is a development technique in which an automated test is written before the production code needed to satisfy it. The goal is not simply to accumulate unit tests: it is to use short feedback loops to clarify behaviour, design smaller pieces of code, detect regressions early and make refactoring safer. For QA engineers, understanding TDD is especially valuable because it connects test design directly with software design and moves quality feedback much earlier in the development lifecycle.
What Test-Driven Development is
TDD reverses the traditional sequence of “implement first, test afterwards”. Instead, a developer starts with an executable example of the behaviour that should exist, watches that test fail for the expected reason, implements the smallest useful change that makes it pass and then improves the design while keeping the suite green.
Specification
A test describes an observable behaviour before the implementation exists.
Fast feedback
Small tests reveal incorrect assumptions within seconds rather than after a larger feature is complete.
Design pressure
Code that is difficult to test often exposes excessive coupling, hidden dependencies or unclear responsibilities.
Regression safety
Each new behaviour becomes part of an executable safety net for later changes.
Refactoring confidence
Internal implementation can evolve while externally expected behaviour remains protected.
Shared quality ownership
Developers, QA engineers and product stakeholders can collaborate on examples before code is written.
The Red–Green–Refactor cycle
The classic TDD loop is deliberately short:
| Step | What happens | Why it matters |
|---|---|---|
| Red | Write a small test for behaviour that does not yet exist and confirm that it fails for the expected reason. | Proves the test can detect the missing behaviour instead of passing accidentally. |
| Green | Write the minimum production code required to make the test pass. | Keeps the change small and the feedback loop fast. |
| Refactor | Improve names, duplication, structure and design while repeatedly running the tests. | Separates behavioural progress from design improvement while preserving confidence. |
A tiny example
Suppose a subscription should be considered active when its expiry date is today or later. Start with the expected behaviour:
# test/models/subscription_test.rb
require "test_helper"
class SubscriptionTest < ActiveSupport::TestCase
test "is active when expiry date has not passed" do
subscription = Subscription.new(expires_on: Date.current)
assert subscription.active?
end
end
The first run should fail because active? does not exist yet. Add only enough implementation to satisfy the behaviour:
# app/models/subscription.rb
class Subscription < ApplicationRecord
def active?
expires_on.present? && expires_on >= Date.current
end
end
Run the suite again. Once it is green, review the code and tests for duplication or poor naming, refactor if needed and then start the next behaviour — for example, an expired subscription should not be active.
TDD is a design practice, not a test level
TDD is frequently associated with unit tests because small tests give the fastest feedback, but the underlying test-first idea can be used at several levels. The right level depends on the behaviour being designed.
| Layer | Primary focus | Typical feedback speed | Example |
|---|---|---|---|
| Unit / model | One class, function or domain rule | Very fast | A subscription cannot start after its end date. |
| Controller / request | HTTP behaviour around an endpoint | Fast | Invalid registration returns validation errors. |
| Integration | Several application components working together | Medium | A signed-in user can create a subscription. |
| System / browser | User-visible journey through the real application | Slower | A visitor registers, signs in and sees the dashboard. |
| Contract / service | Boundary between systems | Fast to medium | A payment client maps provider responses correctly. |
A healthy strategy normally contains many fast lower-level tests and a smaller number of broader end-to-end tests. TDD does not remove the need for exploratory testing, accessibility testing, security testing, performance testing or production monitoring.
TDD, BDD and ATDD
These practices overlap but solve slightly different collaboration problems.
| Practice | Main emphasis | Typical question |
|---|---|---|
| TDD | Design and implementation through executable tests | What is the next small behaviour this code must provide? |
| BDD | Shared understanding of behaviour using examples and domain language | How should the system behave from a user or business perspective? |
| ATDD | Acceptance criteria agreed before implementation and automated where valuable | What evidence will prove this story is acceptable? |
In practice, a team can use all three: product, development and QA define examples together, developers use lower-level TDD while implementing the code and the team retains suitable acceptance scenarios as regression tests.
The Rails testing structure
The original learning material uses Ruby on Rails as its example application. Modern Rails still includes a built-in Minitest-based testing stack and creates a dedicated test structure for different test concerns.
test/
├── application_system_test_case.rb
├── controllers/
├── fixtures/
├── helpers/
├── integration/
├── jobs/
├── mailers/
├── models/
├── system/
└── test_helper.rb
| Location | Purpose |
|---|---|
test/models | Domain logic, validations, methods and model behaviour. |
test/controllers | Controller, routing and response-oriented behaviour where appropriate. |
test/integration | Flows crossing multiple controllers or application components. |
test/system | Full browser journeys using Capybara-backed system tests. |
test/fixtures | Reusable test records stored as fixture data. |
test/mailers | Email subject, recipients, body and delivery behaviour. |
test/jobs | Background job execution and enqueueing behaviour. |
test_helper.rb | Common test setup loaded across the suite. |
Run the suite
bin/rails test
Run a single file
bin/rails test test/models/subscription_test.rb
Run one test by line
bin/rails test test/models/subscription_test.rb:8
Fast, targeted execution is important for TDD because developers should be able to run the nearest tests repeatedly while coding and then run broader suites before integration.
Model tests: start with domain behaviour
Model tests are a natural place to learn TDD because they are fast and have little infrastructure overhead. Prefer behaviour that matters to the domain rather than tests that merely duplicate framework implementation.
Example: validation first
require "test_helper"
class UserTest < ActiveSupport::TestCase
test "requires an email address" do
user = User.new(name: "QA User")
assert_not user.valid?
assert_includes user.errors[:email], "can't be blank"
end
end
Then implement only the rule needed to make the test pass:
class User < ApplicationRecord
validates :email, presence: true
end
Prefer useful business examples
test "cannot create a subscription with an end date before the start date" do
subscription = Subscription.new(
starts_on: Date.current,
ends_on: Date.yesterday
)
assert_not subscription.valid?
assert_includes subscription.errors[:ends_on], "must be on or after the start date"
end
This describes a business rule. A future refactor can change how the rule is implemented without changing the test's intent.
The test database and fixtures
Automated tests must be repeatable. Rails separates the test environment from development and production so tests can create, update and delete records without corrupting real data.
Fixture example
# test/fixtures/users.yml
qa_user:
name: QA User
email: qa@example.com
admin:
name: Admin User
email: admin@example.com
The fixture can be referenced from a test:
test "returns the user's display name" do
user = users(:qa_user)
assert_equal "QA User", user.display_name
end
Fixtures are not the only option
Fixtures are built into Rails and remain useful for stable, named test data. Other teams prefer factories or builders where dynamic object creation is easier to read. The important quality properties are the same:
- Data should be easy to understand from the test.
- Tests should not depend on execution order.
- One test should not leave state that breaks another.
- Data setup should remain as small as the behaviour requires.
- Random data should be reproducible when failures occur.
- Sensitive production data should not be copied into test environments without an approved process.
Functional and HTTP-level testing
The course outline uses the traditional term functional tests for behaviours around controllers, passwords, sign-in forms, cookies and record creation. In modern Rails, many teams express these behaviours through request or integration-style tests because they exercise the HTTP boundary without needing a full browser.
Example: invalid sign-in
require "test_helper"
class SessionsFlowTest < ActionDispatch::IntegrationTest
test "rejects invalid credentials" do
post login_path, params: {
email: "qa@example.com",
password: "wrong-password"
}
assert_response :unprocessable_entity
assert_select "[data-test='login-error']", text: /invalid/i
end
end
Example: record creation
test "creates a subscription with valid data" do
user = users(:qa_user)
assert_difference("Subscription.count", 1) do
post subscriptions_path, params: {
subscription: {
user_id: user.id,
plan: "monthly"
}
}
end
assert_response :redirect
assert_redirected_to subscription_path(Subscription.last)
end
HTTP-level tests are valuable because they can cover routing, parameter handling, authentication, database changes and response status in one fast test without the additional cost of a browser.
Authentication, passwords and cookies
Authentication is a useful example of layered testing because each layer answers a different question.
| Concern | Best starting layer | Example assertion |
|---|---|---|
| Password validation rule | Model / unit | Passwords shorter than the minimum are rejected. |
| Login endpoint | Request / integration | Valid credentials establish a session and redirect. |
| Session or cookie behaviour | Integration | Protected routes become available after sign-in. |
| Real login form | System | A user can enter credentials and reach the dashboard. |
Do not test cryptographic algorithms themselves if they belong to a trusted framework or library. Test your application's contract with that functionality: accepted input, rejected input, session state, access rules and user-visible outcomes.
Integration tests: test complete application flows
Integration tests combine multiple pieces of application behaviour. They are especially useful for flows such as registration, sign-in/sign-out and subscription creation — all topics covered in the original learning path.
require "test_helper"
class RegistrationFlowTest < ActionDispatch::IntegrationTest
test "registers a new account and opens the dashboard" do
assert_difference("User.count", 1) do
post users_path, params: {
user: {
name: "New User",
email: "new@example.com",
password: "valid-password"
}
}
end
follow_redirect!
assert_response :success
assert_select "h1", text: /dashboard/i
end
end
What belongs in an integration test?
- Several controllers or services participate in the behaviour.
- The database state matters to the final outcome.
- Authentication or session state is part of the scenario.
- You need confidence in HTTP wiring but do not need a real browser.
- A unit test would require so many mocks that it would no longer represent the real flow.
System tests: exercise the product as a user
Rails system tests run the application through a browser using Capybara. They provide valuable confidence for critical journeys but are slower and more expensive to maintain than lower-level tests, so use them intentionally.
require "application_system_test_case"
class RegistrationTest < ApplicationSystemTestCase
test "visitor can create an account" do
visit signup_path
fill_in "Name", with: "QA User"
fill_in "Email", with: "qa@example.com"
fill_in "Password", with: "valid-password"
click_on "Create account"
assert_text "Welcome"
assert_selector "[data-test='dashboard']"
end
end
Good candidates for system tests
- Registration, sign-in and sign-out.
- Critical purchase or subscription journeys.
- JavaScript-heavy interactions that lower levels cannot meaningfully prove.
- Cross-page workflows where the rendered UI is part of the requirement.
- A small smoke layer that proves the deployed application is usable.
Avoid turning TDD into browser-first development
If every new rule requires a browser test before implementation can begin, feedback becomes slow. Start at the lowest level that can express the behaviour clearly, then add broader tests only where they add different confidence.
Testing mailers
Email behaviour should normally be tested without sending real messages. Verify the application-generated message and delivery intent instead.
require "test_helper"
class AccountMailerTest < ActionMailer::TestCase
test "password reset email" do
user = users(:qa_user)
email = AccountMailer.password_reset(user)
assert_emails 1 do
email.deliver_now
end
assert_equal ["qa@example.com"], email.to
assert_match(/reset/i, email.subject)
assert_match(/password/i, email.body.to_s)
end
end
Useful checks include recipients, subject, important body content, links and whether the message is enqueued or delivered when the business event occurs.
Testing background jobs
Background jobs add asynchronous behaviour, but the application contract can still be tested deterministically.
require "test_helper"
class SubscriptionTest < ActiveSupport::TestCase
include ActiveJob::TestHelper
test "queues invoice generation after activation" do
subscription = subscriptions(:monthly)
assert_enqueued_with(job: GenerateInvoiceJob) do
subscription.activate!
end
end
end
Separate two questions where possible:
- Was the correct job enqueued? Test from the code that triggers it.
- Does the job perform the correct work? Test the job itself with controlled dependencies.
Testing third-party services
External APIs are one of the places where test design has the biggest effect on reliability. A fast TDD loop should not depend on a payment provider, email platform or remote service being available.
Wrap the dependency behind an application-owned interface
class PaymentGateway
def charge(customer_id:, amount:)
# provider SDK or HTTP call
end
end
Your domain service can depend on that interface instead of scattering provider calls throughout the application.
Stub the boundary in a focused test
test "marks subscription paid after a successful charge" do
response = Struct.new(:success?).new(true)
gateway = PaymentGateway.new
subscription = subscriptions(:monthly)
gateway.stub(:charge, response) do
BillingService.new(gateway: gateway).charge(subscription)
end
assert subscription.reload.paid?
end
For HTTP-level integrations, tools such as WebMock or VCR can keep CI deterministic by blocking uncontrolled real network traffic or recording approved interactions. Keep at least some separate contract or integration coverage against a realistic provider environment when the integration is business-critical.
Test doubles: stub, fake, mock or real dependency?
| Option | Use it when | Main risk |
|---|---|---|
| Real dependency | Fast, deterministic and owned by the same test environment. | Setup can become expensive if the dependency is complex. |
| Stub | You need a controlled return value from a collaborator. | Can hide incompatibilities with the real service. |
| Fake | A lightweight working implementation is clearer than many stubs. | The fake may diverge from production behaviour. |
| Mock | The interaction itself is important and must be verified. | Tests can become coupled to implementation details. |
| Recorded HTTP response | You need realistic service payloads without live network dependency. | Recordings become stale and must be reviewed. |
Where QA engineers add value in TDD
TDD is usually performed while production code is being implemented, but that does not make it “developer-only testing”. QA engineers can materially improve the behaviour being driven by the tests.
Question the examples
Add boundary values, negative cases, permissions, state transitions and failure modes developers may not initially consider.
Improve testability
Advocate for injectable dependencies, stable APIs, deterministic data and observability that make testing easier at every level.
Review the layer
Challenge browser tests that could be cheaper and clearer at model, service, API or contract level.
Connect acceptance criteria
Translate story examples into suitable automated checks and identify what still needs exploratory testing.
Analyse escaped defects
When a bug reaches later testing or production, ask which earlier executable example could have prevented it.
Protect the whole strategy
Keep TDD inside a broader quality approach rather than treating passing unit tests as proof the product is ready.
Three Amigos before Red–Green–Refactor
A useful team pattern is to clarify a story with product, development and QA before implementation starts. Consider a rule such as:
Before anybody writes code, the team can identify examples:
| Example | Expected outcome | Likely test layer |
|---|---|---|
| Active monthly subscription cancelled today | Status changes to cancelled; access remains until expiry. | Model/service |
| Already cancelled subscription cancelled again | No duplicate cancellation side-effects. | Model/service |
| Unauthorised user attempts cancellation | Request is rejected. | Request/integration |
| Cancellation triggers confirmation email | Correct message is queued. | Mailer/job integration |
| User cancels from account page | UI confirms cancellation and shows access end date. | System |
Development can then use TDD for the lower-level rules while QA retains a smaller number of higher-value acceptance checks. This gives much better coverage than trying to automate the entire feature at one level.
Common TDD mistakes
| Mistake | Why it hurts | Better approach |
|---|---|---|
| Writing implementation before seeing the test fail | You lose evidence that the test detects the missing behaviour. | Start with the smallest failing example. |
| Testing private methods directly | Tests become coupled to internal structure. | Test observable behaviour through the public contract. |
| One test covers many unrelated behaviours | Failures are harder to understand and maintain. | Keep each test focused on one reason to fail. |
| Too many mocks | The suite can pass even though real components do not integrate. | Mock only meaningful boundaries and retain integration coverage. |
| Browser tests for every rule | Feedback becomes slow and flaky. | Drive most logic through faster lower-level tests. |
| Testing framework internals | Duplicates guarantees provided by Rails or trusted libraries. | Test your configuration and business contract. |
| Ignoring refactoring | The suite grows while design quality degrades. | Treat Refactor as a required part of the loop. |
| Chasing coverage percentage | Teams optimise a number rather than risk reduction. | Prioritise important behaviour, change risk and regression history. |
| Slow local suite | Developers stop running tests frequently. | Keep the TDD subset fast and parallelise broader suites. |
| TDD replaces exploratory testing | Unknown risks and usability issues remain undiscovered. | Combine TDD with exploratory and specialist testing. |
A practical TDD workflow
- Choose one behaviour. Express a single outcome in business or domain language.
- Pick the lowest useful test level. Prefer the fastest layer that can prove the behaviour without excessive mocking.
- Arrange only the required state. Keep data setup small and explicit.
- Write one failing test. Confirm the failure is meaningful.
- Implement the smallest working change. Resist adding speculative behaviour.
- Run the focused test again. Reach green quickly.
- Run nearby regression tests. Confirm the change did not break adjacent behaviour.
- Refactor. Improve names, duplication, object responsibilities and test readability.
- Repeat. Add the next example, especially relevant edge and negative cases.
- Run the broader pipeline. Before merge, execute the appropriate integration, system and quality gates.
When TDD is especially useful
- Business rules with many edge cases.
- Bug fixes where you can reproduce the defect as a failing test first.
- Refactoring legacy code after establishing characterisation tests.
- Service or domain logic with clear inputs and outputs.
- APIs where request and response contracts can be expressed precisely.
- Security-sensitive permission or validation rules.
- Code expected to change frequently over the life of the product.
When strict test-first may be less useful
TDD is a technique, not a law. There are situations where experimentation should come first:
- Exploratory prototypes where the team is still discovering what the solution should be.
- Purely visual experimentation where behaviour has not stabilised yet.
- Throwaway spikes created specifically to learn an API or architecture.
- Legacy code where a safe seam for testing must be created before strict TDD is practical.
Once the uncertainty is reduced and the behaviour becomes valuable production code, establish the tests needed for safe maintenance.
TDD for bug fixing
A production defect is one of the clearest places to apply TDD:
- Reproduce the defect manually or from logs.
- Identify the lowest level that can reproduce the faulty behaviour.
- Write a regression test and verify that it fails.
- Fix the defect.
- Verify the new test passes.
- Run related regression coverage.
- Keep the test so the same failure cannot silently return.
This converts a defect into permanent executable knowledge about the system.
What a good TDD test looks like
A strong test is easier to trust because its intent and failure reason are obvious.
test "expired subscriptions cannot be renewed automatically" do
subscription = subscriptions(:expired)
result = SubscriptionRenewal.call(subscription)
assert_not result.success?
assert_equal "subscription_expired", result.error_code
end
Compare that with a test that asserts many database fields, mock calls and internal helper methods. The second test may have more assertions but can be less valuable if a simple refactor breaks it without changing behaviour.
Useful qualities
- Descriptive test name.
- Minimal setup.
- One clear behavioural reason to fail.
- Deterministic execution.
- No dependency on test order.
- Assertions at a meaningful public boundary.
- Fast enough to run repeatedly.
- Failure output that helps diagnose the problem.
TDD and CI/CD
The value of test-first development increases when the tests also protect the shared branch. A typical pipeline can progressively build confidence:
Commit / Pull Request
│
▼
Static analysis / linting
│
▼
Fast unit & model tests
│
▼
Request / integration tests
│
▼
System / browser smoke tests
│
▼
Specialist checks where required
│
▼
Deployable candidate
Keep the fastest, most deterministic tests early in the pipeline. Broader tests can run in parallel where infrastructure allows it.
A QA engineer’s TDD review checklist
- The behaviour under test is clear from the test name.
- The initial test would fail if the behaviour were missing.
- The test targets the lowest useful layer.
- Test data is controlled and independent.
- The test does not depend on execution order.
- Assertions verify behaviour rather than private implementation.
- Mocks and stubs are limited to meaningful boundaries.
- Important negative and boundary cases are represented.
- Authentication and authorisation are tested separately from generic happy paths.
- External services do not make the normal test suite dependent on live network availability.
- Mailer and background-job side effects are verified where business relevant.
- Critical journeys retain a smaller system-test layer.
- The suite is fast enough to support frequent local execution.
- Refactoring happens while tests remain green.
- Escaped defects are converted into regression tests where appropriate.
- Passing TDD tests are not treated as a replacement for exploratory, performance, security or accessibility testing.
Quick Rails testing cheat sheet
| Need | Example |
|---|---|
| Run all tests | bin/rails test |
| Run model tests | bin/rails test test/models |
| Run one file | bin/rails test test/models/user_test.rb |
| Run one test by line | bin/rails test test/models/user_test.rb:12 |
| Assert true | assert result |
| Assert false | assert_not result |
| Assert equality | assert_equal expected, actual |
| Assert collection includes value | assert_includes collection, value |
| Assert database count change | assert_difference("User.count", 1) { ... } |
| Assert HTTP response | assert_response :success |
| Assert redirect | assert_redirected_to dashboard_path |
| Assert rendered DOM | assert_select "h1", text: "Dashboard" |
| Visit page in system test | visit signup_path |
| Fill field | fill_in "Email", with: "qa@example.com" |
| Click UI control | click_on "Create account" |
| Assert browser text | assert_text "Welcome" |
| Assert email count | assert_emails 1 { ... } |
| Assert job queued | assert_enqueued_with(job: ExampleJob) { ... } |
Useful links
- Testing Rails Applications ↗ — current Rails guide covering the built-in test stack, fixtures, models, controllers, integration, system tests, mailers and jobs.
- ActiveSupport::TestCase ↗ — Rails test case API and test infrastructure.
- Minitest ↗ — the testing library used by the default Rails test stack.
- Capybara ↗ — browser-oriented acceptance testing used by Rails system tests.
- WebMock ↗ — control outbound HTTP calls in tests.
- VCR ↗ — record and replay HTTP interactions for deterministic tests.
- Test-Driven Development ↗ — concise background on the TDD workflow and its design intent.
- EduonixTDD repository ↗ — archived reference repository associated with the original learning material.