Testing simple CRUD (system tests)

Chapter 6 gave you named recipes in YAML using fixtures and stable starting data in every test. Chapter 5 added integration tests for list, create, and show, plus one smoke 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?”

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 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 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 using matchers like visit, fill_in, click_on, assert_text, and friends. They inherit from ApplicationSystemTestCase (see Chapter 1).

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

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:

Chapter 2 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, Chapter 6, this chapter, Chapter 8).

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

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

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:

<%= 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 started with list and create. Chapter 6 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 and Chapter 6 unchanged.

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

# 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

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

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.

# 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

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

# 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

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

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

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

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

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

You should see 0 failures and 0 errors.

Create #

# 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

Update #

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

# 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

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

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.

You now have full CRUD browser coverage running green, which is a major milestone in this guide.

Full CRUD in a real browser is a serious win.

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.

One-time support via Stripe. No account required.

Headful mode (see the browser while tests run) #

By default, Chapter 1 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:

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

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

Run in headful mode:

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.

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

Run:

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

You will see something like this in your terminal:

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

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:

bin/rails test:all

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

Commit your work #

When green:

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

Keep Minitest Rails independent

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

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

Something unclear in this chapter?

Send feedback

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