Testing Dynamic Forms
In Chapter 9 nested fields stayed static: there was one ingredient and one step row on new and edit recipe form but there was no way to add more or remove existing nested fields. That was done intentionally to keep the chapter focused.
This chapter makes the nested fields inside the recipe form dynamic: Add ingredient, Add step, and Remove nested field rows by sprinkling some StimulusJS magic so the form feels like a real cookbook. The buttons are new UI you write by hand, so you use the same TDD habit as Chapter 9: scenario and test first (red), then the smallest form change with StimulusJS controller (green). You will also add an integration test for removing nested fields from a recipe but without using TDD because you already wired _destroy in the controller and allow_destroy in recipe model in Chapter 9 to destroy nested fields from the recipe.
What you will do in this chapter #
- Extend the existing
updates a recipesystem test for Add on edit (red), wire partials and Stimulus for Add functionality (green), then surf Add in the browser. - Grow that same system smoke for Remove (red), wire Remove on the partials and Stimulus (green), surf Remove in the browser, then add an integration test for nested
_destroyon edit (no TDD; server path already wired in Chapter 9). - Run the full suite and commit after a green run.
Scenarios to automate #
These are the outcomes you will prove by the end of the chapter. The browser smoke extends the existing edit test for the recipe; the integration test locks nested destroy over HTTP (already wired in Chapter 9).
| Scenario | Expected | Test Type |
|---|---|---|
| Add ingredient on edit | On pancakes edit, click Add ingredient; a blank row appears; fill Onion and save | System |
| Add step on edit | On pancakes edit, click Add step; a blank row appears; fill an instruction and save | System |
| Remove a new row on edit | Add a blank row, then Remove it; it disappears from the form before save | System |
| Remove a saved row on edit | Remove Salt on pancakes edit, save; Salt gone from show; Flour remains | System |
| Remove saved ingredient and step over HTTP | Patch pancakes with Salt and the preheat step with the param _destroy: "1"; both counts drop by 1; show has no Salt and no preheat; Flour remains |
Integration |
Add nested fields when editing a recipe #
You already added the ability to create, update, and destroy nested fields (ingredients and steps) in Chapter 9 by wiring accepts_nested_attributes_for in the recipe model and permitting nested attributes (ingredients_attributes and steps_attributes) in the recipe controller. But the form still shows one static row per association. Time to make the form dynamic: in this chapter, you add Add ingredient, Add step, and Remove buttons so a user can grow or trim the list of nested records on edit before updating the recipe.
Rails does not generate Add and Remove buttons for nested fields, you will need to write them by hand. To make the nested fields dynamic, you wire up a Stimulus controller to clone a <template> row when user clicks Add button and toggle _destroy attribute when user clicks Remove button. Don’t worry if you don’t understand <template> and Stimulus parts yet, all of these will come shortly in this chapter.
You could also build the same UX for dynamic nested fields with Turbo Streams. I have seen a lot of people try that first because it feels more “Rails-y”. But for a blank nested row that doesn’t rely on any data from the server, Turbo Streams are usually overkill. In practice you end up with:
- A round trip to the server on every Add (and often every Remove) even though nothing hits the database yet, only empty fields.
- Extra routes and controller actions just to render HTML markup for nested fields (for ingredients and again for steps).
- Turbo Stream templates (or
render turbo_stream:) whose only job is to append or remove HTML markup. -
A different mental model than usual save all records at once approach
You end up saving the recipe first, then adding or removing ingredients one request at a time. That works well on a show page, but it is a worse fit when the recipe and its rows must save together in one submit.
Overall it’s not really worth it to add Turbo Streams for this functionality. For one form that submits everything together, Stimulus is the usual Rails 8 approach. You will learn about testing Turbo Streams/Frames and look at when to use StimulusJS vs Turbo features in Chapter 11.
Why extend the test for edit instead of create? #
You already have a system test for test "updates a recipe" from Chapter 7, you will grow the same test instead of extending the test for create. You might be wondering “why extend edit and not create?”
Read on.
For system smoke tests, my usual approach of choosing where to put tests is to look for the happy path that gives the most coverage in one visit. Edit wins here: pancakes already has fixture ingredients and steps, so one flow can add a new nested row, remove a new unsaved row, remove a saved row, and submit. Create only starts with blank rows, so you never prove Remove on an existing child in that same session. That is why you extend the edit test instead of creating a new one for create.
What counts as working? #
| Scenario | You might say |
|---|---|
| Add ingredient on edit | On pancakes edit, click Add ingredient; fill Onion on the new row; save; Onion is displayed on show page |
| Add step on edit | On pancakes edit, click Add step; fill an instruction on the new row; save; that instruction is displayed on show page |
Add scenarios to the test file #
Update the scenario comment on the system test for updates a recipe (from Chapter 7) at test/system/recipes_test.rb with the following:
# test/system/recipes_test.rb
# Actor: guest
# Starting point: pancakes edit form in browser
# Action: change title, add an ingredient, add a step, submit
# Expected outcome: new title; Onion and the new step on show page; Salt and Flour still there
# test "updates a recipe" do
# end
Red: extend the system test #
Replace the body of test "updates a recipe" with the following:
# 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 "Add ingredient"
all("input[name*='[ingredients_attributes]'][name*='[name]']").last.set("Onion")
click_on "Add step"
all("textarea[name*='[steps_attributes]'][name*='[instruction]']").last.set("Flip when bubbles form")
click_on "Update Recipe"
assert_text "Extra fluffy pancakes"
assert_text "Salt"
assert_text "Flour"
assert_text "Onion"
assert_text "Flip when bubbles form"
end
This is what’s happening in the code above:
click_on "Add ingredient"andclick_on "Add step"clicks on the buttons to add a new ingredient and step row..last.set(...)fills the blank row each Add button just created.- The assertions check the show page after Update Recipe. It ensures new ingredient and step are displayed, and existing ingredients and steps are still there.
Run the test:
bin/rails test test/system/recipes_test.rb
You should see an error:
E
Error:
RecipesTest#test_updates_a_recipe:
Capybara::ElementNotFound: Unable to find link or button "Add ingredient"
test/system/recipes_test.rb:37:in 'block in <class:RecipesTest>'
Capybara is not able to find Add ingredient since you haven’t wired up the buttons to add a new ingredient and step row in the recipe form yet. The error is expected. That is your red step.
Green: partials and Stimulus #
The test is throwing an error right now at “Add ingredient” but once you wire that up it will also throw an error at “Add step” and missing form fields for new rows of ingredients and steps. We won’t TDD all of that here. Instead we will wire up the buttons and the form fields all at once in three small pieces:
- Rails view partials that holds the fields for one ingredient row and one step row (name/quantity/unit, or position/instruction).
- Stimulus controller that runs on Add clicks and clones a blank row into the form.
- Form wiring that connects the Add buttons and a hidden
<template>blank row on_form.html.erbto the Stimulus controller.
Partials for nested rows #
Create app/views/recipes/form/_ingredient_fields.html.erb with the following. Putting the new file under form/ makes it clear the partials belong to the recipe form, and not any other pages.
<%# app/views/recipes/form/_ingredient_fields.html.erb %>
<div>
<%= form.hidden_field :id %>
<div>
<%= form.label :name %>
<%= form.text_field :name %>
</div>
<div>
<%= form.label :quantity %>
<%= form.number_field :quantity, step: 0.01 %>
</div>
<div>
<%= form.label :unit %>
<%= form.text_field :unit %>
</div>
</div>
Also create a new partial for steps at app/views/recipes/form/_step_fields.html.erb with the following:
<%# app/views/recipes/form/_step_fields.html.erb %>
<div>
<%= form.hidden_field :id %>
<div>
<%= form.label :position %>
<%= form.number_field :position %>
</div>
<div>
<%= form.label :instruction %>
<%= form.text_area :instruction %>
</div>
</div>
This is what’s happening in the code above:
- Each partial holds the fields for one nested row: ingredient fields (name, quantity, unit) or step fields (position, instruction).
- The hidden
idis required to tell Rails which record to update when the form is submitted. This is the Rails way of updating existing records.
Stimulus: Add a row dynamically #
Create a new Stimulus controller at app/javascript/controllers/nested_form_controller.js with the following:
// app/javascript/controllers/nested_form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container", "template"]
add(event) {
event.preventDefault()
const content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, Date.now())
this.containerTarget.insertAdjacentHTML("beforeend", content)
}
}
This is what’s happening in the code above:
addreads the HTML inside the<template>target, replaces everyNEW_RECORDstring with a unique number (Date.now()), then inserts that HTML into the page.insertAdjacentHTML("beforeend", content)is a browser API. It pastes thecontentstring as real DOM nodes just before the closing tag of the container (so after any rows already there)."beforeend"means “append inside this element,” not “replace the whole list.”- You have not put
NEW_RECORDin the view file yet. That placeholder shows up in the next step when you wire_form.html.erbwithchild_index: "NEW_RECORD"inside a<template>.
Connect the form to Stimulus #
Open app/views/recipes/_form.html.erb and replace the content in the file with the following to include the code for dynamic nested fields:
<%# app/views/recipes/_form.html.erb (nested sections) %>
<%= form_with(model: recipe) do |form| %>
<% if recipe.errors.any? %>
<div style="color: red">
<h2><%= pluralize(recipe.errors.count, "error") %> prohibited this recipe from being saved:</h2>
<ul>
<% recipe.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :title, style: "display: block" %>
<%= form.text_field :title %>
</div>
<div>
<%= form.label :description, style: "display: block" %>
<%= form.textarea :description %>
</div>
<div>
<%= form.label :prep_time, style: "display: block" %>
<%= form.number_field :prep_time %>
</div>
<div>
<%= form.label :servings, style: "display: block" %>
<%= form.number_field :servings %>
</div>
<div data-controller="nested-form">
<h2>Ingredients</h2>
<div data-nested-form-target="container">
<%= form.fields_for :ingredients do |ingredient_form| %>
<%= render "recipes/form/ingredient_fields", form: ingredient_form %>
<% end %>
</div>
<template data-nested-form-target="template">
<%= form.fields_for :ingredients, Ingredient.new, child_index: "NEW_RECORD" do |ingredient_form| %>
<%= render "recipes/form/ingredient_fields", form: ingredient_form %>
<% end %>
</template>
<button type="button" data-action="nested-form#add">Add ingredient</button>
</div>
<div data-controller="nested-form">
<h2>Steps</h2>
<div data-nested-form-target="container">
<%= form.fields_for :steps do |step_form| %>
<%= render "recipes/form/step_fields", form: step_form %>
<% end %>
</div>
<template data-nested-form-target="template">
<%= form.fields_for :steps, Step.new, child_index: "NEW_RECORD" do |step_form| %>
<%= render "recipes/form/step_fields", form: step_form %>
<% end %>
</template>
<button type="button" data-action="nested-form#add">Add step</button>
</div>
<div>
<%= form.submit %>
</div>
<% end %>
This is what’s happening in the (new) code above:
data-controller="nested-form"tells Stimulus to attachnested_form_controller.jsto that<div>and everything inside it. The valuenested-formmaps to the file name: dashes become underscores, and Stimulus looks fornested_form_controller.js. Stimulus runs only inside the<div>container where thedata-controller="nested-form"is declared.- The
<div data-nested-form-target="container">holds the rows the user can see (existing fixture rows on edit, plus anything Add clones in). <template>holds a blank row for cloning. Browsers treat<template>content as inert: it is not shown on the page and its fields are not submitted with the form until JavaScript copies that HTML into the live DOM. That is why the blank row stays off-screen untiladdclones it.child_index: "NEW_RECORD"prints theNEW_RECORDplaceholder into nested field names (for examplerecipe[ingredients_attributes][NEW_RECORD][name]). Stimulus then swapsNEW_RECORDforDate.now()on each clone.data-action="nested-form#add"on the Add button means “on click, call theaddmethod on thenested-formcontroller for this block.”- Existing rows (Salt, Flour, the preheat step) and new rows share the same partials under
app/views/recipes/form/(_ingredient_fieldsand_step_fields). - Ingredients and steps each get their own
data-controller="nested-form"block on the form, but both use the samenested_form_controller.jsfile.
Run the Chapter 9 integration file to confirm nested create still passes after the refactor, you want 0 failures and 0 errors:
bin/rails test test/integration/recipes_integration_test.rb
Then re-run the recipes system test:
bin/rails test test/system/recipes_test.rb
You want 0 failures and 0 errors. That is green for Add ingredients and steps.
Try Add functionality in the browser #
Before you add Remove functionality, click through the recipe form by hand so you see the Add functionality for nested fields working with Stimulus and form wiring away from the actual test suite.
- Start
bin/devif the server is not running. - Open the recipes list (http://localhost:3000/recipes), click Fluffy pancakes, then Edit this recipe.
- Click Add ingredient. A blank name / quantity / unit row should appear under Salt and Flour.
- Fill that row with Onion (quantity and unit optional), then click Add step, fill an instruction “Flip when bubbles form”, and click Update Recipe.
- On the show page, confirm Onion and the new step appear with Salt, Flour, and Preheat the pan.
Remove nested rows when editing a recipe #
With that we have wired up the ability to add nested fields to the recipe form successfully. Next you will wire up the ability to remove nested fields.
For remove, you need to handle two different edge cases when clicking the Remove button:
- A new row (unsaved record) is dropped from the DOM and never reaches the server.
- A saved row (Salt on pancakes) stays in the DOM (but hidden), gets
_destroyattribute toggled to1, and is deleted when you submit Update Recipe.
You already wired allow_destroy: true and permitted _destroy in Chapter 9 which means server side code is already in place, now you need to wire up the HTML side of the Remove functionality.
In this section, you will grow the same system smoke test for updates a recipe (red), wire Remove functionality in both the view and the Stimulus controller (green), then lock the saved-row path over HTTP with an integration test (no TDD for that HTTP slice).
What counts as working? #
| Scenario | You might say | Test type |
|---|---|---|
| Remove a new row on edit | Add a blank ingredient row, click Remove button; the row is gone before save | System test |
| Remove a saved row on edit | Remove Salt, save; Salt gone from show; Flour and Onion remain | System test |
| Remove saved ingredient and step over HTTP | Patch pancakes by sending _destroy attribute for Salt (ingredient) and preheat (step); both gone from show page; Flour remains |
Integration test |
Red: extend the system test #
Update the scenario comment with the following to include the new action of removing a new row and Salt before submit.
# test/system/recipes_test.rb
# Actor: guest
# Starting point: pancakes edit form in browser
# Action: change title, add ingredient and step, remove a new blank ingredient, remove Salt, submit
# Expected outcome: new title; Onion and new step on show; Salt gone; Flour still there
# test "updates a recipe" do
# end
Then replace the body of test "updates a recipe" with the following:
# 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 "Add ingredient"
all("input[name*='[ingredients_attributes]'][name*='[name]']").last.set("Onion")
click_on "Add step"
all("textarea[name*='[steps_attributes]'][name*='[instruction]']").last.set("Flip when bubbles form")
click_on "Add ingredient"
all("button", text: "Remove ingredient").last.click
within find("input[name*='[ingredients_attributes]'][name*='[name]'][value='Salt']").ancestor("[data-nested-form-target='row']") do
click_on "Remove ingredient"
end
click_on "Update Recipe"
assert_text "Extra fluffy pancakes"
assert_no_text "Salt"
assert_text "Flour"
assert_text "Onion"
assert_text "Flip when bubbles form"
end
This is what’s happening in the code above:
- After Onion and the new step are filled, a second Add ingredient creates a blank new row.
all("button", text: "Remove ingredient").last.clickremoves that unsaved row from the DOM (edge case:Remove a new row on edit).- The “Remove ingredient” for Salt finds the name input whose value is Salt, traverses to that nested row with
ancestor, then clicks Remove ingredient insidewithin(edge case:Remove a saved row on edit). - Show assertions expect Salt gone, Flour and Onion still there.
Run:
bin/rails test test/system/recipes_test.rb
You should see an error when Capybara cannot find Remove ingredient. That is your red step for this section. Add works but Remove button is not on the form yet so it cannot be clicked and fails the test.
E
Error:
RecipesTest#test_updates_a_recipe:
NoMethodError: undefined method 'click' for nil
test/system/recipes_test.rb:48:in 'block in <class:RecipesTest>'
Green: partials update and Stimulus wiring #
To fix failing tests, we will use the same approach as Add: update HTML to include Remove buttons first, then add the Stimulus action for actually removing the row from the form:
- Rails view partials to include a Remove button, a
rowtarget,data-new-record, and a hidden_destroyfield. - Stimulus controller to include a
removeaction that drops a new row or marks a saved row for destroy on the server.
Partials: Add Remove buttons to nested rows #
Replace app/views/recipes/form/_ingredient_fields.html.erb with the following:
<%# app/views/recipes/form/_ingredient_fields.html.erb %>
<div data-nested-form-target="row" data-new-record="<%= form.object.new_record? %>">
<%= form.hidden_field :id %>
<%= form.hidden_field :_destroy, value: "0" %>
<div>
<%= form.label :name %>
<%= form.text_field :name %>
</div>
<div>
<%= form.label :quantity %>
<%= form.number_field :quantity, step: 0.01 %>
</div>
<div>
<%= form.label :unit %>
<%= form.text_field :unit %>
</div>
<button type="button" data-action="nested-form#remove">Remove ingredient</button>
</div>
Replace app/views/recipes/form/_step_fields.html.erb with:
<%# app/views/recipes/form/_step_fields.html.erb %>
<div data-nested-form-target="row" data-new-record="<%= form.object.new_record? %>">
<%= form.hidden_field :id %>
<%= form.hidden_field :_destroy, value: "0" %>
<div>
<%= form.label :position %>
<%= form.number_field :position %>
</div>
<div>
<%= form.label :instruction %>
<%= form.text_area :instruction %>
</div>
<button type="button" data-action="nested-form#remove">Remove step</button>
</div>
This is what’s happening in the code above (description of new code only):
data-nested-form-target="row"labels the wrapper div as a Stimulusrowtarget. In the next step, we will addremoveaction tonested_form_controller.jsthat will call.closest(...)from the button up to this div so it knows which row to drop or hide. Without this attribute, Stimulus has no clear way to find the right ingredient or step to remove.data-new-record="<%= form.object.new_record? %>"is a flag on the same div wrapper that tells Stimulus whether the row is new or already saved. Rails setsnew_record?totruefor a blankIngredient.new/Step.new(the row Add just cloned) andfalsefor Salt, Flour, or the preheat step already in the database. Stimulus will read this in the next step: new rows get deleted from the page only while saved rows get marked for destroy on submit.form.hidden_field :_destroy, value: "0"is used by nested attributes to mark a row for deletion;0means keep the row. When you Remove a saved row, Stimulus will flip this to1which tells the server to delete the row. Chapter 9 already permitted_destroyand setallow_destroy: trueso the server knows what to do.- The Remove button uses
type="button"so a click does not submit the recipe form.data-action="nested-form#remove"means, call theremovemethod on thenested-formcontroller to actually remove the row from the form dynamically.
Stimulus: Remove a row #
Replace app/javascript/controllers/nested_form_controller.js with the following so clicking on the Remove button will remove the row from the form:
// app/javascript/controllers/nested_form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container", "template"]
add(event) {
event.preventDefault()
const content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, Date.now())
this.containerTarget.insertAdjacentHTML("beforeend", content)
}
remove(event) {
event.preventDefault()
const row = event.target.closest("[data-nested-form-target='row']")
if (row.dataset.newRecord === "true") {
row.remove()
} else {
row.querySelector("input[name*='[_destroy]']").value = "1"
row.hidden = true
}
}
}
This is what’s happening in the code above:
removeaction is responsible for removing the row from the form. It starts from the clicked button (event.target) and uses.closest("[data-nested-form-target='row']")to find that button’s row wrapper.- Do not reach for
this.rowTargethere. Stimulusthis.rowTargetis always the firstrowtarget inside the controller. With Salt, Flour, and a new Onion row, Remove on Flour would still grab Salt..closestwalks up from the click, so you get the row that owns that button. (this.rowTargetsis every row; you would still need to pick one.) - If that row was never saved (
data-new-record="true"), it deletes the the node (<div>wrapper) from the DOM and nothing is sent to the server for that blank row. - If the row was already in the database (Salt), it sets the hidden
_destroyfield to1and hides the row so the record is deleted from the database when submitting the form.
Re-run the system test, you want 0 failures and 0 errors:
bin/rails test test/system/recipes_test.rb
Try Remove functionality in the browser #
Before you add integration test for removing a saved row, click through Remove functionality in the browser by hand so you see both edge cases handled by Stimulus: drop a new row, and mark a saved row for destroy.
- Start
bin/devif the server is not already running. - Visit recipe list (http://localhost:3000/recipes), click Fluffy pancakes, then Edit this recipe.
- Click Add ingredient, then click Remove ingredient on that new blank row. The row should disappear from the form before you save.
- Click Remove ingredient for the Salt row. The Salt row should hide (Stimulus sets
_destroyand hides it). - Optionally click Remove step for the step Preheat the pan row.
- Click Update Recipe. On the show page, confirm Salt (and the preheat step, if you removed it) are gone, and Flour remains.
Dynamic nested Add and Remove functionality works in the browser now.
A form that grows and shrinks with tests behind it is a real milestone.
You proved Stimulus Add/Remove with a system smoke without chasing Turbo markup. If that habit is sticking, fund the next chapter and help keep the guide free.
One-time support via Stripe. No account required.
Integration test: Remove a saved row over HTTP #
The system smoke already proved Remove in the browser. This integration test skips Capybara and hits the same nested destroy path over HTTP: patch pancakes with _destroy for Salt and the preheat step, then check counts and show HTML. You will not use TDD for this slice since Chapter 9 already wired allow_destroy: true and permitted _destroy required for this test to pass.
Add scenarios to the test file #
Add the following scenario comment at the end of test/integration/recipes_integration_test.rb:
# test/integration/recipes_integration_test.rb
# Actor: guest
# Starting point: pancakes has salt, flour, and preheat in fixtures
# Action: patch with salt's id and preheat's id, both with _destroy
# Expected outcome: redirect to show; Salt and preheat gone; Flour still on page
# test "removes existing ingredients and steps when updating a recipe" do
# end
Add the integration test #
Replace the body of the test "removes existing ingredients and steps when updating a recipe" with the following:
# test/integration/recipes_integration_test.rb
test "removes existing ingredients and steps when updating a recipe" do
recipe = recipes(:pancakes)
salt = ingredients(:salt)
preheat = steps(:preheat)
assert_difference ["Ingredient.count", "Step.count"], -1 do
patch recipe_url(recipe), params: {
recipe: {
title: recipe.title,
ingredients_attributes: {
"0" => { id: salt.id, _destroy: "1" }
},
steps_attributes: {
"0" => { id: preheat.id, _destroy: "1" }
}
}
}
end
assert_redirected_to recipe_url(recipe)
follow_redirect!
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
This is what’s happening in the code above:
patchsends_destroy: "1"for Salt and for the preheat step in the same update to mark them for deletion.assert_difference ["Ingredient.count", "Step.count"], -1expects one fewer ingredient and one fewer step when the update for the recipe succeeds.- After redirect, Salt and the preheat instruction are absent from the show HTML and Flour (2 cups) remains.
Run:
bin/rails test test/integration/recipes_integration_test.rb
You want 0 failures and 0 errors.
Rows you do not include in the patch are left alone. That is why Flour can stay when you only send Salt (and the preheat step) with _destroy.
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 dynamic nested fields with browser add/remove and nested destroy"
Small commits make it easier to roll back when Turbo or auth land in the next chapters.
What is next #
Chapter 11 covers Turbo Streams and Frames: remove ingredients and steps from the recipe show page without a full reload, and use Turbo Frame for a quick filter in recipe index page.
Continue to Testing Turbo Frames and Streams.
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.