Your first test

Chapter 4 was all judgment and manual proof. You wrote scenarios, clicked through the reference clone app, and compared your thinking with example answers. This chapter is where that habit meets Ruby in your Recipes app from Chapter 1.

I still remember the first time a test failed on purpose and I felt relieved instead of a panic. The test was doing its job. My app was still wrong. I fixed the code, ran the same command again, and the test passed.

Testers often call that red then green. Red means a test failed (you will see a red F or failure line in the terminal). Green means it passed (0 failures). You will hear those words again in Chapter 19 when we talk about test-driven development (TDD). For now, think of them as:

  • red = “not there yet”
  • green = “matches the scenario.”

This red/green loop is what we will follow in this chapter: write what should happen, see red, fix the app, see green. We will do it with a real Recipe model and three small automated checks.

Keep Chapter 4 nearby. Every test in this chapter should trace back to a short scenario you could explain to someone without opening the code.

Two apps, two roles #

You have been using two different apps till this point and that is intentional:

  • Reference clone (Chapter 4)

    Full Recipes product for manual checks after each scenario building. You can ignore it for this chapter or peek when you want to compare behavior.

  • Your recipes app (from Chapter 1)

    Still small on purpose. You will add a Recipe resource here and write the first tests yourself.

We generate Recipe with the main columns up front (title, description, prep_time, servings) so you are not fighting extra migrations in every later chapter. Sign-in, user_id, ingredients, and steps still come later. In this chapter we only add a validation and tests for title. The other fields sit on the form and in the database ready for when you need them.

What you will do in this chapter #

By the end you should have:

  1. A Recipe scaffold with a title column and a migrated database.
  2. A model test for “blank title is rejected” that goes red first, then green after you add a validation (your first red/green cycle).
  3. A manual pass through the app in the browser (list, create, edit, delete) so you know what the scaffold does before HTTP tests.
  4. A quick run of the generated controller tests, then two integration tests you add by hand (list and create over HTTP).
  5. One system smoke that opens the recipe list in a real browser.
  6. A few git commits, each one only after a green test run (a habit worth keeping in real projects).

You will not add authentication, fixtures, or nested ingredients yet. Those come later. If something in this chapter feels too small, that is the point: one scenario, one test method, one main outcome.

Where the guide is headed (Recipes roadmap) #

You will not build everything in the table below today. It is here so you see how later chapters attach to the same app.

Area What Recipes gains How we test it
Core resource Recipe CRUD Model + system tests (you start here)
Test data Sample rows in YAML Fixtures (Chapter 6)
Bigger domain Ingredients, steps, nested routes More model and system tests
HTTP layer Forms, redirects, multi-step flows Integration grows in Chapter 5-8; system CRUD in Chapter 7
Users and access Sign in, ownership Authentication then authorization
Out-of-request work Mailers, jobs Dedicated chapters
External APIs Third party HTTP WebMock / VCR chapter

Let’s now get started with your first test, we will start by adding a first database table to the app.

Add your first Recipe table #

We will work with the Recipes app you generated in Chapter 1, so make sure to cd into the root of the recipes app before you hit any of the commands below.

Generate and migrate #

  1. Generate the scaffold:

     bin/rails generate scaffold Recipe title:string description:text prep_time:integer servings:integer
    

    rails generate scaffold creates the model, migration, controller, views, routes, and starter tests under test/. Here is what each column is for:

    Column Type Role in the app
    title string Recipe name. Required once you add the validation in this chapter.
    description text Optional notes about the dish. On the form from day one; tested in later chapters.
    prep_time integer Minutes to prepare. Optional for now; Chapter 4 drills use it when you add numericality rules.
    servings integer How many people the recipe feeds. Optional for now.

    The new and edit forms will show all of these fields. You can leave description, prep time, and servings blank while you work through this chapter. We only automate the blank title rule today.

  2. Apply the migration:

     bin/rails db:migrate
    

    That updates db/schema.rb and your development database. Tests use a separate test database; Rails prepares it when you run bin/rails test, but migrating in development keeps your local app consistent when you click around.

Commit your work #

Before you commit, run the test suite and confirm nothing broke. The scaffold added starter tests; they should pass even though you have not written your own yet.

bin/rails test

You want 0 failures and 0 errors. Then commit:

# Add all folders and files to the git with `.`
git add .
git commit -m "Add Recipe scaffold"

Small commits are easier to roll back. Running tests right before each commit tells you that checkpoint really works.

Model test: red, then green #

This section follows the Chapter 4 drill for a blank title. Same scenario, now in test/models/. You will write the test before the validation exists on purpose. That is the red/green pattern TDD uses: fail for the right reason, then make the smallest app change that turns the test green.

The scenario #

Write it in plain language before you touch the test file:

  • Actor: anyone creating a recipe (the rule lives on the model, so guest or signed-in does not matter yet)
  • Starting point: a new, unsaved recipe
  • Action: set title to blank and ask if the record is valid
  • Expected outcome: invalid; error on title mentions blank

Manual check in rails console #

Prove the gap in the app before you automate:

bin/rails console
recipe = Recipe.new(title: "")
recipe.valid?
recipe.errors[:title]

On a fresh scaffold you will see valid? return true and errors[:title] empty. Rails is not wrong. You simply have not told it that title is required yet. That mismatch is exactly why the first test run should be red: the test expects a blank title to be rejected, and the app has not caught up yet.

Type exit when you are done.

What counts as “working correctly”? #

Before you type assertions, list what you would check by hand. For a blank title, something like:

  • valid? returns false (Rails should reject the record)
  • errors[:title] mentions that the title cannot be blank

Those lines are your expected outcomes. Each one becomes an assertion later. If you cannot name the outcomes, pause and run the console steps again until you can.

Red: add tests #

Open test/models/recipe_test.rb. The generator may have left sample tests in that file. Replace the whole file with the following:

# test/models/recipe_test.rb
require "test_helper"

class RecipeTest < ActiveSupport::TestCase
  # Actor: anyone creating a recipe
  # Starting point: a new, unsaved recipe
  # Action: set title to blank and ask if the record is valid
  # Expected outcome: invalid; error on title mentions blank
  #
  # test "rejects a blank title" do
  # end
end

Save the file. The story lives in the test file before the robot script.

Example model test (add the assertions) #

When you are ready, fill in the test body:

# test/models/recipe_test.rb
require "test_helper"

class RecipeTest < ActiveSupport::TestCase
  test "rejects a blank title" do
    recipe = Recipe.new(title: "")
    assert_not recipe.valid?
    assert_includes recipe.errors[:title], "can't be blank"
  end
end

What each line is doing:

  • require "test_helper" loads the shared test setup from test/test_helper.rb.
  • ActiveSupport::TestCase is the base class for model tests.
  • test "rejects a blank title" do ... end is one test block: your scenario turned into Ruby.
  • Recipe.new(title: "") is the action from the scenario.

Run only this file and tests should fail:

bin/rails test test/models/recipe_test.rb

You should see a failure (not an error) with the message: “Expected true to be nil or false”.

That is your red step. A failure means an assertion did not match (here, you expected invalid, Rails said valid). An error means Ruby crashed (typo, missing constant, nil surprise). Red on this first run is what you want: the test is paying attention.

Green: fix the app #

Edit app/models/recipe.rb and add:

# app/models/recipe.rb
validates :title, presence: true

Run the same test command again:

bin/rails test test/models/recipe_test.rb

You should see 0 failures, 0 errors. That is green: the app now matches the scenario.

You completed a full red-to-green loop and proved a real app rule from failure to passing test.

You just finished your first red-to-green loop.

The first time a test fails for the right reason is when testing stops feeling random. If that clicked here, fund the next chapter and keep this guide growing.

One-time support via Stripe. No account required.

Commit your work #

Run all tests and confirm they still pass:

bin/rails test

Then commit:

git add .
git commit -m "Require Recipe title"

Surf the app before HTTP tests #

Model tests stay inside Ruby: you build records and call methods like valid? without HTTP or a browser. Integration tests step out one layer. They send requests to your routes (get, post, and so on), run through the controller, and check status codes and redirects, still without opening the browser.

Before you write those HTTP tests, click through the app yourself so you know what “working” feels like in a real browser. Same habit as Chapter 4: manual proof first, then code.

Start the server if it is not already running:

bin/dev

Open http://localhost:3000/recipes and walk through these flows. You do not need to sign in as there is no authentication in the app yet.

Step What to do What you should see
List Visit /recipes Recipe index loads (empty or with rows you add)
Create Click New (or go to /recipes/new), fill in a title, submit You land on the new recipe’s show page
Show Open one recipe from the list Title and form fields you entered
Edit Click Edit, change the title, save Show page shows the new title
List again Back to /recipes Your recipe appears in the list
Delete Destroy the recipe (confirm if the browser asks) Recipe gone from the index

Button labels vary by Rails version (New recipe, Create Recipe, Destroy, and so on). Match what your scaffold shows.

Notice you could do all of that without logging in. Anyone visiting your app right now can list, create, edit, and delete recipes. Hold onto that feeling. It matters when we talk about tests and when Chapter 11 locks the app down.

Why guests can create and edit recipes until Chapter 11 #

You just proved it by hand: the app is wide open. On purpose, it stays that way for several chapters. Anyone can use the full recipe scaffold (list, show, new, edit, destroy) without signing in. That is not how I would ship a production app, but it is how we keep early chapters manageable.

You are already learning Minitest folders, assertions, fixtures, and browser tests. Adding sign-in, sessions, user_id, and “who owns this row?” at the same time pushes a lot of new ideas at once. We delay authentication until Chapter 11 so you can get comfortable with tests first.

After Chapter 11, guests may only browse (recipe index and detail). Creating, editing, and deleting require a signed-in user, and the UI will not show those actions to guests. Chapter 12 then covers ownership (you cannot change someone else’s recipe).

For HTTP tests in this chapter, we only automate list and create. Show, edit, update, and destroy over HTTP land in Chapters 6 through 8. You already know the full CRUD works in the browser; the tests catch up one slice at a time.

HTTP tests: controller and integration #

You surfed the app in the browser. Now you prove the same flows over HTTP in Ruby: routing, controller, response status, and redirects. Still no browser in these tests, only request helpers and assertions.

Chapter 2 mapped two folders for checking the HTTP stack: test/controllers/ and test/integration/. Both use the same HTTP helpers (get, post, and so on). Neither of them opens a browser.

When you generated the Recipe scaffold, Rails added starter tests in test/controllers/ by default. Open that file, run it once so you know what the generator gave you, then add new HTTP coverage in test/integration/ as this guide does. We will not extend the controller file for flows integration can cover. Chapter 10 explains when controller tests still earn their keep and why we ignore them for the Recipes app.

What the scaffold put in test/controllers/ #

Open test/controllers/recipes_controller_test.rb. Your file may differ slightly by Rails version, but the shape is the same: one test block per controller action, each sending a single HTTP request with get, post, patch, or delete, then checking the response.

# test/controllers/recipes_controller_test.rb
require "test_helper"

class RecipesControllerTest < ActionDispatch::IntegrationTest
  setup do
    @recipe = recipes(:one)
  end

  test "should get index" do
    get recipes_url
    assert_response :success
  end

  test "should get new" do
    get new_recipe_url
    assert_response :success
  end

  test "should create recipe" do
    assert_difference("Recipe.count") do
      post recipes_url, params: { recipe: { description: @recipe.description, prep_time: @recipe.prep_time, servings: @recipe.servings, title: @recipe.title } }
    end

    assert_redirected_to recipe_url(Recipe.last)
  end

  test "should show recipe" do
    get recipe_url(@recipe)
    assert_response :success
  end

  # edit, update, and destroy tests follow the same pattern
end

What is going on:

  • RecipesControllerTest lives under test/controllers/ because the generator names tests after the controller.
  • setup runs before each test and creates a recipe row some tests need.
  • Each test "..." block is one trip to one URL. No browser opens. Integration tests use the same HTTP helpers as controller tests (get, post, and so on).

Run the generated controller tests once so you see them pass:

bin/rails test test/controllers/recipes_controller_test.rb

You want 0 failures and 0 errors. That tells you routing, the controller, and views wired up for basic CRUD.

Where this guide keeps HTTP tests #

The Rails testing guide names two HTTP folders that confuse beginners because they look similar in code:

Rails folder What the guide says it is for
test/controllers/ Functional tests for controllers: simulate HTTP requests and assert on the response for a controller action (success, redirect, flash, and so on).
test/integration/ Integration tests: tests that cover interactions between controllers and important workflows (several requests in one scenario).

Modern Rails puts both in classes that inherit ActionDispatch::IntegrationTest and use the same request helpers (get, post, follow_redirect!, and so on). Your scaffold RecipesControllerTest is already that style. The difference is not a different gem or syntax. It is what you are trying to prove.

The Rails guide’s integration example for creating an article is a small workflow in one test: get the new form, post create, follow_redirect!, then assert on the page. That is the shape we want for Recipes user stories. The scaffold controller file instead gives you one test per action (test "should get index", test "should create recipe", and so on). That is fine for checking each endpoint in isolation. It is awkward when the scenario is “guest creates a valid recipe and lands on the show page,” because that story needs more than one request.

I also prefer test/integration/ for how the folder name reads when you open the project:

Folder What it signals to me
test/controllers/ Tests tied to controller actions (did index return 200? did create redirect?)
test/integration/ Tests for how a feature behaves over HTTP (can a guest create a recipe and land on the show page?)

For this guide:

  • Leave test/controllers/recipes_controller_test.rb where it is. You already ran it once to see what the generator gave you.
  • Add and change HTTP coverage for Recipes in test/integration/ (for example recipes_integration_test.rb).
  • Do not extend the scaffold controller file for workflows you could express in integration tests. Chapter 10 covers when test/controllers/ still earns its keep (API-only apps, isolated action tests).

The Rails guide also notes that system tests are slower and fit critical browser paths, while integration tests are often the better balance for most HTTP behavior. You already surfed CRUD in the browser above. Here we start automating HTTP without Chrome; system smoke comes later in this chapter.

HTTP integration progression (where the rest lands) #

Chapter Integration tests added to recipes_integration_test.rb
5 (this chapter) Visits the list; create with valid title
6 Index shows fixture data; show one recipe
7 Update, destroy
8 Create/update reject blank title over HTTP
12 New files for sign-in; update mutations for auth

Same file, more stories over time. That is the progression you should feel: scenario first, one test block, run green, commit, next chapter.

Write your first integration tests #

Integration tests issue HTTP requests in Ruby (still no browser). You will use request helpers plus assertions on the response.

What counts as working correctly? #

Flow You might say
List When I request the recipe list in an integration test, the app answers with a normal success response.
Create When I open the new form, post a valid title, and follow the redirect, one new row exists and I land on that recipe’s show page.

Write those stories down before you open the file.

Scenarios in the file first #

Create test/integration/recipes_integration_test.rb with comments only. Two scenarios, each with a commented-out test block:

# test/integration/recipes_integration_test.rb
require "test_helper"

class RecipesIntegrationTest < ActionDispatch::IntegrationTest
  # Actor: guest (HTTP request, no browser)
  # Starting point: app is running; recipe list route exists
  # Action: GET the recipe list
  # Expected outcome: HTTP success response
  # test "visits the list" do
  # end

  # Actor: guest (HTTP request, no browser)
  # Starting point: new recipe form is available
  # Action: GET new form, POST valid title
  # Expected outcome: one new row; redirect to show; show page succeeds
  # test "creates a recipe" do
  # end
end

Example integration file #

When you are ready, fill in the test bodies:

# test/integration/recipes_integration_test.rb
require "test_helper"

class RecipesIntegrationTest < ActionDispatch::IntegrationTest
  test "visits the list" do
    get recipes_url
    assert_response :success
  end

  test "creates a recipe" do
    get new_recipe_url
    assert_response :success

    assert_difference("Recipe.count", 1) do
      post recipes_url, params: { recipe: { title: "Lentil soup" } }
    end

    assert_redirected_to recipe_url(Recipe.last)
    follow_redirect!
    assert_response :success
  end
end

Run the integration file green before you commit:

bin/rails test test/integration/recipes_integration_test.rb

When integration tests pass, run the rest of your non-system suite (model + integration):

bin/rails test

Then commit:

git add .
git commit -m "Add Recipes integration tests"

System smoke: one browser check #

Integration tests speak HTTP without painting the page in the browser. A system test drives a real browser (headless Chrome) and checks what a user would see.

What counts as working correctly? #

“When a guest opens the recipe list in a real browser, the page loads and shows a top-level heading.” That is enough for a smoke test in this chapter.

Scenario in the file first #

Rails 8 scaffolds do not create system tests by default. You can pass --system-tests=true when generating if you want them up front. Create test/system/recipes_test.rb next to test/application_system_test_case.rb and start with comments:

# test/system/recipes_test.rb
require "application_system_test_case"

class RecipesTest < ApplicationSystemTestCase
  # Actor: guest
  # Starting point: app boots in the browser driver
  # Action: open recipe list
  # Expected outcome: page loads with a top-level heading
  #
  # test "visits the list" do
  # end
end

Example system test #

Fill in the test body when you are ready:

# test/system/recipes_test.rb
require "application_system_test_case"

class RecipesTest < ApplicationSystemTestCase
  test "visits the list" do
    visit recipes_url
    assert_selector "h1", text: "Recipes"
  end
end

Run:

bin/rails test:system test/system/recipes_test.rb

NOTE: You need Chrome or Chromium and the Capybara and Selenium gems for system tests. If the driver fails, install Chrome and retry. Failures here are often selector text or timing; errors are often a missing driver or a typo.

Commit your work

Run the full suite before you commit. System tests included:

bin/rails test:system

When that passes:

git add .
git commit -m "Add Recipes system smoke test"

Run the wider suite (optional) #

When each slice passes on its own, run everything together:

bin/rails test:all

That runs non-system tests and test/system in one pass, now including your Recipe files.

What you have now #

You turned Chapter 4 scenarios into real tests in three folders:

Scenario Folder What you proved
Blank title rejected test/models/ Validation logic in Ruby
List + create over HTTP test/integration/ Routing, status and page render
Index in a browser test/system/ Page actually renders for a user

That is the loop for the rest of the guide: write the scenario, prove it by hand when it helps, automate in the right folder, commit at a green checkpoint (all tests passing). You will use red/green again when a new kind of test is worth learning on purpose; Chapter 19 talks about when strict test-first helps and when test-after is fine.

What is next #

Right now each test builds its own Recipe.new or creates its own recipe. That is fine for two examples. It gets noisy when you have ten tests and each one repeats the same setup of building the test records required for testing. Chapter 6 introduces fixtures so the test database starts from a known YAML snapshot and your tests stop fighting random leftover records.

Continue to Test data: fixtures.

Keep Minitest Rails independent

Minitest Rails is an independent educational guide for Rails developers learning automated testing.

Reader support funds new chapters, Rails version updates, and more real-world examples.

Something unclear in this chapter?

Send feedback

Disclaimer: This guide is written in tandem with AI but reviewed and enhanced by me based on my experience with Rails and testing. I stand by the advice and patterns here.