Testing Rails models with Minitest

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 using red/green TDD on the model. After CRUD in Chapter 7, 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.

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 scaffolded the recipes table. You have been using it in fixtures, integration tests, and system tests since Chapter 6. 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:

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

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

# 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. 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:

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:

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:

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

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:

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

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

bin/rails test test/models/recipe_test.rb

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

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:

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

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.

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 #

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

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

bin/rails test test/models/recipe_test.rb

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

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 #

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

Run again. You want 0 failures and 0 errors.

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, Chapter 14). External HTTP belongs behind a job and stubs (Chapter 16). 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 #

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

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

bin/rails test test/models/recipe_test.rb

The test should fail because nothing strips the title yet.

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):

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

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.

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

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

# 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”).

Run the integration test:

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.

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:

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

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:

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

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:

<%# 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 %>

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:

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

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.

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

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

Run the integration file:

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.

Model validations, a scope on the index, and invalid data on create all have integration tests that check the HTML, not only status codes.

Model and HTTP layers wired together is a real milestone.

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.

One-time support via Stripe. No account required.

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).

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

bin/rails test:all

You want 0 failures and 0 errors. Then:

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

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.

Disclaimer: This guide is based on hands-on Rails and testing experience and was proofread by AI. I stand by the advice and patterns here.