[Chapter 4](/guide/how-to-approach-testing/) was all judgment and manual proof. You wrote scenarios, clicked through the [reference clone app](<%= Guide::RecipesPracticeSetup.new(repo_url: site.data.site_metadata.recipes_scenario_drill_url).web_url %>), and compared your thinking with example answers. This chapter is where that habit meets Ruby in your Recipes app from [Chapter 1](/guide/setting-up-minitest/#generate-the-recipes-app).

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](/guide/habits-and-tdd/) 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.

<%= render Guide::ChapterProgress.new(
  feature: "A **Recipe** resource (scaffold) and a **title** validation on the model.",
  why: "You need something real to test against. We start with one resource and one rule so you can practise red/green and three test folders without signing in, fixtures, or full CRUD in one sitting.",
  test: "**Model:** blank title rejected (red then green). **Integration:** visits the list and create with a valid title only (`test/integration/`). **System:** one smoke that the list loads in a browser. We do **not** cover every controller action in HTTP tests yet.",
  later: "Show, edit, update, destroy, and validation failures over HTTP spread across [Chapter 6](/guide/test-data-fixtures/) through [Chapter 8](/guide/testing-models/). Full browser CRUD lands in [Chapter 7](/guide/testing-simple-crud-system-tests/)."
) %>

## 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](/guide/test-data-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](/guide/testing-authentication/) then [authorization](/guide/authorization-testing/) |
| 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](/guide/setting-up-minitest/#generate-the-recipes-app), 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:

    ```bash
    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:

    ```bash
    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.

```bash
bin/rails test
```

You want 0 failures and 0 errors. Then commit:

```bash
# 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:

```bash
bin/rails console
```

```ruby
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:

```ruby
# 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:

```ruby
# 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.

<%= render Shared::Tip.new(
  title: "New assertions",
  markdown: <<~MD
    1. **`assert_not recipe.valid?`** asks "should this be invalid?"
      Syntax: `assert_not` plus a condition. Here the condition is `recipe.valid?`. The test fails if Rails thinks the record is valid.

    2. **`assert_includes recipe.errors[:title], "can't be blank"`** asks "is this message in the error list?"
      Syntax: `assert_includes` plus a collection, then the item you expect. Here the collection is `recipe.errors[:title]`. The test fails if that string is missing.
  MD
) %>

<%= render Shared::Tip.new(
  markdown: <<~MD
    **Scenario vs test block:** Comments carry the story. The `test "..." do` block is the automated check. You can keep both while learning, then drop comments when the test name and assertions read clearly on their own.
  MD
) %>

Run only this file and tests should fail:

```bash
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:

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

Run the same test command again:

```bash
bin/rails test test/models/recipe_test.rb
```

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

<%= render Guide::SupportCta.new(
  variant: :mid_chapter,
  site_metadata: site.data.site_metadata,
  milestone_hook: "You completed a full red-to-green loop and proved a real app rule from failure to passing test.",
  headline: "You just finished your first red-to-green loop.",
  body_text: "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."
) %>

### Commit your work

Run all tests and confirm they still pass:

```bash
bin/rails test
```

Then commit:

```bash
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](/guide/how-to-approach-testing/): manual proof first, then code.

Start the server if it is not already running:

```bash
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](/guide/testing-authentication/) 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](/guide/testing-authentication/) 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](/guide/authorization-testing/) 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](/guide/test-data-fixtures/) through [8](/guide/testing-models/). 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](/guide/kinds-of-rails-tests/) 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](/guide/request-controller-tests/) 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.

```ruby
# 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:

```bash
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](https://guides.rubyonrails.org/testing.html?utm_source=minitestrails.com) 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](https://guides.rubyonrails.org/testing.html?utm_source=minitestrails.com#functional-testing-for-controllers): simulate HTTP requests and assert on the response for a controller action (success, redirect, flash, and so on). |
| `test/integration/` | [Integration tests](https://guides.rubyonrails.org/testing.html?utm_source=minitestrails.com#integration-testing): 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](/guide/request-controller-tests/) covers when `test/controllers/` still earns its keep (API-only apps, isolated action tests).

The Rails guide also notes that [system tests](https://guides.rubyonrails.org/testing.html?utm_source=minitestrails.com#when-to-use-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](/guide/test-data-fixtures/) | Index shows fixture data; show one recipe |
| [7](/guide/testing-simple-crud-system-tests/) | Update, destroy |
| [8](/guide/testing-models/) | Create/update reject blank title over HTTP |
| [12](/guide/testing-authentication/) | 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:

```ruby
# 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:

```ruby
# 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
```

<%= render Shared::Tip.new(
  title: "New assertions",
  markdown: <<~MD
    1. **`get recipes_url`**
      Pretends a browser requested the list. No browser opens.

    2. **`assert_response :success`** asks "did the last request succeed?"
      Syntax: `assert_response` plus a status symbol (`:success` means a normal 2xx). Pair it with the `get` or `post` right before it.

    3. **`post recipes_url, params: { recipe: { title: "..." } }`**
      Submits a create form over HTTP. The nested `recipe:` hash mirrors the form field names the scaffold expects.

    4. **`assert_difference("Recipe.count", 1) { ... }`** asks "did the row count go up by exactly one?"
      Wrap the `post` inside the block.

    5. **`assert_redirected_to recipe_url(Recipe.last)`** asks "did Rails redirect to this URL?"
      Use after a successful create. If this fails, read the failure line (often still on the form with 422 instead of redirecting).

    6. **`follow_redirect!`**
      After a redirect, loads the next page so a following `assert_response` checks where the user actually landed.
  MD
) %>

Run the integration file green before you commit:

```bash
bin/rails test test/integration/recipes_integration_test.rb
```

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

```bash
bin/rails test
```

Then commit:

```bash
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:

```ruby
# 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:

```ruby
# 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
```

<%= render Shared::Tip.new(
  title: "New assertion",
  markdown: <<~MD
    **`assert_selector "h1", text: "Recipes"`** asks "is this element on the page with this text?"
    Syntax: `assert_selector` plus a CSS selector, then `text:` for the heading copy. You used `visit` in [Chapter 1](/guide/setting-up-minitest/#smoke-test-for-up).
  MD
) %>

Run:

```bash
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:

```bash
bin/rails test:system
```

When that passes:

```bash
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:

```bash
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](/guide/habits-and-tdd/) 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](/guide/test-data-fixtures/) 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](/guide/test-data-fixtures/)**.
