Model tests are the fast feedback loop. They answer "does this Ruby and Active Record behavior work?" without HTTP or a browser. You already rejected a blank title in [Chapter 5](/guide/your-first-test/#model-test-red-then-green) using red/green TDD on the model. After CRUD in [Chapter 7](/guide/testing-simple-crud-system-tests/), it is time to grow the model layer: built-in validations, a custom validation, a scope wired to the recipe list, and a taste of instance methods with clear rules on what is worth testing.

Controllers and views came from the Rails scaffold, you surfed those flows and wrote HTTP tests without changing app code first. That's why we added tests later for controllers without using TDD. But code inside the **Model** is different. The numerical validations for `prep_time` and `servings` are not in your model yet nor any scope and public methods. So, this chapter uses the same TDD habit as Chapter 5: write the test, see red, add the code, see green.

<%= render Guide::ChapterProgress.new(
  feature: "Built-in and custom validations, a quick recipe filter on the index page (scope + form), `printable?` via TDD, and a `before_validation` strip callback with a model test.",
  why: "The columns were there since Chapter 5. The validations and query helpers were not. Model tests are the fastest place to prove them before slower layers trust the same data.",
  test: "Model: TDD for validations, `printable?`, and a normalization callback (title strip).
  Integration: invalid data on create over HTTP (422, errors on form); quick filter via GET form params.",
  later: "Nested ingredient and step validations in [Chapter 9](/guide/second-resource-and-associations/). Auth rules in [Chapter 12](/guide/testing-authentication/)."
) %>

## What you will do in this chapter

1. Refresh the memory on what each `recipes` column is for.
2. Add model tests for negative `prep_time` and `servings` using TDD: run red, add built-in validations, run green.
3. Add a custom validation test (whitespace-only description) using TDD: run red, add the method, run green.
4. Add `printable?` with the same TDD habit: test first, then the method.
5. Add a normalization callback (`before_validation` title strip) with a model test. Keep both; leave mail, jobs, and similar orchestration to explicit controller or job calls.
6. Recap what belongs in `test/models/` versus integration, then add a `quick` scope with integration using TDD: red test, scope and controller, green; filter form for the browser.
7. Add one integration test so invalid data on **create** re-renders the form with errors (blank title is the example payload; no matching edit test).
8. Commit after a green run.

## Scenarios to automate

Following are the scenarios we will automate in this chapter:

| Scenario | Expected |
| --- | --- |
| Blank title | Invalid, error on title (model: already handled in Chapter 5; HTTP: invalid data on create, this chapter) |
| Negative prep time | Invalid, error on prep_time |
| Negative servings | Invalid, error on servings |
| Whitespace-only description | Invalid, error on description |
| Valid fixture row | `valid?` is true |
| `quick` scope | Index filter for **Quick recipes (under 30 min)** shows pancakes (15 min), hides lentil soup (30 min); proved in integration |
| `printable?` | True when title and positive servings are present |
| Title whitespace strip | Strip `"  Soup  "` to `"Soup"` before validation (`before_validation`) |

## The recipes table (refresher)

[Chapter 5](/guide/your-first-test/#generate-and-migrate) scaffolded the `recipes` table. You have been using it in fixtures, integration tests, and system tests since [Chapter 6](/guide/test-data-fixtures/#create-recipe-fixtures). Before you add further validations to the recipe model, let's refresh the memory on what each column is for.

Open `db/schema.rb` and you should see something like:

```ruby
# db/schema.rb
create_table "recipes", force: :cascade do |t|
  t.string "title"
  t.text "description"
  t.integer "prep_time"
  t.integer "servings"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end
```

| Column | What it is | Where you already see it |
| --- | --- | --- |
| `title` | The recipe name on the list, show page, and forms | Fixtures (`Fluffy pancakes`), every create/update test |
| `description` | Optional, longer notes about the dish | Fixture YAML, new/edit form, show partial |
| `prep_time` | Optional, minutes to prepare (a whole number) | Fixture YAML (`15`, `30`), number field on the form |
| `servings` | Optional, how many people the recipe feeds | Fixture YAML (`4`, `6`), number field on the form |
| `created_at` / `updated_at` | Rails timestamps (set automatically) | Not the focus in this chapter |

The new and edit forms already expose all four user-facing fields. Integration and system tests mostly sent `title` because that was enough to prove CRUD. Model tests are where you prove validation rules on each column in Ruby; fast and without having to use HTTP.

Let's map validations to the scenarios we listed above:

- Blank title:
    
    You proved this on the model in Chapter 5. This chapter adds one integration test on create that proves errors render on the form (blank title is the invalid payload).
- Negative prep time / servings:

    When someone enters a number, it must be greater than zero. Blank (`nil`) stays fine.
- Valid fixture row:

    `recipes(:pancakes)` has a title plus positive (> 0) `prep_time` and `servings`, so it should stay valid after you add the new validation rules.

## Model validations: red then green

[Chapter 5](/guide/your-first-test/#red-add-tests) walked through TDD for `validates :title`. We will use the same pattern here for `prep_time` and `servings` and use TDD to add validation rules to the recipe model.

### What counts as working?

| Scenario | You might say |
| --- | --- |
| Negative prep time | When I build a recipe with `prep_time: -1`, `valid?` is false and `errors[:prep_time]` mentions the number must be greater than zero. |
| Negative servings | When I build a recipe with `servings: -1`, `valid?` is false and `errors[:servings]` mentions the number must be greater than zero. |
| Valid fixture | `recipes(:pancakes).valid?` stays true after the new validations land. |

### Add scenarios to the test file

Open `test/models/recipe_test.rb`. You already have `is valid` and `rejects a blank title` from Chapters 5 and 6. Add commented scenario lines for the two new tests only:

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

class RecipeTest < ActiveSupport::TestCase
  # Actor: anyone saving a recipe
  # Starting point: a new recipe with a title
  # Action: set prep_time to -1 and call valid?
  # Expected outcome: invalid; error on prep_time
  # test "rejects negative prep time" do
  # end

  # Actor: anyone saving a recipe
  # Starting point: a new recipe with a title
  # Action: set servings to -1 and call valid?
  # Expected outcome: invalid; error on servings
  # test "rejects negative servings" do
  # end
end
```

Leave your existing `test "is valid"` and `test "rejects a blank title"` blocks in place above these scenario comments.

### Red: add the tests

Fill in the two new test bodies:

```ruby
# test/models/recipe_test.rb
class RecipeTest < ActiveSupport::TestCase
  test "rejects negative prep time" do
    recipe = Recipe.new(title: "Soup", prep_time: -1)
    assert_not recipe.valid?
    assert_includes recipe.errors[:prep_time], "must be greater than 0"
  end

  test "rejects negative servings" do
    recipe = Recipe.new(title: "Soup", servings: -1)
    assert_not recipe.valid?
    assert_includes recipe.errors[:servings], "must be greater than 0"
  end
end
```

You already know `assert_not` and `assert_includes` from [Chapter 5](/guide/your-first-test/#example-model-test-add-the-assertions). Same syntax, different column; here is a refresher:

- `assert_not recipe.valid?` asks “should this be invalid?”
- `assert_includes recipe.errors[:prep_time], "must be greater than 0"` asks “is this message in the error list?”

Run the model file:

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

You want **failures** on the two new tests. Rails still thinks a negative `prep_time` or `servings` is valid because those validations are not on the model yet. A failure means the assertion disagreed with Rails (you expected invalid, it said valid). That is your **red** step:

```bash
F

Failure:
RecipeTest#test_rejects_negative_prep_time [test/models/recipe_test.rb:17]:
Expected true to be nil or false

F

Failure:
RecipeTest#test_rejects_negative_servings [test/models/recipe_test.rb:23]:
Expected true to be nil or false
```

### Green: add validations on the model

Open `app/models/recipe.rb`. You already have `validates :title, presence: true` from Chapter 5. Add new validations for `prep_time` and `servings`:

```ruby
# app/models/recipe.rb
class Recipe < ApplicationRecord
  validates :title, presence: true
  validates :prep_time, numericality: { greater_than: 0 }, allow_nil: true
  validates :servings, numericality: { greater_than: 0 }, allow_nil: true
end
```

`allow_nil: true` keeps optional fields "optional". Blank prep time or servings on the form will still save but if they are present, numbers must be greater than zero.

Run the model file again:

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

You want 0 failures and 0 errors on all four tests (`is valid`, blank title, negative prep time, negative servings). That is **green**.

## Custom validation: red then green

Built-in validators (`presence`, `numericality`) cover a lot and they are mostly enough. But sometimes you need to add a validation that can't be covered by Rails default validators. In those cases you add a custom validation.

You can add a custom validation by combining `validate :method_name` and a private method. For custom validations, I follow the same habit as built-in validations: add a test when you write the logic yourself.

### What counts as working?

| Scenario | You might say |
| --- | --- |
| Whitespace-only description | When `description` is only spaces, `valid?` is false and `errors[:description]` explains why. |

### Add scenarios to the test file

Open `test/models/recipe_test.rb` and add the following scenario at the bottom of the file for custom validation:

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

class RecipeTest < ActiveSupport::TestCase
  # Actor: anyone saving a recipe
  # Starting point: a new recipe with a title
  # Action: set description to spaces only and call valid?
  # Expected outcome: invalid; error on description
  # test "rejects a whitespace-only description" do
  # end
end
```

### Red: add the test

Next, add the test to check validation rules:

```ruby
# test/models/recipe_test.rb
  test "rejects a whitespace-only description" do
    recipe = Recipe.new(title: "Soup", description: "   ")
    assert_not recipe.valid?
    assert_includes recipe.errors[:description], "can't be only spaces"
  end
```

Run the model test:

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

The new test should **fail** because the custom validation does not exist yet.

```bash
F

Failure:
RecipeTest#test_rejects_a_whitespace-only_description [test/models/recipe_test.rb:29]:
Expected true to be nil or false
```

### Green: add the validation method

In `app/models/recipe.rb`, add the validator and private method:

```ruby
# app/models/recipe.rb
class Recipe < ApplicationRecord
  validates :title, presence: true
  validates :prep_time, numericality: { greater_than: 0 }, allow_nil: true
  validates :servings, numericality: { greater_than: 0 }, allow_nil: true

  validate :description_cannot_be_whitespace_only

  private

  def description_cannot_be_whitespace_only
    return if description.nil? || description.empty?
    return if description.strip.length.positive?

    errors.add(:description, "can't be only spaces")
  end
end
```

Line by line explanation of the custom validation:

1. `validate :description_cannot_be_whitespace_only` tells Rails to run your private method before save, the same way `validates :title, presence: true` runs built-in checks.
2. `return if description.nil?` means "no description is fine." The field is optional, so `nil` should not add an error.
3. `return if description.empty?` means "an empty string from an unfilled form field is fine too." System and integration tests often create recipes with only a title; the textarea still submits `""`, not `nil`.
4. `return if description.strip.length.positive?` means "if we strip leading and trailing spaces and there is still at least one character left, the description is good." `"  Lentil soup  "` passes because `strip` leaves real text.
5. If none of those `return` lines fire, the value is only whitespace (e.g. `"   "`, `"\t"`). Then `errors.add` records the message your test expects.

Why not `description.blank?` on the early return?

In Rails, `blank?` is true for `nil`, `""`, and strings that are only spaces. So `return if description.blank?` would treat `"   "` like "nothing to check" and skip your custom validation entirely and the test would stay red forever. We need separate guards: `nil?` and `empty?` for truly absent values, `strip.length.positive?` for real content, and only then reject whitespace-only input.

Run the test again and this time it should pass. You want 0 failures and 0 errors.

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

## Instance methods: one illustrative example

An instance method runs Ruby on one record. You should test it when the logic is worth protecting, not because every method deserves a test.

`printable?` answers "could we print a recipe card?" with a simple rule: title and positive servings must be present.

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: <<~MD
    This example is intentionally small so you see the pattern. In a real app you would skip testing something this thin unless it drives billing, permissions, or another high-risk path.
  MD
) %>

### What counts as working?

| Scenario | You might say |
| --- | --- |
| Printable fixture | `recipes(:pancakes).printable?` is true |
| Missing servings | A new record with only a title is not printable |

### Add scenarios to the test file

```ruby
# test/models/recipe_test.rb
  # Actor: anyone checking a recipe record
  # Starting point: recipes(:pancakes) from fixtures
  # Action: call printable?
  # Expected outcome: true
  # test "printable is true when title and servings are present" do
  # end

  # Actor: anyone checking a new recipe
  # Starting point: Recipe.new with title only
  # Action: call printable?
  # Expected outcome: false
  # test "printable is false without servings" do
  # end
```

### Red: add the tests

```ruby
# test/models/recipe_test.rb
test "printable is true when title and servings are present" do
  assert recipes(:pancakes).printable?
end

test "printable is false without servings" do
  assert_not Recipe.new(title: "Soup").printable?
end
```

Run the model test:

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

Both tests should **error** out with `NoMethodError` because `printable?` does not exist yet.

```bash
E

Error:
RecipeTest#test_printable_is_false_without_servings:
NoMethodError: undefined method 'printable?' for an instance of Recipe
    test/models/recipe_test.rb:38:in 'block in <class:RecipeTest>'
```

### Green: add the method

```ruby
# app/models/recipe.rb
def printable?
  title.present? && servings.present? && servings.positive?
end
```

Run again. You want 0 failures and 0 errors.

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

`recipes(:pancakes)` has a title and servings greater than 0, so `printable?` should be true. `Recipe.new(title: "Soup")` has no servings, so `printable?` should be false.

## Callbacks: keep them rare

Callbacks (`before_validation`, `after_create`, and the rest) hook into Active Record's save lifecycle. They still live on the model, so you often prove them in `test/models/` the same way you prove validations: set up a record, run `valid?` or `save`, assert what changed on that record.

Callbacks are a good fit for **small normalization** on the same object. For example trimming extra leading and trailing spaces from the recipe title before validation runs to save a recipe. The exercise below is that kind of callback.

Save the heavy side effects for layers you choose explicitly. If creating a recipe should email a friend, enqueue a PDF export, or call an HTTP client, that does not belong in `after_create` on `Recipe`. Those steps touch other parts of the app, fail in ways you want visible in a stack trace, and deserve an explicit call from a controller or job you test in `test/mailers/` or `test/jobs/` ([Chapter 13](/guide/testing-mailers/), [Chapter 14](/guide/testing-background-jobs/)). External HTTP belongs behind a job and stubs ([Chapter 16](/guide/mock-external-services/)). A callback that does that work runs on **every** save, even console edits and imports you did not mean to notify anyone about.

### What counts as working?

| Scenario | You might say |
| --- | --- |
| Strip title spaces | When title is `"  Soup  "`, calling `valid?` leaves `title` as `"Soup"` |

### Add scenarios to the test file

```ruby
# test/models/recipe_test.rb
  # Actor: anyone saving a recipe
  # Starting point: new recipe with padded title
  # Action: call valid?
  # Expected outcome: title attribute is stripped
  # test "strips whitespace from title before validation" do
  # end
```

### Red: add the test

```ruby
# test/models/recipe_test.rb
test "strips whitespace from title before validation" do
  recipe = Recipe.new(title: "  Soup  ", prep_time: 15, servings: 4)
  assert recipe.valid?
  assert_equal "Soup", recipe.title
end
```

Run the model test:

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

The test should **fail** because nothing strips the title yet.

```bash
F

Failure:
RecipeTest#test_strips_whitespace_from_title_before_validation [test/models/recipe_test.rb:44]:
Expected: "Soup"
  Actual: "  Soup  "
```

### Green: add the callback

Add the `before_validation` line above your existing `private` section (callbacks are public declarations on the class, like `validates`):

```ruby
# app/models/recipe.rb
before_validation :strip_title_whitespace, if: -> { title.present? }

private

def strip_title_whitespace
  self.title = title.strip
end
```

Run the model test again. The strip test should pass with 0 failures and 0 errors.

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

You test the **outcome** on the record (`title` after `valid?`), not "did the callback run?" That is the usual pattern for normalization callbacks.

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: <<~MD
    Callbacks are easy to add and hard to remove. Before you add one, pause and ask: if callbacks did not exist today, where would I put this code? Whatever comes to mind first (a controller after save, a job, a model method you call from one place) is usually where it belongs. Put it there instead of hiding it in a callback so they can be tested explicitly.
  MD
) %>

## What belongs in model tests (and what does not)

You have now practiced built-in validations, a custom validation, an instance method, and a normalization callback. Not every line in `app/models/recipe.rb` needs a test. You can use the table below as a recap map.

| Kind | Model test? | Notes |
| --- | --- | --- |
| Built-in validations | Yes | TDD when you add the validation (this chapter, Chapter 5). |
| Custom validations | Yes | You wrote the logic; prove it in `test/models/`. |
| Instance methods | Only when heavy | Test methods that combine fields or have real business logic. Skip thin wrappers that duplicate a validation. |
| Callbacks | Avoid as much as possible, fine for normalizing values | Avoid mail, jobs, and external APIs in callbacks; call those from a controller or job instead. |
| Scopes | No separate model test when the index uses them as a filter | A scope is a named filter (saved query). Prove it on the page readers use, with an integration test. |

## When a model test is enough

Reach for `test/models/` when the question is only about the record in Ruby:

- Built-in validations on a column: negative `prep_time` or `servings` should add an error on that attribute after `valid?` (same red/green TDD flow you used for blank `title` in Chapter 5).
- Custom validation logic you wrote yourself: a description that is only spaces should be invalid and set an error on `description` (including the `nil?`, `empty?`, and `strip` guards so `""` and `"   "` are handled correctly).
- Instance methods that combine fields: `printable?` is true when `title` and positive `servings` are present, false when servings are missing.
- Normalization on the record: padded title `"  Soup  "` becomes `"Soup"` after `valid?` because of the `before_validation` strip.

Those tests build a `Recipe` (or load a fixture), call `valid?` or the method, and assert on `errors` or the attribute. No request, no HTML.

Reach for integration or system tests instead when the question needs HTTP or the UI:

- A filter on the recipe list: open the index, pick **Quick recipes (under 30 min)**, and only the fast recipes stay visible. The filter uses a scope on the model, but readers never type `Recipe.quick` in a console. They use the dropdown.
- Validation through a form: open **new**, post invalid data (for example a blank `title`), get 422, and see the error summary on the re-rendered form. Assert that errors **render**. One create test is enough since the edit shares the same `_form` partial.

The next two sections stay on the integration side: first the `quick` filter on the recipe list, then invalid data on create over HTTP.

## Scopes: add the query, prove the filter over HTTP

Right now your recipe index page shows all recipes at once. That looks fine because you only have two records in fixtures so the list is still small but in real apps this can be a very long list with 100s, even 1000s of records. Users often want a shorter list, for example "show me only the quick recipes that can be prepped in less than 30 minutes."

In Rails, that can be achieved by using a [scope](https://guides.rubyonrails.org/active_record_querying.html?utm_source=minitestrails.com#scopes). It is Rails' name for a **saved filter** on a model. You write the filter once on `Recipe` model, give it a short name, and call that name anywhere you need the same filtered list.

### Why teams use scopes

| Without a scope | With a scope |
| --- | --- |
| `Recipe.where("prep_time < ?", 30)` in the controller | `Recipe.quick` in the controller |
| Copy-paste the same `where` in a mailer or report | Call `Recipe.quick` again |
| Hard to read what "under 30" means at a glance | The name `quick` documents the intent |

### Our rule: prep time under 30 minutes

We will add a scope named `quick` that relates to: **prep time is under 30 minutes** (not 30 exactly, and not blank).

Your fixtures from [Chapter 6](/guide/test-data-fixtures/#create-recipe-fixtures) already set up a clean split for each recipe with different prep_time in each record:

| Fixture | `prep_time` | Quick filter should |
| --- | --- | --- |
| `recipes(:pancakes)` | 15 minutes | Show (15 is under 30) |
| `recipes(:lentil_soup)` | 30 minutes | Hide (30 is not under 30) |

You can open `test/fixtures/recipes.yml` if you want to confirm those numbers. You do not need new fixture rows for this chapter.

When the filter is off, both Cookbook appear. When the reader picks **Quick recipes (under 30 min)**, pancakes stays and lentil soup drops off the list. That is the behavior we will prove in an integration test.

### Why we skip a model test for `quick`

A model test could call `Recipe.quick` in Ruby and count rows. That is fast, but it does not prove the **page** works: the dropdown, the query param, and the HTML list.

In the app, users will use the filter form on the index page, instead of `Recipe.quick` in isolation. So we write a **red integration test first**, add the scope and wire the controller, then see **green**. Same TDD habit as the validation sections, different folder because the story is HTTP.

### What counts as working?

| Scenario | You might say |
| --- | --- |
| Quick filter on index | Index shows the filter form; submitting **Quick** lists pancakes (15 mins) and hides lentil soup (30 mins) |

### Add scenarios to the test file

```ruby
# test/integration/recipes_integration_test.rb
  # Actor: guest (HTTP request, no browser)
  # Starting point: pancakes prep_time 15, lentil_soup prep_time 30 in fixtures
  # Action: visit index, then GET index with quick=1 (same as Apply filter on Quick)
  # Expected outcome: filter form on page; pancakes title in HTML; lentil soup title absent
  # test "filters the list to quick recipes" do
  # end
```

### Red: write the integration test first

The test simulates choosing **Quick recipes (under 30 min)** and checks which fixture titles appear in the HTML. If all of this feels superficial, don't worry, you will also surf through the index page and use the filter in the real app in a while.

Start with the filtered list only for now. You will need to add form assertions after the filter markup exists in later sections.

Add the following to the recipes integration test:

```ruby
# test/integration/recipes_integration_test.rb
test "filters the list to quick recipes" do
  get recipes_url, params: { quick: "1" }
  assert_response :success
  assert_match recipes(:pancakes).title, response.body
  assert_no_match recipes(:lentil_soup).title, response.body
end
```

Note that pancakes with 15 minutes prep_time should stay while lentil soup with 30 minutes prep_time should not (our rule is **under** 30, not "30 or less").

<%= render Shared::Tip.new(
  title: "New assertion",
  markdown: <<~MD
    **`assert_no_match recipes(:lentil_soup).title, response.body`** asks "is this text absent from the HTML?"
    Syntax: `assert_no_match` plus a string or regex, then the haystack (here `response.body`). Use it when a filtered list should hide a fixture row.
  MD
) %>

Run the integration test:

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

You want a **failure** on the new test. The index still lists every fixture, so lentil soup's title is still in `response.body`. That is **red**.

```bash
F

Failure:
RecipesIntegrationTest#test_filters_the_list_to_quick_recipes [test/integration/recipes_integration_test.rb:84]:
Expected /Lentil\ soup/ to not match "<!DOCTYPE html>...(index page HTML)..."
```

The HTML in your terminal will be longer. Look for the assertion line and the `Expected ... to not match` message.

### Green: add the `quick` scope, then wire the controller

#### Step 1: add the filter on the model

Add to `app/models/recipe.rb`:

```ruby
# app/models/recipe.rb
scope :quick, -> { where(prep_time: ...30) }
```

`...30` is Ruby's way to write "less than 30." Pancakes at 15 match. Lentil soup at exactly 30 does not.

This is a scope in Rails terms: a class method on `Recipe` that returns a relation you can keep chaining with other scopes or queries. You will call it in the controller with `Recipe.quick`.

Run the integration file again. The filter test should still **fail**. That's because the scope exists but `RecipesController#index` still loads every recipe and doesn't use the `quick` filter param.

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

#### Step 2: use the scope when the reader asks for quick recipes

Update `RecipesController#index` to use the `quick` scope:

```ruby
# app/controllers/recipes_controller.rb
def index
  @recipes = params[:quick] == "1" ? Recipe.quick : Recipe.all
end
```

This code is telling the controller: when `quick` is `"1"`, load `Recipe.quick` (under 30 minutes) otherwise load all recipes. `== "1"` matches the value the select box will send. We will treat an empty string as "all recipes."

Run the integration test again:

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

This time tests should pass; you want 0 failures and 0 errors. The filter test is **green**.

### Add filter form on the index page

The test already proved the param works. That worked because we didn't have to "click" anything in the form. But in the app opened in the browser, users will still need a control on the page to filter between all recipes and quick recipes.

Most Rails apps use a **GET form** for filters like this: pick an option, click **Apply filter**, and the same page reloads with query params in the URL without needing additional JavaScript. GitHub issue filters, admin tables, and shop catalogs work the same way.

Add the following code just below the `<h1>Recipes</h1>` in `app/views/recipes/index.html.erb`:

```erb
<%%# app/views/recipes/index.html.erb %>
<%%= form_with url: recipes_path, method: :get, local: true, class: "recipe-filters" do %>
  <label for="quick">Show</label>
  <%%= select_tag :quick,
        options_for_select(
          [["All recipes", ""], ["Quick recipes (under 30 min)", "1"]],
          params[:quick]
        ),
        id: "quick" %>
  <%%= submit_tag "Apply filter" %>
<%% end %>
```

<%= render Guide::LoadDevelopmentFixtures.new(
  after_load: <<~MD
    Load fixtures before you try the filter in the browser. Fluffy pancakes (`prep_time` 15) should stay when you pick `Quick recipes (under 30 min)` while Lentil soup (`prep_time` 30) should disappear.
  MD
) %>

Next, quickly surf in the browser before we add more tests to reflect this new filter: 

1. Pick **Quick recipes (under 30 min)**
2. Click **Apply filter**
3. Confirm only pancakes stays (15 minutes). Lentil soup (30 minutes) should disappear.
4. Pick **All recipes** and apply again to see both.

### Expand the filter test for the form

We have already proved the scope and controller code works using list assertions. Now, we will add more checks to ensure the filter control exists on the index before you simulate a submit.

Update the test to the following:

```ruby
# test/integration/recipes_integration_test.rb
test "filters the list to quick recipes" do
  get recipes_url
  assert_response :success
  assert_select "form.recipe-filters"
  assert_select "select#quick"

  get recipes_url, params: { quick: "1" }
  assert_response :success
  assert_match recipes(:pancakes).title, response.body
  assert_no_match recipes(:lentil_soup).title, response.body
end
```

What's happening in the code?

- The first `get` is "I am on the recipe list."
- `assert_select` checks the filter form and dropdown are in the HTML.
- The second `get` with `quick: "1"` is what **Apply filter** sends (GET form, same URL, query param).

Run the integration test again. You want 0 failures and 0 errors.

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

## HTTP reflects model validations (more integration tests)

In Chapter 5, you proved blank title on the model. In this section, you will prove those validations work in the form the same way over HTTP: open new or edit, submit bad data, and see validation errors on the re-rendered page.

New and edit share the same `validates :title` and usually the same `_form` partial. You do not need a second integration test on edit for the same error message. The model test already proves the validation in Ruby; one more HTTP test on create proves the controller re-renders the form with errors.

This test uses a **blank title** as invalid data. The test name stays generic (`invalid data`) because the assertion checks that **errors show on the form**, not one exact validation string. That survives copy tweaks and extra validations later.

You are adding the validation error test in the integration file because lone `422` only checks the controller; it does not prove the reader sees errors. Skip `get new_recipe_url` here; go straight to a failed `post` and assert on the re-rendered form.

### What counts as working?

| Flow | You might say |
| --- | --- |
| Create with invalid data | When I open new, post a blank title, no row is created, the response is 422, and the form shows the error summary |

### Add scenarios to the test file

Add commented scenario lines in `test/integration/recipes_integration_test.rb`. Leave your tests from Chapters 5 through 7 unchanged.

```ruby
# test/integration/recipes_integration_test.rb
  # Actor: guest (HTTP request, no browser)
  # Starting point: new recipe form available
  # Action: POST create with invalid data (blank title)
  # Expected outcome: no new row; 422; error summary on re-rendered new form
  # test "does not create a recipe with invalid data" do
  # end
```

### Add the integration test

Fill in the test body with the following code:

```ruby
# test/integration/recipes_integration_test.rb
test "does not create a recipe with invalid data" do
  assert_no_difference("Recipe.count") do
    post recipes_url, params: { recipe: { title: "" } }
  end
  assert_response :unprocessable_entity
  assert_match /prohibited this recipe from being saved/i, response.body
  assert_select "h1", "New recipe"
end
```

`creates a recipe` already proved the new form loads. This test only needs a failed `post` and assertions on the re-rendered form.

The regex matches the error summary heading Rails scaffolds in `_form.html.erb` when `recipe.errors` is present. You are proving errors **render on the page**, instead of locking the test to one message like `can't be blank`.

<%= render Shared::Tip.new(
  title: "New assertions",
  markdown: <<~MD
    1. **`assert_response :unprocessable_entity`**
      Last response was 422, the status Rails often uses when a form fails validation over HTTP.

    2. **`assert_no_difference("Recipe.count") { ... }`**
      Row count must not change inside the block. Wrap a `post` that should be rejected.

    3. **`assert_match /prohibited this recipe from being saved/i, response.body`**
      The scaffold error block in `_form.html.erb` when validation fails. Proves the form surfaced errors along with 422 error status.

    4. **`assert_select "h1", "New recipe"`** (after the failed `post`)
      Still on the new page with the form, not redirected away.
  MD
) %>

Run the integration file:

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

You want 0 failures and 0 errors. Blank title was already invalid on the model from Chapter 5, so this should pass once the test is in place.

<%= render Guide::SupportCta.new(
  variant: :mid_chapter,
  site_metadata: site.data.site_metadata,
  milestone_hook: "Model validations, a scope on the index, and invalid data on create all have integration tests that check the HTML, not only status codes.",
  headline: "Model and HTTP layers wired together is a real milestone.",
  body_text: "Chapters like this are where testing habits stick: fast model tests for validations, integration tests for what readers actually see. If that split is clicking, fund the next chapter and keep the guide free."
) %>

### Full `recipes_integration_test.rb` after Chapters 5-8

This is the assembled file once every HTTP scenario from the progression is in place. Diff against yours if anything differs (test order is fine).

```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
    assert_match "Recipes", response.body
    assert_match recipes(:pancakes).title, response.body
    assert_select "#recipes div[id^='recipe_']", count: Recipe.count
  end

  test "shows a recipe" do
    get recipe_url(recipes(:pancakes))
    assert_response :success
    assert_match recipes(:pancakes).title, response.body
  end

  test "creates a recipe" do
    get new_recipe_url
    assert_response :success
    assert_match "New recipe", response.body

    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
    assert_match "Lentil soup", response.body
  end

  test "does not create a recipe with invalid data" do
    assert_no_difference("Recipe.count") do
      post recipes_url, params: { recipe: { title: "" } }
    end
    assert_response :unprocessable_entity
    assert_match /prohibited this recipe from being saved/i, response.body
    assert_select "h1", "New recipe"
  end

  test "updates a recipe" do
    recipe = recipes(:pancakes)

    get edit_recipe_url(recipe)
    assert_response :success
    assert_select "h1", "Editing recipe"

    patch recipe_url(recipe), params: { recipe: { title: "Extra fluffy pancakes" } }
    assert_redirected_to recipe_url(recipe)
    follow_redirect!
    assert_response :success
    assert_match "Extra fluffy pancakes", response.body
  end

  test "destroys a recipe" do
    recipe = recipes(:lentil_soup)

    assert_difference("Recipe.count", -1) do
      delete recipe_url(recipe)
    end
    assert_redirected_to recipes_url
    follow_redirect!
    assert_response :success
    assert_select "#recipes div[id^='recipe_']", count: Recipe.count
  end

  test "filters the list to quick recipes" do
    get recipes_url
    assert_response :success
    assert_select "form.recipe-filters"
    assert_select "select#quick"

    get recipes_url, params: { quick: "1" }
    assert_response :success
    assert_match recipes(:pancakes).title, response.body
    assert_no_match recipes(:lentil_soup).title, response.body
  end
end
```

## Commit your work

Run the full suite:

```bash
bin/rails test:all
```

You want 0 failures and 0 errors. Then:

```bash
git add .
git commit -m "Add Recipe validations, title strip callback, quick filter, and model tests"
```

Small commits make it easier to roll back, bisect a regression, or pick up on another machine.

## What is next

[Chapter 9](/guide/second-resource-and-associations/) grows Recipes with `ingredients` and `steps` nested under a recipe. And that means: more forms, more model tests, more system paths, and associations inside a fixture (`recipe: pancakes` in child YAML).

Continue to **[Testing nested resource and associations](/guide/second-resource-and-associations/)**.
