[Chapter 10](/guide/testing-dynamic-forms/) made the edit form dynamic with StimulusJS: add and remove nested rows in the browser, then save all at once with Update Recipe. Stimulus is the right tool when it's about adding dynamic UI to the form.

This chapter is the next UX step using Turbo Frames and Streams. Turbo is useful when you should hit the server and update parts of the page. Turbo Drive (default since Rails 7) already avoids a classic full browser reload. A Turbo frame scopes which region of the response lands (for example the recipe list), so the page parts outside that frame stay put. Turbo Stream removes a card or row in place. That feels smoother than a Turbo Drive visit that replaces the whole page body, especially for deleting a single record or filtering the list.

Until now, destroying a recipe or nested row often redirected or rebuilt the full page body, and the quick filter did the same kind of Turbo Drive visit. You change that with Turbo Frames and Streams in this chapter. On the testing side, the same question as every other chapter still wins: did the outcome land (recipe card gone on destroy, list filtered), not whether a `turbo-stream` tag showed up?

<%= render Guide::ChapterProgress.new(
  feature: "Turbo Stream destroy for a recipe on the index; Turbo Stream remove for ingredients and steps on recipe show; Turbo Frame around the index quick filter.",
  why: "Chapter 10 solved in-form nested add/remove. Mid-page deletes and filtered lists should update in place: Turbo Stream remove on the card or row, Turbo Frame-scoped list swap instead of a Turbo Drive visit that replaces the whole page body.",
  test: "System: Destroy a recipe from the list; remove ingredients and steps from the detail page; filter recipes using Turbo updates.
  Integration: Remove ingredients and steps from the recipe.",
  later: "[Chapter 12](/guide/testing-authentication/) requires sign-in before you can create, update, or destroy."
) %>

## What you will do in this chapter

1. Destroy a recipe from the list with a Turbo Stream, then surf.
2. Update the system test for `destroy the recipe` to ensure page is redirected to the list after destroying the recipe, then surf.
3. Remove an ingredient and a step from the detail page with Turbo Stream, then surf.
4. Wrap the filter in the recipe list page inside a Turbo Frame so only the list region swaps instead of the full page reload, then surf.
5. Decide when StimulusJS and Turbo each earn their keep.
6. Commit after a green run.

## Flows you will cover

These are the outcomes you will prove by the end of the chapter.

| Flow | You might say | Test Type |
| --- | --- | --- |
| Destroy from list | On index, destroy lentil soup after confirm; card gone; user still on the list page | System (TDD) |
| Remove on show | On pancakes show, remove Salt and the preheat step, confirm each; both gone; Flour remains | System (TDD) |
| Nested remove over HTTP | `DELETE` ingredient and step; both counts −1 in the database; show HTML has no Salt and no preheat | Integration (after green) |
| Filter using Turbo | Submit quick filter; list shows pancakes, but not lentil soup; Frame scopes the swap to the list | System (no TDD; same outcome, only implementation change from full Turbo Drive to Turbo Frame) |

## Test outcomes, not Turbo markup (implementation)

A lot of times, you will be tempted to test the implementation details (code syntax or HTML markup) instead of the outcomes. This is a bad habit and you should avoid it. I especially see this tendency when working with Turbo Streams and Frames. A test that only checks Turbo markup can pass while the row never leaves the database, or fail while the user already saw the right result after a redirect.

So, what are the outcomes you should test while avoiding the implementation part?

| Flow | Assert this (outcome) | Avoid this (implementation) |
| --- | --- | --- |
| Destroy from the list | Recipe title gone from the list; you stayed on index | Response body includes `<turbo-stream action="remove" target="recipe_xxxx" />` |
| Remove on show | Ingredient and Step each decreased by a count of 1 in the database | Response body includes `<turbo-stream action="remove" target="ingredient_xxxx" />` or `<turbo-stream action="remove" target="step_xxxx" />` |
| Nested remove over HTTP | Ingredient and Step each decreased by a count of 1 in the database; show HTML has no Salt and no preheat; Flour remains | Only that a `turbo-stream` remove targeted `ingredient_…` |
| Filter using Turbo | Pancakes title still on filtered index; lentil soup gone | `assert_select "turbo-frame#recipes"` as the main proof |

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: <<~MD
    Updates made by the Turbo are visible only in the browser (not visible over HTTP check), so it doesn't help to use Integration tests. Turbo features are perfect candidate for **system** tests due to this reason. Use **integration** only when you add new routes or actions and need to prove the backend deletes with the right data.
  MD
) %>

## Destroy a recipe from the list

Destroy for the recipe was added in [Chapter 7](/guide/testing-simple-crud-system-tests/#add-a-confirm-dialog-before-delete). It destroyed the recipe and redirected to the list, so the title was gone after you landed there. Now you add **Destroy** on the index so one card can drop in place with a Turbo Stream, no redirect, smoother UX.

### What counts as working?

| Flow | You might say |
| --- | --- |
| Destroy from list | On index, destroy lentil soup after confirm; card gone; user still on the list page |

### Add scenarios to the test file

Add the following scenario and the test block to the end of the system test at `test/system/recipes_test.rb`:

```ruby
# test/system/recipes_test.rb
  # Actor: guest (until Chapter 12)
  # Starting point: recipes index
  # Action: accept_confirm, Destroy this recipe on lentil soup
  # Expected outcome: still on index; lentil soup title gone; pancakes still visible
  # test "destroys a recipe from the list" do
  # end
```

### Add the system test

Replace the body of the `test "destroys a recipe from the list"` with the following:

```ruby
# test/system/recipes_test.rb
  test "destroys a recipe from the list" do
    recipe = recipes(:lentil_soup)
    visit recipes_url

    assert_text recipe.title
    assert_text recipes(:pancakes).title

    accept_confirm do
      within("##{dom_id(recipe)}") { click_on "Destroy this recipe" }
    end

    assert_current_path recipes_path
    assert_no_text recipe.title
    assert_text recipes(:pancakes).title
  end
```

This is what's happening in the code above:

- `within("##{dom_id(recipe)}")` scopes the click to a particular recipe card so you do not hit Destroy on the wrong recipe.
- `assert_current_path recipes_path` proves you stayed on the same recipe list page without any redirect.
- `assert_no_text` / `assert_text` are the outcome to ensure the lentil soup recipe is gone from the list while the pancakes recipe is still visible. No check for a `<turbo-stream>` tag (testing outcomes, not implementation).

Run:

```bash
bin/rails test test/system/recipes_test.rb -i test_destroys_a_recipe_from_the_list
```

You should see an error. Index does not have **Destroy this recipe** inside each card yet. That is **red**.

```bash
E

Error:
RecipesTest#test_destroys_a_recipe_from_the_list:
Capybara::ElementNotFound: Unable to find link or button "Destroy this recipe" within #<Capybara::Node::Element tag="div" path="/HTML/BODY[1]/DIV[1]/DIV[1]">
```

### Green: Add destroy button and stream the response from the controller

Scaffold often puts `id="<%%= dom_id recipe %>"` on the recipe partial, while leaving the **Show this recipe** link in the index file *outside* that div. `dom_id` is required for the Turbo Stream to target the correct recipe card and if we leave the show button as it is then when the recipe card is destroyed, the show button will still be visible on the index page; not what we want.

Let's fix this by putting the whole list card in the partial and also add a destroy button to the recipe list page. The recipe partial is also used on show, so we will also pass a `for_show` local to hide the **Show this recipe** link on the detail page.

Replace the `_recipe.html.erb` partial with the following:

```erb
<%%# app/views/recipes/_recipe.html.erb %>
<%% for_show = local_assigns.fetch(:for_show, false) %>
<%% destroy_url = for_show ? recipe_path(recipe, format: :html) : recipe %>

<div id="<%%= dom_id recipe %>">
  <div>
    <strong>Title:</strong>
    <%%= recipe.title %>
  </div>

  <div>
    <strong>Description:</strong>
    <%%= recipe.description %>
  </div>

  <div>
    <strong>Prep time:</strong>
    <%%= recipe.prep_time %>
  </div>

  <div>
    <strong>Servings:</strong>
    <%%= recipe.servings %>
  </div>

  <%% unless for_show %>
    <p>
      <%%= link_to "Show this recipe", recipe, data: { turbo_frame: "_top" } %>
    </p>
  <%% end %>

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

This is what's happening in the code above:

- `for_show = local_assigns.fetch(:for_show, false)` reads an optional local. Index calls `render recipe` with no local, so `for_show` is `false`. Show will pass `for_show: true`.
- `destroy_url` picks the path for Destroy. On show (`for_show: true`) it is `recipe_path(recipe, format: :html)`, so the request hits `format.html` and redirects to the list. On the list it is plain `recipe`, so Turbo can ask for a Stream and get `turbo_stream.remove`.
- `unless for_show` wraps **Show this recipe** link so on the list you get the link. On show you hide the link since you are already on the detail page.
- `data: { turbo_frame: "_top" }` on that Show link breaks out of a Turbo Frame. When the list sits inside `turbo_frame_tag "recipes"`, `_top` makes the show action replace the whole page instead of navigating into that particular frame. Show HTML has no matching `recipes` frame, so Turbo would otherwise show "Content missing".
- `dom_id recipe` on the wrapper is what `turbo_stream.remove` targets when you destroy from the list.

Search `render @recipe` in the show page and update it to use the `for_show` local while keeping the rest of the code as it is:

```erb
<%%# app/views/recipes/show.html.erb %>

<%%# ... old code ... %>

<%%= render @recipe, for_show: true %>

<%%# ... old code ... %>
```

<%= render Shared::Tip.new(
  title: "Note",
  markdown: <<~MD
    You are probably going to see an extra Destroy button on the show page. Don't worry too much about it, you will drop it and clean up the show page in the next section.
  MD
) %>

Slim the index view to only render the partial (keeping your filter form and **New recipe** link as they are):

```erb
<%%# app/views/recipes/index.html.erb %>

<%%# ... old code ... %>

<div id="recipes">
  <%% @recipes.each do |recipe| %>
    <%%= render recipe %>
  <%% end %>
</div>

<%%# ... old code ... %>
```

Next, update the recipes controller so destroy action can answer with a Turbo Stream remove. That drops the card in place on the list instead of redirecting through `format.html`.

Finally, replace the destroy action with the following code at `app/controllers/recipes_controller.rb`:

```ruby
# app/controllers/recipes_controller.rb
def destroy
  @recipe.destroy!

  respond_to do |format|
    format.turbo_stream { render turbo_stream: turbo_stream.remove(@recipe) }
    format.html do
      redirect_to recipes_path,
                  notice: "Recipe was successfully destroyed.",
                  status: :see_other
    end
    format.json { head :no_content }
  end
end
```

Only `format.turbo_stream { render turbo_stream: turbo_stream.remove(@recipe) }` is new code here:

- `format.turbo_stream` tells the server to respond with a Turbo Stream for destroying the recipe.
- `render turbo_stream` renders the Turbo code back to the browser.
- `turbo_stream.remove(@recipe)` targets `dom_id(@recipe)` (for example `recipe_12`) from the recipe partial and only removes that particular recipe card from the list.

You might be wondering why we still have the `format.html` when the list uses Turbo Streams. It's because HTML clients and destroy from the show page in the next section still get the redirect to the list.

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: <<~MD
    Rails can also answer Turbo Streams with a view file:
    `format.turbo_stream` (without any block) looks for `app/views/recipes/destroy.turbo_stream.erb` whose body is a single `turbo_stream.remove @recipe` call. A separate turbo_stream file is useful when the stream grows and performs multiple actions (remove + flash + another update). For a one-line remove, I prefer the inline `render turbo_stream: turbo_stream.remove(...)` form like we have just added in the controller above. It's simple, one liner without any additional file and gets the job done.
  MD
) %>

Re-run the list destroy test:

```bash
bin/rails test test/system/recipes_test.rb -i test_destroys_a_recipe_from_the_list
```

You want 0 failures and 0 errors. That is **green**.

<%= render Guide::LoadDevelopmentFixtures.new(
  after_load: <<~MD
    Load fixtures so **Lentil soup** and **Fluffy pancakes** are on the list before you try Destroy.
  MD
) %>

Next, quickly surf the delete feature in the browser:

1. Start `bin/dev` if needed.
2. Open the recipes index.
3. On **Lentil soup**, click **Destroy this recipe**, confirm, and check the whole card disappears (title, Show, and Destroy), while you stay on the list (no page redirect or reload). The filter and **New recipe** should also stay in the page.
4. (Optional) Refresh the page to ensure the changes persisted: the lentil soup recipe is still gone from the list while pancakes recipe remains.

<%= render Shared::Tip.new(
  title: "Don't close the browser yet!",
  markdown: <<~MD
    Since you are already surfing a feature in the browser, take this chance to also see the duplicate Destroy buttons on show page.

    Open the detail page for **Fluffy pancakes** and count the **Destroy this recipe** buttons. You should see two buttons: one from the recipe partial you just wired, and one left over from the Chapter 7 show template. Do not click either yet. That double button is the cleanup target in the next section.
  MD
) %>

## Destroy a recipe from show page

Turbo Stream is a poor fit for destroy from show page because after the recipe is deleted, that show URL has nothing left to display. Instead, you want to land on the list after the delete operation.

### Remove the extra Destroy on show

You just saw two **Destroy this recipe** buttons on pancakes show at `app/views/recipes/show.html.erb`. Remove the one from the show template so only the partial's Destroy remains.

Search `app/views/recipes/show.html.erb` for the destroy button and remove it:

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

### Update the system test for the redirect

"Destroy from the list" and "Destroy from show page" have one subtle difference: list destroy stays on the list with a Turbo Stream remove, while show destroy uses HTML format and redirects to the list.

Right now in the system test for "destroys a recipe", you are only asserting that the recipe is removed from the list. To prove the user was redirected to the recipe list, add an assertion on the current path.

#### Add scenarios to the test file

Replace the scenario comment for the test `destroys a recipe` at `test/system/recipes_test.rb` with the following:

```ruby
# test/system/recipes_test.rb
  # Actor: guest (until Chapter 12)
  # Starting point: lentil soup show
  # Action: accept_confirm, Destroy this recipe
  # Expected outcome: redirected to the recipes list; lentil soup title gone
  # test "destroys a recipe" do
  # end
```

#### Update the system test

Replace the body of the test `destroys a recipe` with the following to assert the current path is the recipes list page:

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

    assert_current_path recipes_path
    assert_no_text recipe.title
  end
```

This is what's happening in the code above:

- `assert_current_path recipes_path` proves destroy redirected to the list (not Stream remove that leaves you on a deleted show URL).
- `assert_no_text recipe.title` is the same list outcome as Chapter 7, without a second `visit`.

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: <<~MD
    Destroy on the list uses the normal recipe URL, so the controller answers with `turbo_stream.remove` and you stay on the list. Destroy on show uses `recipe_path(recipe, format: :html)`, so `format.html` redirects to the list. Turbo stays on for both in the frontend so that confirmation dialog still works. Same `destroy` action, two different UX paths.
  MD
) %>

Run the system test, you want 0 failures and 0 errors.

```bash
bin/rails test test/system/recipes_test.rb -i test_destroys_a_recipe
```

<%= render Guide::LoadDevelopmentFixtures.new(
  after_load: <<~MD
    Load fixtures before you destroy **Fluffy pancakes** from the detail page.
  MD
) %>

Next, quickly surf in the browser:

1. Open **Fluffy pancakes**.
2. Click **Destroy this recipe**, confirm, and check you land on the recipes list without that title.
3. Confirm the delete operation still works on the list page as well by destroying another recipe and ensuring the title is gone from the list while you stay on the list page.

## Remove ingredients and steps from show

With the changes from [Chapter 10](/guide/testing-dynamic-forms/), you are able to remove ingredients and steps using the nested fields from the recipe form. In this section, you will add **Remove** buttons to the show page and test the ability to remove an ingredient or a step with one click.

New **Remove** buttons and nested destroy routes are custom code we will write by hand, so you will start with a system test first (red), and then wire the button in the show page (green).

### What counts as working?

| Flow | You might say |
| --- | --- |
| Remove on show | On pancakes show, remove Salt and the preheat step, both gone; Flour remains |
| Nested remove over HTTP | `DELETE` ingredient and step; both counts −1; show HTML has no Salt and no preheat |

### Add scenarios to the test file

Add the following scenario to the system test file at `test/system/recipes_test.rb`:

```ruby
# test/system/recipes_test.rb
  # Actor: guest (until Chapter 12)
  # Starting point: pancakes show
  # Action: accept_confirm, Remove on Salt, then Remove on preheat
  # Expected outcome: Salt and preheat gone from page; Flour still visible
  # test "removes ingredients and steps from the detail page" do
  # end
```

### Add the system test

Replace the body of the test `removes ingredients and steps from the detail page` with the following code:

```ruby
# test/system/recipes_test.rb
  test "removes ingredients and steps from the detail page" do
    recipe = recipes(:pancakes)
    visit recipe_url(recipe)

    assert_text "Salt"
    assert_text "Flour"
    assert_text steps(:preheat).instruction

    accept_confirm do
      within("##{dom_id(ingredients(:salt))}") { click_on "Remove" }
    end

    accept_confirm do
      within("##{dom_id(steps(:preheat))}") { click_on "Remove" }
    end

    assert_no_text "Salt"
    assert_text "Flour"
    assert_no_text steps(:preheat).instruction
  end
```

This is what's happening in the code above:

- You first start with asserting that the Salt and Flour ingredients are present on the show page.
- You then delete the Salt and the preheat step one by one by clicking the **Remove** button and confirming the dialog using `accept_confirm`.
- `within` scopes each click to the Salt row or the preheat step so you do not remove Flour (or the wrong step) by accident. `dom_id` helps you target the correct element.
- `assert_no_text` / `assert_text` verifies the outcome: Salt and the preheat step are gone, but Flour remains.

Run:

```bash
bin/rails test test/system/recipes_test.rb -i test_removes_ingredients_and_steps_from_the_detail_page
```

You should see an error. Show has no **Remove** buttons (and no nested destroy routes) yet. That is **red**.

```bash
E

Error:
RecipesTest#test_removes_ingredients_and_steps_from_the_detail_page:
Capybara::ElementNotFound: Unable to find css "#ingredient_xxxxx"
```

### Green: nested destroy routes, controllers, and Remove on show

We want to destroy ingredients and steps one by one from the detail page, this functionality is not possible right now with destroy action we have in the recipe controller. We will start first by adding a route so each ingredient and step can be destroyed individually from their own routes and controllers.

You might be asking "Why not dump everything into the recipe controller?" and the answer is that we want to keep the recipe controller focused on the recipe itself and not worry about the ingredients and steps. This also makes controller actions stick to RESTful principles and in return we get a cleaner testable code.

Add two new routes, one each for destroying ingredients and steps under recipes at the end of `config/routes.rb` file:

```ruby
# config/routes.rb
resources :recipes do
  resources :ingredients, only: :destroy
  resources :steps, only: :destroy
end
```

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: <<~MD
    We are nesting routes under recipes because ingredients and steps can't exist without a recipe due to the foreign key constraints. We are using the `only: :destroy` option so that Rails only open up the URL to destroy the ingredient or step. If we don't do this, Rails will introduce a new route for each action (index, show, create, update, destroy) for each resource.
  MD
) %>

Next, we need to generate the controllers for the new routes. Start by generating and opening the ingredients controller in the bash terminal with the following command:

```bash
nano app/controllers/ingredients_controller.rb
```

This will create a new file at `app/controllers/ingredients_controller.rb`, then add the following code:

```ruby
# app/controllers/ingredients_controller.rb
class IngredientsController < ApplicationController
  before_action :set_recipe, :set_ingredient

  def destroy
    @ingredient.destroy!
    render turbo_stream: turbo_stream.remove(@ingredient)
  end

  private

  def set_recipe
    @recipe = Recipe.find(params[:recipe_id])
  end

  def set_ingredient
    @ingredient = @recipe.ingredients.find(params[:id])
  end
end
```

Do the same for the steps controller, generate and open the file with the following command in the bash terminal:

```bash
nano app/controllers/steps_controller.rb
```

Then add the following code to the file:

```ruby
# app/controllers/steps_controller.rb
class StepsController < ApplicationController
  before_action :set_recipe, :set_step

  def destroy
    @step.destroy!
    render turbo_stream: turbo_stream.remove(@step)
  end

  private

  def set_recipe
    @recipe = Recipe.find(params[:recipe_id])
  end

  def set_step
    @step = @recipe.steps.find(params[:id])
  end
end
```

This is what's happening in the controllers code above (summarizing both steps and ingredients controllers):

- Both controllers share the same shape: find the parent recipe, find the child through that recipe, destroy it, then answer with a Turbo Stream remove.
- `before_action :set_recipe` loads `@recipe` from `params[:recipe_id]` (the nested route). `set_ingredient` / `set_step` then find the child with `@recipe.ingredients.find` or `@recipe.steps.find`, so you cannot delete another recipe's row by id alone.
- `destroy!` raises if the delete fails. On success, `render turbo_stream: turbo_stream.remove(...)` sends a remove stream targeting `dom_id` for that ingredient or step.
- No `format.html` here. Remove only runs from the recipe show page through Turbo, so a Stream response is enough.

Finally, wire Destroy buttons in the show page and call these new destroy URLs you added above in routes and controllers.

Replace the code for "Ingredients" and "Steps" lists with the following while keeping everything else unchanged at `app/views/recipes/show.html.erb` file:

```erb
<%% if @recipe.ingredients.any? %>
  <h2>Ingredients</h2>
  <ul id="ingredients">
    <%% @recipe.ingredients.each do |ingredient| %>
      <li id="<%%= dom_id(ingredient) %>">
        <%%= ingredient.name %><%% if ingredient.quantity.present? %> (<%%= number_with_precision(ingredient.quantity, precision: 2, strip_insignificant_zeros: true) %><%% if ingredient.unit.present? %> <%%= ingredient.unit %><%% end %>)<%% end %>
        <%%= button_to "Remove",
              recipe_ingredient_path(@recipe, ingredient),
              method: :delete,
              data: { turbo_confirm: "Remove this ingredient?" } %>
      </li>
    <%% end %>
  </ul>
<%% end %>

<%% if @recipe.steps.any? %>
  <h2>Steps</h2>
  <ol id="steps">
    <%% @recipe.steps.order(:position).each do |step| %>
      <li id="<%%= dom_id(step) %>">
        <%%= step.instruction %>
        <%%= button_to "Remove",
              recipe_step_path(@recipe, step),
              method: :delete,
              data: { turbo_confirm: "Remove this step?" } %>
      </li>
    <%% end %>
  </ol>
<%% end %>
```

This is what's happening in the code above:

- Each ingredient and step row gets `id` from `dom_id(...)`, matching what `turbo_stream.remove` targets in the controllers.
- **Remove** posts to the nested destroy routes (`recipe_ingredient_path` / `recipe_step_path`) with `turbo_confirm`, same confirm pattern as recipe Destroy.

Re-run the system test:

```bash
bin/rails test test/system/recipes_test.rb -i test_removes_ingredients_and_steps_from_the_detail_page
```

You want 0 failures and 0 errors. That is **green**.

<%= render Guide::LoadDevelopmentFixtures.new(
  after_load: <<~MD
    Load fixtures so **Fluffy pancakes** still has **Salt**, **Flour**, and **Preheat** before you try Remove.
  MD
) %>

Next, quickly surf in the browser and confirm the functionality:

1. Open **Fluffy pancakes**.
2. Click **Remove** next to **Salt**, confirm, and check Salt disappears without a full navigation.
3. Click **Remove** next to **Preheat the pan.**, confirm, and check that step disappears too.
4. Refresh: both stay gone; **Flour** remains.

### Prove nested removal over HTTP (Integration test)

The system smoke already proved Remove in the browser. This integration test skips Capybara and hits the new nested destroy path over HTTP: `delete` Salt and the preheat step, then check counts decreased by 1 and check that the show page has no Salt and no preheat. You will not use TDD for this slice since the routes and controllers just landed in Green section above.

#### Add scenarios to the test file

Add the following scenario to the integration test file at `test/integration/recipes_integration_test.rb`:

```ruby
# test/integration/recipes_integration_test.rb
  # Actor: guest (until Chapter 12)
  # Starting point: pancakes has salt, flour, and preheat
  # Action: delete salt and preheat via nested URLs
  # Expected outcome: Ingredient.count and Step.count each -1; show has no Salt and no preheat; Flour remains
  # test "removes ingredients and steps from the detail page" do
  # end
```

#### Add the integration test

Replace the body of the test `removes ingredients and steps from the detail page` with the following code:

```ruby
# test/integration/recipes_integration_test.rb
  test "removes ingredients and steps from the detail page" do
    recipe = recipes(:pancakes)
    salt = ingredients(:salt)
    preheat = steps(:preheat)

    assert_difference ["Ingredient.count", "Step.count"], -1 do
      delete recipe_ingredient_url(recipe, salt), as: :turbo_stream
      delete recipe_step_url(recipe, preheat), as: :turbo_stream
    end

    get recipe_url(recipe)
    assert_response :success
    assert_no_match salt.name, response.body
    assert_no_match preheat.instruction, response.body
    assert_match "Flour (2 cups)", response.body
  end
```

<%= render Shared::Tip.new(
  title: "New assertion",
  markdown: <<~MD
    **`delete url, as: :turbo_stream`** asks Rails to treat the request like a Turbo Stream client (format / Accept).
    Syntax: pass `as: :turbo_stream` as a keyword on `delete` (same idea as `as: :json` later in [Chapter 16](/guide/testing-api/)). Without it, a destroy that only renders a stream can raise `UnknownFormat` for a plain HTML delete.
  MD
) %>

This is what's happening in the code above:

- Two `delete` calls hit the nested destroy actions as Turbo Stream requests, matching how the show page Remove buttons talk to the server.
- `assert_difference ["Ingredient.count", "Step.count"], -1` expects one fewer of each record after performing the deletes.
- Integration tests don't have ability to test Stream DOM updates, so you fetch the show page and check that Salt and the preheat instruction are gone and Flour (2 cups) remains.

Run the test, you want 0 failures and 0 errors:

```bash
bin/rails test test/integration/recipes_integration_test.rb -i test_removes_ingredients_and_steps_from_the_detail_page
```

## Filter recipes with a Turbo Frame

[Chapter 8](/guide/testing-models/#red-write-the-integration-test-first) already proved `?quick=1` filters the list over HTTP. In this section you add a system smoke for the same title outcome, then wrap the list in a Turbo Frame so the filter response swaps only that region. Turbo Drive (default since Rails 7) already avoids a classic full browser reload; the Turbo Frame's job is scoping which part of the response lands on the page.

### What counts as working?

| Flow | You might say |
| --- | --- |
| Filter recipes with a Turbo Frame | Submit quick filter; list shows pancakes, not lentil soup |

### Add scenarios to the test file

Add the following scenario to the system test file at `test/system/recipes_test.rb`:

```ruby
# test/system/recipes_test.rb
  # Actor: guest (until Chapter 12)
  # Starting point: recipes index with pancakes and lentil soup
  # Action: select Quick, click Apply filter
  # Expected outcome: pancakes visible; lentil soup gone from the list
  # test "filters the list" do
  # end
```

### Add the system test

Replace the body of the test `filters the list` with the following code:

```ruby
# test/system/recipes_test.rb
  test "filters the list" do
    visit recipes_url
    assert_text recipes(:pancakes).title
    assert_text recipes(:lentil_soup).title

    select "Quick recipes (under 30 min)", from: "Show"
    click_on "Apply filter"

    assert_text recipes(:pancakes).title
    assert_no_text recipes(:lentil_soup).title
  end
```

<%= render Shared::Tip.new(
  title: "New Capybara helper",
  markdown: <<~MD
    **`select "Quick recipes (under 30 min)", from: "Show"`** picks an option in a dropdown, like a person using the filter.
    Syntax: `select` plus the option text, then `from:` with the field label (or `id` / `name`). This is a Capybara step, not a Minitest assertion. If the label is wrong, Capybara cannot find the select and the test errors before Apply filter runs.
  MD
) %>

This is what's happening in the code above:

- Both fixture titles are on the list before you filter.
- After the dropdown change, **Apply filter** submits and the asserts check titles only (pancakes stays, lentil soup gone), not a `turbo-frame` tag.
- Green before any Frame exists is expected. Chapter 8's Drive-backed filter already shows the right titles.

Run:

```bash
bin/rails test test/system/recipes_test.rb -i test_filters_the_list
```

You will likely get 0 failures **before** any Turbo Frame exists for the filter. That is expected, not a bug in the test.

Chapter 8's filter form already does a full page GET backed by Turbo Drive, it then shows the correct list of recipes. The system test you just added above asserts that outcome (`assert_text` / `assert_no_text` on recipe titles), instead of an implementation detail: a `turbo-frame` tag. So the test cannot go red just because the Turbo Frame for the filter is missing. The Turbo Frame is a UX upgrade you add on purpose: narrower swap than a Turbo Drive visit that replaces the page body.

### Wrap the list in a Turbo Frame

Replace the whole content of the `app/views/recipes/index.html.erb` file with the following code so the filter targets a frame and the recipe list renders inside that frame:

```erb
<%%# app/views/recipes/index.html.erb %>
<p style="color: green"><%%= notice %></p>

<%% content_for :title, "Recipes" %>

<h1>Recipes</h1>

<%%= form_with url: recipes_path, method: :get, class: "recipe-filters",
      data: { turbo_frame: "recipes" } do %>
  <%%= label_tag :quick, "Show" %>
  <%%= select_tag :quick,
        options_for_select(
          [["All recipes", ""], ["Quick recipes (under 30 min)", "1"]],
          params[:quick]
        ),
        id: "quick" %>
  <%%= submit_tag "Apply filter" %>
<%% end %>

<%%= turbo_frame_tag "recipes" do %>
  <%% @recipes.each do |recipe| %>
    <%%= render recipe %>
  <%% end %>
<%% end %>

<%%= link_to "New recipe", new_recipe_path %>
```

This is what's happening in the code above:

- `data: { turbo_frame: "recipes" }` tells Turbo to put the form response into the frame named `recipes` instead of Turbo Drive replacing the whole page body. Only the matching `recipes` frame swaps.
- `turbo_frame_tag "recipes"` wraps the list of recipes. The filtered collection re-renders inside that frame. The `<h1>` and **New recipe** link sit outside the frame, so Turbo leaves those live DOM nodes alone and only swaps the framed list.
- Show already uses `data: { turbo_frame: "_top" }` on the partial, so that link does not target the `recipes` frame.

Re-run the filter system test to ensure the Turbo Frame did not break the test outcome. You want 0 failures and 0 errors.

```bash
bin/rails test test/system/recipes_test.rb -i test_filters_the_list
```

You *could* drive this same filter from `RecipesController#index` with a Turbo Stream instead of a Frame. Sketch only (do not add this to your app):

```ruby
# app/controllers/recipes_controller.rb (index)
respond_to do |format|
  format.html
  format.turbo_stream
end
```

```erb
<%%# app/views/recipes/index.turbo_stream.erb %>
<%%= turbo_stream.update "recipes" do %>
  <%% @recipes.each do |recipe| %>
    <%%= render recipe %>
  <%% end %>
<%% end %>
```

That works, but you need format handling plus a stream template, and the form must request a Stream. For "refresh one region after a GET," a Turbo Frame just works: point the form at the frame, wrap the list, and just leave the controller alone.

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: <<~MD
    A Turbo Frame can change timing in system tests. If a click feels flaky, wait on the outcome text. Capybara retries `assert_text` / `assert_no_text` until the copy appears or disappears (or the wait times out). A fixed `sleep` is slower and still races.

    ```ruby
    # Avoid: hope the frame finished in 0.5s
    click_on "Apply filter"
    sleep 0.5
    assert_no_text recipes(:lentil_soup).title

    # Prefer: wait until the title is gone
    click_on "Apply filter"
    assert_no_text recipes(:lentil_soup).title
    ```

    Never replace those title asserts with `find("turbo-frame#recipes")` as the main proof. Outcomes beat HTML markup.
  MD
) %>

<%= render Guide::LoadDevelopmentFixtures.new(
  after_load: <<~MD
    Load fixtures so **Fluffy pancakes** and **Lentil soup** are on the list before you try the quick filter.
  MD
) %>

Next, quickly surf in the browser:

1. Open the recipes index.
2. Filter to **Quick recipes (under 30 min)** and click **Apply filter**.
3. Confirm pancakes stays and lentil soup leaves the list.

<%= render Guide::SupportCta.new(
  variant: :mid_chapter,
  site_metadata: site.data.site_metadata,
  milestone_hook: "Stream removes and a framed quick filter now prove outcomes, not Turbo markup.",
  headline: "Mid-page deletes and a scoped filter with tests behind them is a real milestone.",
  body_text: "You asserted cards and rows gone, and a filtered list, without chasing turbo-stream tags. If that habit is sticking, fund the next chapter and help keep the guide free."
) %>

## When Stimulus or Turbo earns its keep

You have now used Stimulus (Chapter 10), Turbo Streams (destroy and nested remove), and a Turbo Frame (quick filter). All three make the page feel dynamic but that does not mean Turbo should handle every dynamic bit.

Turbo is a strong tool for "the server answered, now update part of the page." If you reach for it every time something moves in the browser, you are using a hammer for every job. Pick the tool that matches the job.

| Tool | Job it fits | Cookbook example |
| --- | --- | --- |
| Stimulus | Browser-only behavior before (or without) a new server round trip. Clone fields, hide a row, toggle UI. | Add/remove nested rows on edit, then submit the whole form once ([Chapter 10](/guide/testing-dynamic-forms/)) |
| Turbo Frame | One request should refresh one region of the page (nav and other parts of the page stay put). | Quick filter swaps the recipe list inside the frame |
| Turbo Stream | One request should change specific DOM pieces (remove a row, prepend a flash, replace a card). | Destroy a recipe card on index; remove an ingredient or step on show |

Turbo Frame and Stream are normally used together and mostly you will not need to choose between them.

### A quick gut check

Ask yourself:

1. Does the change need the server right now? No → start with Stimulus (or plain HTML). Yes → Turbo Frame or Stream.
2. Are you still filling one form that will submit later? Prefer Stimulus for row add/remove.
3. Should one region of the page refresh after navigate or submit? Prefer a Turbo Frame.
4. Should a precise piece of DOM change after create, update, or destroy? Prefer a Turbo Stream.

You will still mix tools in a real app. Stimulus can live inside a Frame. A Stream can land after a Frame request. The mistake is treating "dynamic" as "must be Turbo" every time.

## Commit your work

Run the full suite:

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

You want 0 failures and 0 errors across model, integration, and system tests. Then:

```bash
git add .
git commit -m "Add Turbo Stream removes and framed quick filter"
```

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

## What is next

You now have Stimulus for nested form rows and Turbo for mid-page deletes and framed filtering. [Chapter 12](/guide/testing-authentication/) ends guest write access: sign-in, guest read-only list and show, and session helpers on the same integration habit. Come back to create, update, and destroy (including these Stream removes) once only signed-in users can change recipes.

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