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.

TDD is not “write every possible test first”. It works in very small increments. One behaviour is expressed, implemented and cleaned up before moving to the next behaviour.

The Red–Green–Refactor cycle

The classic TDD loop is deliberately short:

StepWhat happensWhy it matters
RedWrite 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.
GreenWrite the minimum production code required to make the test pass.Keeps the change small and the feedback loop fast.
RefactorImprove 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.

Important: a test that passes on the first run may be correct, but in TDD you should investigate why. The initial failure gives evidence that the test actually exercises the missing behaviour.

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.

LayerPrimary focusTypical feedback speedExample
Unit / modelOne class, function or domain ruleVery fastA subscription cannot start after its end date.
Controller / requestHTTP behaviour around an endpointFastInvalid registration returns validation errors.
IntegrationSeveral application components working togetherMediumA signed-in user can create a subscription.
System / browserUser-visible journey through the real applicationSlowerA visitor registers, signs in and sees the dashboard.
Contract / serviceBoundary between systemsFast to mediumA 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.

PracticeMain emphasisTypical question
TDDDesign and implementation through executable testsWhat is the next small behaviour this code must provide?
BDDShared understanding of behaviour using examples and domain languageHow should the system behave from a user or business perspective?
ATDDAcceptance criteria agreed before implementation and automated where valuableWhat 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
LocationPurpose
test/modelsDomain logic, validations, methods and model behaviour.
test/controllersController, routing and response-oriented behaviour where appropriate.
test/integrationFlows crossing multiple controllers or application components.
test/systemFull browser journeys using Capybara-backed system tests.
test/fixturesReusable test records stored as fixture data.
test/mailersEmail subject, recipients, body and delivery behaviour.
test/jobsBackground job execution and enqueueing behaviour.
test_helper.rbCommon 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.

Never aim automated tests at a production database. Test execution should happen against isolated, resettable data with credentials and configuration intended for testing.

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.

ConcernBest starting layerExample assertion
Password validation ruleModel / unitPasswords shorter than the minimum are rejected.
Login endpointRequest / integrationValid credentials establish a session and redirect.
Session or cookie behaviourIntegrationProtected routes become available after sign-in.
Real login formSystemA 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.

Do not mock the world. Mocks and stubs are most valuable at slow, unstable or expensive boundaries. Excessive mocking can produce a green suite that no longer resembles the real system.

Test doubles: stub, fake, mock or real dependency?

OptionUse it whenMain risk
Real dependencyFast, deterministic and owned by the same test environment.Setup can become expensive if the dependency is complex.
StubYou need a controlled return value from a collaborator.Can hide incompatibilities with the real service.
FakeA lightweight working implementation is clearer than many stubs.The fake may diverge from production behaviour.
MockThe interaction itself is important and must be verified.Tests can become coupled to implementation details.
Recorded HTTP responseYou 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:

Business rule: a customer may cancel a monthly subscription until the next billing cycle begins, but a cancelled subscription must retain access until its paid period ends.

Before anybody writes code, the team can identify examples:

ExampleExpected outcomeLikely test layer
Active monthly subscription cancelled todayStatus changes to cancelled; access remains until expiry.Model/service
Already cancelled subscription cancelled againNo duplicate cancellation side-effects.Model/service
Unauthorised user attempts cancellationRequest is rejected.Request/integration
Cancellation triggers confirmation emailCorrect message is queued.Mailer/job integration
User cancels from account pageUI 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

MistakeWhy it hurtsBetter approach
Writing implementation before seeing the test failYou lose evidence that the test detects the missing behaviour.Start with the smallest failing example.
Testing private methods directlyTests become coupled to internal structure.Test observable behaviour through the public contract.
One test covers many unrelated behavioursFailures are harder to understand and maintain.Keep each test focused on one reason to fail.
Too many mocksThe suite can pass even though real components do not integrate.Mock only meaningful boundaries and retain integration coverage.
Browser tests for every ruleFeedback becomes slow and flaky.Drive most logic through faster lower-level tests.
Testing framework internalsDuplicates guarantees provided by Rails or trusted libraries.Test your configuration and business contract.
Ignoring refactoringThe suite grows while design quality degrades.Treat Refactor as a required part of the loop.
Chasing coverage percentageTeams optimise a number rather than risk reduction.Prioritise important behaviour, change risk and regression history.
Slow local suiteDevelopers stop running tests frequently.Keep the TDD subset fast and parallelise broader suites.
TDD replaces exploratory testingUnknown risks and usability issues remain undiscovered.Combine TDD with exploratory and specialist testing.

A practical TDD workflow

  1. Choose one behaviour. Express a single outcome in business or domain language.
  2. Pick the lowest useful test level. Prefer the fastest layer that can prove the behaviour without excessive mocking.
  3. Arrange only the required state. Keep data setup small and explicit.
  4. Write one failing test. Confirm the failure is meaningful.
  5. Implement the smallest working change. Resist adding speculative behaviour.
  6. Run the focused test again. Reach green quickly.
  7. Run nearby regression tests. Confirm the change did not break adjacent behaviour.
  8. Refactor. Improve names, duplication, object responsibilities and test readability.
  9. Repeat. Add the next example, especially relevant edge and negative cases.
  10. 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:

  1. Reproduce the defect manually or from logs.
  2. Identify the lowest level that can reproduce the faulty behaviour.
  3. Write a regression test and verify that it fails.
  4. Fix the defect.
  5. Verify the new test passes.
  6. Run related regression coverage.
  7. 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

NeedExample
Run all testsbin/rails test
Run model testsbin/rails test test/models
Run one filebin/rails test test/models/user_test.rb
Run one test by linebin/rails test test/models/user_test.rb:12
Assert trueassert result
Assert falseassert_not result
Assert equalityassert_equal expected, actual
Assert collection includes valueassert_includes collection, value
Assert database count changeassert_difference("User.count", 1) { ... }
Assert HTTP responseassert_response :success
Assert redirectassert_redirected_to dashboard_path
Assert rendered DOMassert_select "h1", text: "Dashboard"
Visit page in system testvisit signup_path
Fill fieldfill_in "Email", with: "qa@example.com"
Click UI controlclick_on "Create account"
Assert browser textassert_text "Welcome"
Assert email countassert_emails 1 { ... }
Assert job queuedassert_enqueued_with(job: ExampleJob) { ... }

Useful links