[Chapter 6](/guide/test-data-fixtures/) gave you named recipes in YAML using fixtures and stable starting data in every test. [Chapter 5](/guide/your-first-test/) added integration tests for list, create, and show, plus one [smoke](/guide/kinds-of-rails-tests/#general-testing-words-vs-rails-words) system test that the list loads in a browser. This chapter completes the integration tests with update and destroy, then adds smoke-style system tests for the main pages you click by hand: list, detail, new, edit, and delete.

System tests are the slowest tool in the box, but they are the closest thing to "I clicked it and it worked."

Please note that I do not write a lot of system tests, nor do I try to make them comprehensive. I reach for them when I want to know the browser can still open the important pages and the buttons I care about still work. Think: list loads, detail loads, the new form opens, edit opens, destroy works. Integration tests carry most of the behavior proof. System tests are the thin safety net on top when JavaScript, Turbo, or timing might hide problems that integration tests cannot see. Treat this chapter as the reference you come back to when someone asks "how many system tests should we have?"

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: <<~MD
  Smoke tests in this guide mean a small browser check in `test/system/`: Does a page load? Does a button exist? Does a page still open? We defined this term in [Chapter 2](/guide/kinds-of-rails-tests/#general-testing-words-vs-rails-words) while also elaborating that integration tests are **not** smokes. Because they prove full outcomes (status, redirect, body, row counts) in `test/integration/`.
  MD
) %>

<%= render Guide::ChapterProgress.new(
  feature: "No new database columns. Add a destroy confirm dialog, finish integration update and destroy, then smoke-style browser checks for list, detail, new, edit, and delete.",
  why: "Fixtures gave you stable data. Integration tests already prove redirects and row counts. System tests answer whether the UI still opens and the important buttons still work in a real browser.",
  test: "Integration: update and destroy in `test/integration/recipes_integration_test.rb`.
  System: smoke paths in `test/system/recipes_test.rb`.",
  later: "Blank-title failures with integration tests wait until [Chapter 8](/guide/testing-models/). [Chapter 11](/guide/testing-authentication/) updates create, update, and destroy tests for sign-in."
) %>

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: <<~MD
  **CRUD** means Create, Read, Update, Delete. For Recipes that maps to: create a recipe (C - Create), view the list or detail of the single recipe (R- Read), edit a recipe (U - Update), and destroy a recipe (D - Delete).
  MD
) %>

## What you will do in this chapter

1. Click through update and destroy in the browser, add a confirm dialog on delete, then write scenarios.
2. Add integration tests for update and destroy in `test/integration/recipes_integration_test.rb`.
3. Add smoke-style system tests in `test/system/recipes_test.rb` for list, detail, new, edit, and delete.
4. Run `bin/rails test:all` green before you commit.

## Why this chapter still uses a guest who can change recipes

[Chapter 5](/guide/your-first-test/#why-guests-can-create-and-edit-recipes-until-chapter-11) explained the choice: we simplify the first half of the guide so you learn testing mechanics before authentication mechanics. Here you practise full CRUD in the browser while the app still allows it.

That is temporary. In [Chapter 11](/guide/testing-authentication/) you will add sign-in and lock the app down so guests only view the recipe list and detail pages. After adding authentication, they will not be able to create, edit, or delete the recipe. You will update the tests from this chapter when that happens.

## Flows you will cover

| Flow | Scenario sketch | Starting data | Actor in this chapter |
| --- | --- | --- | --- |
| List | Visitor sees the list | Fixtures visible on the list page | Guest (still the same after Ch 11) |
| Show | Open one recipe | `recipes(:pancakes)` | Guest (still the same after Ch 11) |
| Create | Valid form submit lands on show | Empty or existing list | Guest today; signed-in only after Ch 11 |
| Update | Change title on edit | Fixture recipe | Guest today; owner signed in after Ch 12 |
| Destroy | Recipe disappears from the list | Fixture you can delete | Guest today; owner signed in after Ch 12 |

## How I use system tests

Bookmark this section. When a teammate asks "should we system-test everything?" or "why did Rails drop system tests from `rails new`?", this is the answer in one place.

### What a system test is

System tests live in `test/system/`. They drive a real (usually headless) browser through [Capybara](https://rubydoc.info/github/teamcapybara/capybara/master?utm_source=minitestrails.com) using matchers like `visit`, `fill_in`, `click_on`, `assert_text`, and friends. They inherit from `ApplicationSystemTestCase` (see [Chapter 1](/guide/setting-up-minitest/#what-is-inside-testapplication_system_test_caserb)).

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: <<~MD
  In Capybara, **`visit`**, **`fill_in`**, and **`click_on`** are sometimes called **matchers** or DSL methods. They are commands that act like a user in the browser. **`assert_text`** and **`assert_selector`** are assertions: they check the page has "something" after performing certain actions.
  MD
) %>

The question a system test answers is narrow: **if a person uses the UI on an important page, does it still work?** Not "did every validation message render?" and not "did every edge case in the model hold?". Those belong in faster tests first.

### Why I keep the suite small

System tests are the slowest layer in the box. They can also be [flaky](/guide/kinds-of-rails-tests/#why-rails-splits-tests-at-all): the test passes on one run and fails on the next because the page was not ready yet, a Turbo stream was still updating, or a label on the page changed. When a system test fails, the message is often farther from the root cause than a model or integration failure.

So my habit is intentional: integration (and model) tests **carry behavior.** System tests are a **small smoke suite** on top. I am not trying to replay every integration scenario in the browser.

I only automate **happy paths** in system tests. Blank titles, unauthorized users, and other edge cases belong in model and integration tests where failures are faster and clearer.

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: <<~MD
  A happy path is the straightforward success case: valid input, allowed user, expected page at the end. [Chapter 4](/guide/how-to-approach-testing/) had you write scenarios around that idea before you automated anything. Unhappy paths (bad input, wrong user, missing data) usually get model or integration tests first in this guide.
  MD
) %>

For Recipes, a small smoke suite looks like this:

| Page | What I want to know in the browser |
| --- | --- |
| List | The list page loads and shows expected fixture copy. |
| Detail | A show page opens for one recipe. |
| New | The new form opens and a valid submit still works. |
| Edit | The edit form opens and a valid update still works. |
| Delete | Destroy opens the confirm dialog; and the row disappears from the list. |

That is what you will automate below. One short test per page, not every validation and error state.

### Why Rails 8 stopped generating them by default

Rails stopped scaffolding `test/system/` on every `rails new`. Maintainers were clear about the reason: "system tests are a nice-to-have, not something you 
want for every endpoint by default. They belong on **critical paths**, especially where JavaScript 
is involved."

You can read more about the decision in the Rails repo:

- [Don't generate system tests by default](https://github.com/rails/rails/pull/55743?utm_source=minitestrails.com)
- [Skip all system test files on app generation](https://github.com/rails/rails/pull/56272?utm_source=minitestrails.com)


[Chapter 2](/guide/kinds-of-rails-tests/#system-tests-testsystem) made the same point about slower, flakier runs. This chapter is where we practise the small, smoke-shaped style Rails is nudging you toward.

### Do

- Write integration tests first for behavior.
    
    status codes, redirects, row counts, validation failures (refs: [Chapter 5](/guide/your-first-test/), [Chapter 6](/guide/test-data-fixtures/), this chapter, [Chapter 8](/guide/testing-models/)).
- Keep **one clear outcome** per system test.

    "list loads with fixture text", not "entire app works".
- Use fixtures for stable starting rows (see: [Chapter 6](/guide/test-data-fixtures/)).

- Add system tests when integration is not enough:

    visible copy, confirm dialogs, Turbo-driven UI, JavaScript you cannot see in `response.body`.

- Match real button and label text from your scaffold so system tests break when copy changes.
- Keep assertions minimal. Assert the one thing that proves the flow worked.

    Good: `assert_text "Extra fluffy pancakes"` after update.
    
    Too much: asserting every label, every field value, and every button on the page in the same test.
- Run `bin/rails test:system` or `bin/rails test:all` before you merge. Fix one file at a time when red.

### Do not

- Duplicate every integration test in a browser.

  If `post` + `follow_redirect!` already proves create, you do not need a second system test that only repeats the same happy path unless the browser adds information.
- Assert every validation message in system tests.

  Model tests and integration tests are faster and more precise for "blank title rejected."
- Build mega journeys

  (sign up, create three records, email a friend, delete two) in one method. When step six fails, you still do not know which step broke.
- Reach for system tests first on server-rendered CRUD with no JavaScript risk.

    Start with model and integration; add browser smoke when the UI is worth the cost.

[Chapter 10](/guide/request-controller-tests/) goes deeper on when to use which test type: integration vs system tests. Integration tests are enough when status, redirect, and response body tell the story. Reach for system tests when you need the browser.

## Surf update and destroy first

[Chapter 5](/guide/your-first-test/#surf-the-app-before-http-tests) had you click through full CRUD in the browser before any integration tests. If it has been a while, do it again for **edit** and **destroy** before you automate them here. Because Rails already scaffolded the controller and views, there is no red/green TDD loop for those files in this guide. You surf first, write the scenario, then add tests.

Start the server if it is not running:

```bash
bin/dev
```

Open `http://localhost:3000/recipes` and walk through:

| Step | What to do | What you should see |
| --- | --- | --- |
| Edit | Open a fixture recipe, click Edit, change the title, save | Show page shows the new title |
| Destroy | Open a recipe, click Destroy | Recipe is gone from the list right away |

That last step is worth noticing. A fresh Rails scaffold does **not** ask "Are you sure?" before delete. One click and the row is gone. That is fine for learning, but it is not what you want in a real app.

### Add a confirm dialog before delete

Delete is destructive. A stray click or a slip on a trackpad should not wipe a recipe (or a customer order, or a blog post). A short confirm step is normal UX for actions you cannot undo in one tap.

Turbo ships with Rails, so you do not need extra JavaScript. Add `data: { turbo_confirm: "..." }` to the Destroy button. Turbo shows a browser confirm dialog with your message before it sends the delete request.

Open `app/views/recipes/show.html.erb` and find the Destroy `button_to`. Change it to:

```erb
<%%= button_to "Destroy this recipe", @recipe, method: :delete, data: { turbo_confirm: "Are you sure?" } %>
```

Go through the delete feature one more time:

| Step | What to do | What you should see |
| --- | --- | --- |
| Destroy (with confirm) | Open a recipe, click **Destroy this recipe**, click OK in the dialog | Recipe is gone from the list |

You will use `accept_confirm` in the system test below to click through that same dialog. Integration tests skip the browser, so they send `delete` directly and never see the prompt.

## Finish integration coverage (update and destroy)

[Chapter 5](/guide/your-first-test/) started with list and create. [Chapter 6](/guide/test-data-fixtures/) added show. This chapter adds update and destroy before the smoke-style browser checks later in the chapter.

### What counts as working?

| Flow | You might say |
| --- | --- |
| Update | When I open the edit form in an integration test, send a patch with a new title, and follow the redirect, the recipe row in the database shows the new title. |
| Destroy | When I send delete for a fixture recipe in an integration test, the row count drops by one and Rails redirects to the list. |

### Add scenarios to the test file

Add commented scenario lines for the two new tests in `test/integration/recipes_integration_test.rb`. Leave your existing tests from [Chapter 5](/guide/your-first-test/) and [Chapter 6](/guide/test-data-fixtures/) unchanged.

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

class RecipesIntegrationTest < ActionDispatch::IntegrationTest
  # Actor: guest (HTTP request, no browser)
  # Starting point: recipes(:pancakes) exists in fixtures
  # Action: GET edit form, PATCH new title, follow redirect
  # Expected outcome: success on edit and redirect to recipe detail page; title updated in the database
  # test "updates a recipe" do
  # end

  # Actor: guest (HTTP request, no browser)
  # Starting point: recipes(:lentil_soup) exists in fixtures
  # Action: DELETE the recipe
  # Expected outcome: row count down by one; redirect to the list
  # test "destroys a recipe" do
  # end
end
```

### Add update

Fill in `test "updates a recipe"` when you are ready. The test loads the edit form, sends the new title with `patch`, follows the redirect to show, then checks the database.

```ruby
# test/integration/recipes_integration_test.rb
test "updates a recipe" do
  recipe = recipes(:pancakes)

  get edit_recipe_url(recipe)
  assert_response :success

  patch recipe_url(recipe), params: { recipe: { title: "Extra fluffy pancakes" } }
  assert_redirected_to recipe_url(recipe)
  follow_redirect!
  assert_response :success
  assert_equal "Extra fluffy pancakes", recipe.reload.title
end
```

<%= render Shared::Tip.new(
  title: "New assertions",
  markdown: <<~MD
    1. **`patch recipe_url(recipe), params: { recipe: { title: "..." } }`**
      Sends an update for an existing row over HTTP. Same nested `recipe:` params shape as `post` on create.

    2. **`recipe.reload`**
      Reads the row from the database again. Use before `assert_equal` so you are not checking stale data still in memory from before the patch.
  MD
) %>

This is what's happening in the code above:

- `get edit_recipe_url(recipe)` opens the edit form. Same `get` helper you used in [Chapter 5](/guide/your-first-test/).
- `patch recipe_url(recipe), params: { ... }` updates the existing row. You send only the fields that changed (here the title).
- `follow_redirect!` loads the show page after Rails redirects.
- `recipe.reload` reads the row from the database again. `assert_equal` checks the saved title. You met `assert_equal` in [Chapter 6](/guide/test-data-fixtures/#check-the-count-yourself).

### Add destroy

Fill in `test "destroys a recipe"` next. The test deletes a fixture recipe over HTTP and checks the row count and redirect. No browser, so the confirm dialog from the surf section never appears.

```ruby
# test/integration/recipes_integration_test.rb
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
end
```

<%= render Shared::Tip.new(
  title: "New assertions",
  markdown: <<~MD
    1. **`assert_difference("Recipe.count", -1) { ... }`**
      Asks "did the row count drop by exactly one inside this block?"
      Syntax: same as create in [Chapter 5](/guide/your-first-test/), but use `-1` for destroy.

    2. **`delete recipe_url(recipe)`**
      Removes the record at that URL over HTTP. No browser, so the confirm dialog never runs.
  MD
) %>

This is what's happening in the code above:

- `assert_difference("Recipe.count", -1)` wraps the delete and expects exactly one fewer row.
- `delete recipe_url(recipe)` removes the record at that URL.
- `assert_redirected_to recipes_url` checks Rails sends you back to the list.

### Update the list integration test to verify recipe count on the page

In [Chapter 6](/guide/test-data-fixtures/) you made `visits the list` assert that fixture title text appears in the response body. In this chapter, strengthen that same test to also assert the list renders one block per fixture recipe.

Replace the list test body with:

```ruby
# test/integration/recipes_integration_test.rb
test "visits the list" do
  get recipes_url
  assert_response :success
  assert_match recipes(:pancakes).title, response.body
  assert_select "#recipes div[id^='recipe_']", count: Recipe.count
end
```

<%= render Shared::Tip.new(
  title: "New assertion",
  markdown: <<~MD
    **`assert_select "#recipes div[id^='recipe_']", count: Recipe.count`** asks "did this selector appear exactly this many times?"
    Syntax: `assert_select` plus a CSS selector, then options like `count:`. Here each fixture recipe renders as a `div` with an id like `recipe_1` inside `#recipes`. Open the list page HTML in your browser if your scaffold uses different markup.
  MD
) %>

Run the integration file green before you move on to system tests:

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

You want 0 failures and 0 errors.

## System tests: smoke paths in the browser

[Chapter 5](/guide/your-first-test/#system-smoke-one-browser-check) left you with `test/system/recipes_test.rb` and one smoke test (`visits the list`). This section adds the other paths: detail, new, edit, and delete. Same class (`RecipesTest`), same fixtures, real clicks in the browser through Capybara.

You are not building a comprehensive browser suite. You are checking that the main pages still open and the buttons you rely on still work.

### What counts as working?

| Flow | You might say |
| --- | --- |
| List | When a guest opens the list in a browser, fixture titles appear on the page. |
| Show | When a guest opens one recipe, that recipe's title is visible on the page. |
| Create | When a guest fills in a valid title and submits, the new title appears on the page after submit. |
| Update | When a guest edits a fixture recipe and saves a new title, the new title appears on the page. |
| Destroy | When a guest clicks **Destroy this recipe** and confirms, the recipe no longer appears on the list page. |

Unhappy paths (blank title, wrong user) stay in model and integration tests.

### Add scenarios to the system test file

Open `test/system/recipes_test.rb`. Update the scenario comments for `visits the list` if you still have them from Chapter 5, and add scenarios for the flows you have not automated yet:

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

class RecipesTest < ApplicationSystemTestCase
  # Actor: guest (browser)
  # Starting point: recipes(:pancakes) is in fixtures
  # Action: open the recipe list
  # Expected outcome: heading visible; pancake title on the page
  # test "visits the list" do
  # end

  # Actor: guest (browser)
  # Starting point: recipes(:lentil_soup) exists in fixtures
  # Action: open that recipe's show page
  # Expected outcome: recipe title visible
  # test "shows a recipe" do
  # end

  # Actor: guest (browser)
  # Starting point: new recipe form
  # Action: fill title and submit create
  # Expected outcome: new title visible on the page
  # test "creates a recipe" do
  # end

  # Actor: guest (browser)
  # Starting point: recipes(:pancakes) exists in fixtures
  # Action: edit title and submit update
  # Expected outcome: new title visible on the page
  # test "updates a recipe" do
  # end

  # Actor: guest (browser)
  # Starting point: recipes(:lentil_soup) exists in fixtures
  # Action: destroy from show page and confirm
  # Expected outcome: title no longer on the list
  # test "destroys a recipe" do
  # end
end
```

### List and show

Replace the body of `test "visits the list"` from Chapter 5 so it also checks fixture data. Add `shows a recipe` below it:

```ruby
# test/system/recipes_test.rb
test "visits the list" do
  visit recipes_url
  assert_selector "h1", text: "Recipes"
  assert_text recipes(:pancakes).title
end

test "shows a recipe" do
  recipe = recipes(:lentil_soup)
  visit recipe_url(recipe)
  assert_text recipe.title
end
```

Run:

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

You should see 0 failures and 0 errors.

<%= render Shared::Tip.new(
  title: "New assertion",
  markdown: <<~MD
    **`assert_text recipes(:pancakes).title`** asks "does this copy appear on the page?"
    Syntax: `assert_text` plus a string.
  MD
) %>

### Create

```ruby
# test/system/recipes_test.rb
test "creates a recipe" do
  visit new_recipe_url
  fill_in "Title", with: "Test tacos"
  click_on "Create Recipe"
  assert_text "Test tacos"
end
```

<%= render Shared::Tip.new(
  title: "New assertions",
  markdown: <<~MD
    1. **`fill_in "Title", with: "Test tacos"`**
      Types into the field with that label.

    2. **`click_on "Create Recipe"`**
      Clicks the link or button with that visible text.
      
    Both are Capybara steps, not Minitest assertions.
  MD
) %>

### Update

```ruby
# test/system/recipes_test.rb
test "updates a recipe" do
  recipe = recipes(:pancakes)
  visit edit_recipe_url(recipe)
  fill_in "Title", with: "Extra fluffy pancakes"
  click_on "Update Recipe"
  assert_text "Extra fluffy pancakes"
end
```

### Destroy

The confirm dialog comes from the `turbo_confirm` you added in the surf section. Capybara needs `accept_confirm` to click OK before the delete runs.

```ruby
# test/system/recipes_test.rb
test "destroys a recipe" do
  recipe = recipes(:lentil_soup)
  visit recipe_url(recipe)
  accept_confirm do
    click_on "Destroy this recipe"
  end
  visit recipes_url
  assert_no_text recipe.title
end
```

<%= render Shared::Tip.new(
  title: "New assertions",
  markdown: <<~MD
    1. **`accept_confirm do ... end`**
      Wrap the click that opens the browser confirm dialog, then accept it. This runs the delete path.

    2. **`assert_no_text recipe.title`**
      Asks "is this string removed from the page?"
      Syntax: `assert_no_text` plus a string. Here, after delete on the list.
  MD
) %>

Run the system file again when all five paths are in place:

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

You want 0 failures and 0 errors. If a system test fails, read the screenshot path Minitest prints. Wrong button text and timing are the usual causes.

<%= render Guide::SupportCta.new(
  variant: :mid_chapter,
  site_metadata: site.data.site_metadata,
  milestone_hook: "You now have full CRUD browser coverage running green, which is a major milestone in this guide.",
  headline: "Full CRUD in a real browser is a serious win.",
  body_text: "Full browser CRUD is where teams either trust their suite or give up on system tests. If this chapter gave you a repeatable pattern, fund the next chapter and keep the guide free."
) %>

## Headful mode (see the browser while tests run)

By default, [Chapter 1](/guide/setting-up-minitest/) configured headless Chrome: the browser runs with no window. That is fine for day-to-day runs but when a system test fails and you cannot tell why, headful mode is much better. Headful mode means the window is visible and you can see Capybara clicking through pages in your app in real time. The most important use case of headful mode is debugging an issue that you may not see while running tests in headless mode.

We will keep headless mode as default for running system tests. For the headful mode, we will add a `HEADFUL` switch so you can opt in to visible browser mode when debugging.

Update `test/application_system_test_case.rb` with the following:

```ruby
# test/application_system_test_case.rb
require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driver = ENV["HEADFUL"] == "1" ? :chrome : :headless_chrome
  driven_by :selenium, using: driver, screen_size: [ 1400, 1400 ]
end
```

Run as usual (headless):

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

Run in headful mode:

```bash
HEADFUL=1 bin/rails test:system test/system/recipes_test.rb
```

With `HEADFUL=1`, Chrome window opens and you see each step. Use this when you are learning system tests, debugging a failure, or checking browser-only behavior.

## Read failing system test output

Till now, we have only ever seen passing system tests; you might be curious how tests that fail may look like as well. Sometimes you need to see one failure end to end before it clicks.

Let's see a failing test next to quench that curiosity. Add the following temporary failing test to learn what Minitest prints.

```ruby
# test/system/recipes_test.rb
test "failing example for debugging output" do
  visit recipes_url
  assert_text "This text does not exist"
end
```

Run:

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

You will see something like this in your terminal:

```bash
[Screenshot Image]: /path/to/your/project/recipes/tmp/screenshots/failures_test_failing_example_for_debugging_output.png
F
Failure:
RecipesTest#test_failing_example_for_debugging_output [test/system/recipes_test.rb:43]:
expected to find text "This text does not exist" in "Recipes\nTitle: Lentil soup\nDescription: Simple dinner\nPrep time: 30\nServings: 6\nShow this recipe\nTitle: Fluffy pancakes\nDescription: Weekend breakfast\nPrep time: 15\nServings: 4\nShow this recipe\nNew recipe"
```

The above failure includes:

- A screenshot file path saved by the system test runner: `[Screenshot Image]: ...`
- The exact assertion line that failed: `test/system/recipes_test.rb:43`
- Expected text vs what was found: `expected to find text "This text does not exist" in ...`

Open the screenshot and compare it with the failing assertion. This is often the fastest way to spot wrong button text, wrong page, or timing issues.

### Remove the example failing test

The failing test has served it's purpose: I wanted you to see what the output of the failing test looked like. Now that you have seen the failing output once, delete this temporary failing test and re-run the file green.

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

You should now get 0 failures and 0 errors.

## Run the wider suite

Integration and system tests live in different folders. And we can run them both with `bin/rails test:all`:

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

You want 0 failures and 0 errors across the full run.

## Commit your work

When green:

```bash
git add .
git commit -m "Add destroy confirmation and Recipes CRUD system tests"
```

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

## What is next

You now have integration tests for behavior and a small smoke suite for the browser. That split is the habit to keep: grow integration when outcomes matter in requests and responses; add system tests sparingly when the UI itself is the risk.

[Chapter 8](/guide/testing-models/) adds model rules for `prep_time`, `servings`, and a custom description check, plus guidance on scopes and instance methods. It also adds blank-title failures in integration tests.

When you reach [Chapter 11](/guide/testing-authentication/), come back to the create, update, and destroy tests here: sign in first, keep list and show as guest tests, and assert guests do not see change buttons.

Continue to **[Testing models](/guide/testing-models/)**.
