How to approach testing
By the time you reach this chapter, you have already run Minitest in your own Recipes app and you have names for the folders under test/. That is a real win. Chapter 2 gave you the map. Chapter 3 gave you the toolbox words: assertions, fixtures, Capybara, and when something heavier like WebMock might show up later.
This chapter is different on purpose. You will not type new automated tests here. You will not change the small app you generated in Chapter 1. Instead, you will clone a separate reference Recipes app and practice the habit I wish someone had handed me earlier: describe what should happen in plain language, prove it by hand, and only then think about Ruby.
I still remember opening test/models/ with a blank file and a blank mind at the same time. My brain wanted to jump straight to assertions and green dots in the terminal. The tests I wrote that way felt random. What helped was slowing down and writing the same steps I would use if I were clicking through the app myself. This chapter is that slowdown, written out.
What you will know by the end #
When you finish reading and doing the drills, you should be able to:
- Write a scenario in four plain language pieces: who acts, what was true before, what they do, and what “good” looks like after.
- Turn that scenario into a manual check in the browser or in
bin/rails consolebefore you open a file undertest/. - Run a simple three-step loop (what you would check by hand, what you are changing on purpose, how you would know it passed) that maps directly to setup, action, and assertions in Chapter 5.
- Work through Recipes practice drills in the reference sample app you clone from the minitestrails Github, then compare your thinking with hidden example answers.
None of that requires memorizing every Minitest helper yet. You are building judgment first. The syntax comes next.
Approaching a test: where do I even start? #
If you have ever stared at an empty test file and felt stuck, you are not alone. The terminal and the test runner feel like they want an answer immediately. It is easy to forget that a test is just a repeatable version of a question you already know how to ask when you are debugging by hand.
When I feel that pressure, I ask one question before I write Ruby: “How would I prove this works if I were sitting at the app right now?” Maybe that means opening the browser. Maybe it means pasting two lines into bin/rails console. Maybe it means watching a redirect after a form submit. Whatever channel I pick, I write those steps down as a short numbered list.
This guide calls that list a “scenario”. Once the scenario is clear, the test file stops being scary. You are not inventing behavior from syntax. You are encoding a check you already trust.
A small login example (manual first, code second) #
Take a boring, familiar flow: “a user logs in successfully.” Before any test "..." block, I would write what I would do by hand:
- Visit the landing page at
http://localhost:3000. - Click Login.
- Confirm I am on the login page and I can see email and password fields.
- Type an email.
- Type a password.
- Click Login button.
- Confirm I land on the dashboard (or whatever page my app uses after sign-in).
Only after those steps make sense would I open test/system/ and automate them. The automated version is the robot repeating a script I already ran:
# test/system/login_test.rb
require "application_system_test_case"
class LoginTest < ApplicationSystemTestCase
test "user logs in successfully" do
visit root_path
click_on "Login"
assert_text "Login to the app"
fill_in "Email", with: "test@email.com"
fill_in "Password", with: "testpass1234"
click_on "Login"
assert_current_path dashboard_path
end
end
Do not worry if every helper name is new. The point of this chapter is the order: manual steps first, automated script second. Chapter 5 will do that with Recipes. For now, notice how each assert_* line is just one observable outcome from the manual list.
Scenario building #
A scenario is a short story in normal language. It describes one thing that happens in your app and what should change (or stay the same) when that thing is done. It is not Ruby. It is the contract your test will enforce later.
Good scenarios are small. They force you to name one actor, one starting point, one action, and one main outcome. That sounds rigid, but it saves you from the “mega test” that signs someone up, creates a recipe, shares it by email, and deletes it in one method. When that mega test fails on step six, you still do not know which step broke.
What goes into a scenario #
Think in four pieces. You can use them as a worksheet before every test you write in later chapters:
| Piece | Plain question | Example (Recipes, later) |
|---|---|---|
| Actor | Who is doing something? | Alice (user), signed in |
| Starting point | What is true before the action? | The recipe list is empty |
| Action | What do they do? | Alice submits the new recipe form with a blank title |
| Expected outcome | What should you see after? | The recipe is not saved; an error appears on the title field |
If you skip starting point, you will fight random data later. “Sometimes it passes” often means the scenario never said whether the list was empty, whether the user was signed in, or which recipe id you were editing. If you skip expected outcome, you end up adding assertions until something turns green without knowing what you proved. I have done that. It feels productive for five minutes and painful the next day.
Write the scenario before code #
Say the feature idea is “Recipes need a title.”
A vague scenario like “Test recipes” does not help you tomorrow. A useful scenario like the following does:
- Actor: anyone creating a recipe
- Starting point: a new, unsaved recipe record
- Action: set
titleto blank and ask whether the record is valid - Expected outcome: the app says the record is invalid and complains about
title
That scenario can become a model test in test/models/. It can also become a browser check later if you want the error on the form. The scenario comes first; the folder comes second.
Keep the slice small #
One scenario should usually become one test "..." method with one main outcome. If your story includes sign up, email confirmation, create recipe, and share link, split it into four scenarios. You can always add more tests. You cannot easily debug one long test when it fails in the middle and you are not sure which step broke.
Where the scenario lives in test/ folder #
Once the story is clear, use Chapter 2 as the backstop:
- Outcome is mostly data and Ruby rules →
test/models/ - Outcome needs HTTP, sessions, or redirects →
test/controllers/ortest/integration/ - Outcome needs what a human sees in a real browser →
test/system/
Chapter 5 walks through one model scenario, your first HTTP integration tests, and one browser smoke in your own app. The pattern stays the same after that: scenario first, folder second, code last.
Manual testing before automated testing #
Manual testing means you run the scenario yourself before you lock it into Minitest. You are not replacing automation. You are proving the scenario is real and observable.
Automated tests are expensive in time and attention. They are worth it once you know what “correct” means. While you are still figuring that out, manual checks are cheap. I still do them when a feature is new or when a test fails and I need to remember what I was trying to prove.
You are not “only doing QA.” Developers run these micro checks all day: one console line, one form submit, one redirect. The difference in this guide is that you write the steps down on purpose instead of keeping them in your head.
Types of manual channels #
Pick the smallest channel that can falsify your scenario. If console already proves the validation, you do not need a browser for the same rule.
| Channel | When to use it | Recipes example (once the feature exists) |
|---|---|---|
| Browser | Layout, buttons, flashes, Turbo, “does the page look broken?” | Open /recipes/new, submit with an empty title, look for an error on the form |
bin/rails console |
Model rules, scopes, Ruby that does not need HTTP | recipe = Recipe.new(title: ""); recipe.valid? should be false |
Turn a scenario into a manual script #
Take the blank title scenario and write numbered steps you could hand to a teammate:
- Start the app (
bin/rails serverorbin/dev) for a UI check, or use console only for a model-only rule. - Create a recipe with an empty title (form submit or
Recipe.new(title: "")in rails console). - Observe: invalid record, error on
title. For a full app check in the browser, confirm a new row was not added to the list.
If step 3 is unclear, fix the app or fix the scenario before you write assert_* lines. Later chapters assume you could have done this by hand. The automated test is the robot that repeats your check.
What manual testing is not #
Manual checks do not replace a test suite in CI. They are not an excuse to skip edge cases once the happy path works. They are not only for staging. They are how you earn the right to automate.
Automated tests are the repeatable version of a manual script you already trust.
The habit loop #
Once the scenario is clear and you have a manual script, translate it in three passes. I still use this on features I have shipped for years.
1. What would I check by hand?
Copy your manual steps here. Be boring and specific. “Click Save” is weaker than “submit the new recipe form with title left blank and look for an error on the title field.”
If you cannot check it by hand, you are not ready to automate it yet. Build or spike the feature first.
2. What am I changing on purpose?
Write only the inputs and conditions that matter for this check. Optional fields you are not testing can stay out of the story.
For example, if the scenario is “blank title is rejected”, what you are changing on purpose is the title (empty). You do not need to mention prep time, servings, or description in that scenario unless you are actually testing those fields. Leaving them out does not mean they are forbidden in the app. It means they are optional for this test and would only add noise.
Same idea elsewhere: if you are testing “guest cannot open edit”, you need signed-out user and an edit URL. You do not need to spell out every field on the recipe show page unless the test is about that page content.
3. How will I know it passed?
List observable results: valid? is false, response is 422, page shows “can’t be blank”, count did not change. Each line becomes an assertion in Chapter 5.
Carry the scenario into the test file #
When you open test/models/recipe_test.rb next, keep the scenario visible. Paste the story in comments above the test method. Delete the comments later if the code reads clearly on its own. The point is to never start from a blank file and a blank mind at the same time.
Here is the blank title scenario carried into a file you will write soon in Chapter 5. You do not need to type this yet. Read how the comments line up with the Ruby:
# test/models/recipe_test.rb
require "test_helper"
class RecipeTest < ActiveSupport::TestCase
# Scenario: Recipes need a title
# Actor: anyone creating a recipe
# Starting point: a new, unsaved recipe record
# Action: set title to blank and check valid?
# Expected outcome: invalid; error on title mentions blank
#
test "rejects a blank title" do
recipe = Recipe.new(title: "")
assert_not recipe.valid?
assert_includes recipe.errors[:title], "can't be blank"
end
end
The comment block is your checklist. The three lines inside test "..." are the robot version. When a test fails, compare the failure to the comments first, not to a vague memory of what you meant to test.
You do not need to memorize assertion syntax yet. Chapter 3 lists the helpers in a table; subsequent chapters walk through assertions line by line when you write your first real test.
Practice: scenario drills #
Enough of the theory, let’s dive into some practical drill now. I built a reference Recipes app on GitHub so you can practice without touching the smaller app from Chapter 1. Clone it, run bin/setup, and walk through the drills below. When you are done with this chapter, you can delete the clone if you want. Your Chapter 1 app stays where it is for Chapter 5.
The reference clone already has sign-in and guest read-only rules (browse recipes, but no create/edit/destroy without logging in). Your own app from Chapter 5 will stay simpler for several chapters: guests can still use full CRUD until you add authentication in Chapter 11. That split is intentional so drills here feel like a real app while your test chapters do not overwhelm you with sessions too early.
Clone the sample Recipes app
Repository: https://github.com/minitestrails/recipe-scenario-drill
git clone git@github.com:minitestrails/recipe-scenario-drill.git
cd recipe-scenario-drill
bin/setup
`bin/setup` should install gems, prepare the database, and leave you ready to run `bin/dev` or `bin/rails server`. Test login emails and passwords are on the app sign-in page (and also in the project's README). You do not need to understand every file in the repo yet.
How to use each group #
The groups go from simple to complex. Model rules use bin/rails console only and do not need a browser session. Browser drills assume you are signed in as Alice, which the next section walks through. Authorization is last and is the only place you change users on purpose.
| Group | Where to check manually? | Test folder where this test would live | Purpose |
|---|---|---|---|
| Model rules | bin/rails console |
test/models/ |
Fastest way to verify validation and Ruby logic without browser noise. |
| Sign in | Browser | test/integration/ or test/system/ |
Sign in using the accounts on the sign-in page; keep that session for the drills below. |
| List and read | Browser (signed in) | test/system/ or test/integration/ |
Recipe index while logged in; no sign-out between drills. |
| Create | Browser (signed in) | test/system/ or test/integration/ |
New recipes belong to Alice so edit and destroy work later. |
| Update and destroy | Browser (signed in) | test/system/ or test/integration/ |
Edit and delete recipes Alice owns. |
| Nested ingredients | Browser (signed in) | test/system/ and sometimes test/models/ |
Nested forms on new or edit; model checks for edge validations. |
| Steps | Browser (signed in) | test/system/ and sometimes test/models/ |
Ordered instructions on the form; same pattern as ingredients. |
| Share email | Browser (signed in, letter_opener) | test/mailers/ and test/integration/ or test/system/ for UI flow |
Share from a recipe Alice owns; read the email preview in dev. |
| Authorization | Browser (switch users or sign out) | test/integration/ or test/system/ |
Alice vs Bob, then guest blocked from edit. |
Login to the app #
Do this once after bin/setup and before you open the first browser drill (everything from Sign in through Share email in the table above). The reference clone already has authentication and authorization pre-configured. Guests can browse recipes, but creating, editing, destroying, and most of the drills that change data expect a signed-in user. If you skip login, you will hit redirects or missing buttons and think the drill is broken when the app is doing what it was built to do.
Signing in once also saves time so you do not have to keep logging in and out between practice drills. Stay logged in as Alice (alice@example.com) for every browser drill through Share email. You only sign out or switch to Bob in the Authorization group at the end, when the story is explicitly about two different users or a guest who must not edit someone else’s recipe.
- Start the clone with
bin/devorbin/rails serverand open the app in your browser (usuallyhttp://localhost:3000). If you followed instructions to clone the app then your app should already be running without you needing to do anything else. - Click on the “Sign in” button to open the sign-in page.
- At the bottom of the sign-in page, the app lists test accounts with emails and passwords for local development. Use
alice@example.comand the password shown there for Alice. - Submit the form and confirm you are signed in. You should see “Sign out” button in the UI.
- Leave that session open while you work through List and read, Create, Update and destroy, Nested ingredients, Steps, and Share email. Do not sign out between those groups unless a drill tells you to.
When you reach the Authorization group, you will sign in as Bob or sign out to play a guest on purpose. Until then, treat “already logged in as Alice” as part of your starting point for each browser scenario.
Scenario Practice Drills #
Now we will practice scenario drills. For each group, work in this order for making the drill effective:
- Start with the first (main) drill in the group.
- Click Open worksheet (write your answer first) and fill in actor, starting point, action, expected outcome, manual steps, and which folder under
test/you would use later (for exampletest/models/). - Run those manual steps in the cloned app. Use
bin/devorbin/rails serverfor browser work. Usebin/rails consolefor model rules. - When you are done, click Show example answer and compare. Try not to peek before you run the steps yourself.
- Do the shorter follow-up drills in the same group. They reuse the same pattern with a different edge case.
Do not write Minitest in this chapter. You are training judgment and manual proof. The robot (minitest / automated test) comes in the next chapter.
Model rules #
Data validations you can check in `bin/rails console` without opening a browser.
Blank title is rejected
Someone tries to treat a new recipe with an empty title as valid. Your job is to write the four scenario pieces, list manual steps in rails console, and name which folder under test/ you would use when you automate this in Chapter 5 (for example test/models/).
Write this down before you open the answer.
Example answer (cross-check only)
Actor: anyone creating a recipe (guest or signed-in user; the rule is on the model)
Starting point: a new Recipe that has not been saved yet
Action: set title to a blank string and call valid? (or try to save through the UI later)
Expected outcome: the record is invalid, and there is an error on title (for example “can’t be blank”)
Manual steps (rails console):
- From the cloned app root:
bin/rails console recipe = Recipe.new(title: "")recipe.valid?→ should befalserecipe.errors[:title]→ [“can’t be blank”]
Test folder: test/models/ (model test)
Negative preparation time is rejected
Same group as blank title. In the reference app, each recipe can store how many minutes it takes to prepare (the database column is prep_time). Someone tries to save -1 minutes. That should not be valid. Write your scenario and console steps, then reveal the answer.
Use this worksheet, then compare with the example answer.
Example answer (cross-check only)
Actor: anyone creating or updating a recipe
Starting point: a new or existing recipe record in memory
Action: set preparation time to -1 minutes (prep_time: -1 in code) and check valid?
Expected outcome: invalid; error on prep_time (greater-than-zero message)
Manual steps (console):
bin/rails consolerecipe = Recipe.new(title: "Soup", prep_time: -1)recipe.valid?→falserecipe.errors[:prep_time]→ [“must be greater than 0”]
Test folder: test/models/
Authentication #
Sign in to the app.
User signs in successfully
The reference app uses Rails built-in authentication: you sign in with an email and password, and the app remembers you with a session (a cookie) until you sign out.
You are not signed in yet. Open the sign-in page in the clone app. It lists test accounts you can use (for example alice@example.com).
Write who signs in, what is true before, what they do in the browser (which link or URL, what they type), and how you can tell sign-in worked. List each step in the cloned app, then reveal the answer to compare.
Write this down before you open the answer.
Example answer (cross-check only)
Actor: a user who already has an account (test accounts are shown on the sign-in page)
Starting point: the app is running locally; you are not signed in (no Sign out link, or the app treats you as a guest)
Action: open the sign-in page, read the test account email and password shown there, enter them, and submit the form
Expected outcome: you are signed in; the UI shows a logged-in state (for example Sign out, your email, or a welcome message). Protected pages that need a session should work for this user.
Manual steps (browser):
- From the project root:
bin/devorbin/rails server - Visit
http://localhost:3000 - Click Sign in button
- On the sign-in page, copy the email and password listed for Alice
- Submit the form
- Confirm you are logged in (Sign out visible, and your email is in the header)
Test folder: test/integration/ for POST to session + redirect; test/system/ if you automate the full sign-in form in a browser
What you might see: You will land on the home page or /recipes after sign-in. If login fails, re-run bin/setup and check the credentials on the sign-in page again. Do not sign out until the Authorization drills unless a step tells you to.
List and read #
Can a user see the recipe index in the browser?
Signed-in user opens the recipe index
A signed-in user visits the main recipe list in the browser. Write the scenario, list each click or URL you would use in the cloned reference app, and name the test/ folder you would use in Chapter 5.
Write this down before you open the answer.
Example answer (cross-check only)
Actor: signed-in user (alice@example.com)
Starting point: you are already signed in; the app is running locally; zero or more recipes may exist
Action: open the recipe index page (/recipes)
Expected outcome: HTTP success; the page shows a heading and a list of recipes. No 500 error page.
Manual steps (browser):
- Stay signed in as Alice (complete Sign in first if needed)
- Visit
http://localhost:3000/recipes(port may differ; read the terminal) - Confirm you see a page title such as “Recipes” and room for a list
Test folder: test/system/ for “does the page render for a human?” or test/integration/ if you only need status code and a fragment of HTML
What you might see: An empty list is still a pass for this scenario. Empty vs populated is a different scenario (reveal below).
Empty list shows a helpful message
Still signed in as Alice. There are no recipes saved yet (or you cleared them locally). Open the recipe list. The page should still look fine (for example “No recipes yet”, not a crash).
Use this worksheet, then compare with the example answer.
Example answer (cross-check only)
Actor: signed-in user (alice@example.com)
Starting point: you are signed in; zero rows in the recipes table (fresh DB after setup, or Recipe.destroy_all in rails console on your clone only)
Action: visit /recipes
Expected outcome: the page loads and shows copy that makes sense for an empty list (for example “No recipes yet” or an empty state message), not a crash
Manual steps (browser):
- Stay signed in as Alice
- Optional:
bin/rails console→Recipe.destroy_allto force empty (local clone only) - Visit
/recipes - Read the page for empty-state text
Test folder: test/system/ (message is visible HTML) or integration with assert_match on the response body
Create #
Submitting the new recipe form in the browser.
Valid create lands on the show page
Stay signed in as alice@example.com. Create a recipe through the new-recipe form. New recipes are saved under your user.
The app validates recipes like this:
- Title is required (cannot be blank).
- Preparation time (
prep_time, minutes to prepare) is optional. If you fill it in, it must be a number greater than 0 (not zero, not negative). - Servings is optional. If you fill it in, it must be a number greater than 0.
Use values that pass those rules (for example title “Lentil soup”). Write your scenario and browser steps, then reveal the answer.
Write this down before you open the answer.
Example answer (cross-check only)
Actor: signed-in user (alice@example.com)
Starting point: you are signed in; the new recipe form is available (for example /recipes/new)
Action: fill the form with valid data (required title; optional prep_time and servings only if greater than 0) and submit
Expected outcome: the recipe is saved; the browser ends on that recipe’s show page (URL like /recipes/1); you see the title on the page
Manual steps (browser):
- Stay signed in as Alice
- From the recipe list page, click on the “New recipe” button
- Fill Title (required), for example
Lentil soup - Optionally fill Prep time and Servings with positive numbers (for example
30and4), or leave them blank - Click “Create Recipe” button
- Confirm redirect to
/recipes/:idand the title “Lentil Soup” appears in the detail page
Test folder: test/system/ for the full click path; test/integration/ if you only assert POST + redirect without a browser
What you might see: A flash notice such as “Recipe was successfully created” on the show page. If create fails, check title is present and any prep time or servings you entered is greater than 0.
Blank title on create shows an error
Still signed in as Alice. Submit the new recipe form with title left empty. The record should not be created.
Use this worksheet, then compare with the example answer.
Example answer (cross-check only)
Actor: signed-in user (alice@example.com)
Starting point: you are signed in; on /recipes/new
Action: leave title blank, submit the form
Expected outcome: still on new or re-rendered form with an error on title; no new row (check index or rails console Recipe.count)
Manual steps (browser):
- Stay signed in as Alice
- From the recipe list page, click on the “New recipe” button
- Clear the title field, submit
- Look for an inline error near title with the text “Title can’t be blank”
- Optional:
Recipe.countunchanged in rails console
Test folder: test/integration/ for POST + response; test/system/ if you want the error message visible in the UI
Update and destroy #
Edit and destroy recipes you own (stay signed in as Alice).
Edit updates the title on the show page
Stay signed in as alice@example.com. Only the owner can edit a recipe. Open a recipe Alice owns, change its title, and save. Write your scenario and browser steps, then reveal the answer.
Write this down before you open the answer.
Example answer (cross-check only)
Actor: signed-in owner (alice@example.com)
Starting point: you are still signed in as Alice; at least one recipe belongs to Alice (from the Create drill)
Action: open edit form for your recipe, change title, submit
Expected outcome: show page (or index) reflects the new title; no duplicate recipe row
Manual steps (browser):
- Stay signed in as Alice
- From the recipe list page, click on the “Edit” button for a recipe Alice owns
- Change title e.g. “Lentil Soup - Updated”
- Click “Update Recipe” button
- Confirm new title “Lentil Soup - Updated” on the detail page
Test folder: test/system/ for clicks; integration for PATCH + follow redirect if you skip the browser
Destroy removes the recipe
Signed in as the recipe owner, delete a recipe from the UI. It should disappear from the index and show a success flash. Write your scenario and browser steps, then reveal the answer.
Use this worksheet, then compare with the example answer.
Example answer (cross-check only)
Actor: signed-in owner
Starting point: you are signed in; a recipe you own exists and you are willing to delete it locally
Action: open show or index, click Destroy, confirm the dialog if the app asks
Expected outcome: redirect (often to index); recipe gone from list; success flash
Manual steps (browser):
- Stay signed in as Alice (owner of the recipe)
- From the recipe list page, click on the “Delete” button for a recipe Alice owns
- Click “OK” in the confirmation dialog
- On the recipe list page, confirm that the recipe is removed from the list
Test folder: test/system/ or integration DELETE + assert redirect
Nested ingredients #
Ingredients belong to a recipe (nested form fields).
Add an ingredient on the recipe form
Stay signed in as alice@example.com. On new or edit recipe, add at least one ingredient row (name, quantity, unit). Write your scenario and browser steps, then reveal the answer.
Write this down before you open the answer.
Example answer (cross-check only)
Actor: signed-in user (alice@example.com)
Starting point: you are signed in; recipe form with nested ingredient fields (“Add ingredient” on new/edit)
Action: fill recipe title and one ingredient row, submit
Expected outcome: recipe saves; show page lists the ingredient (or edit shows the nested row persisted)
Manual steps (browser):
- Stay signed in as Alice
- From the recipe list page, click on the “New recipe” button
- Fill the title field with “Mushroom Soup”
- Click the “Add ingredient” button and add one ingredient row with the name “Mushrooms”, quantity “2”, unit “cups”
- Click “Create Recipe” button
- Confirm redirect to
/recipes/:idand the title “Mushroom Soup” and the ingredient “2 cups Mushroom” appears in the ingredients section
Test folder: test/system/ for nested forms; model test if you only validate Ingredient attributes in isolation; test/integration/ if you only assert POST + redirect without a browser
What you might see: Turbo may add ingredient rows without a full page reload; you are checking the outcome after save.
Ingredient with invalid quantity fails
Still signed in as Alice. Try saving an ingredient with a bad quantity (blank or negative). Write your scenario first.
Use this worksheet, then compare with the example answer.
Example answer (cross-check only)
Actor: signed-in user (alice@example.com)
Starting point: you are signed in; recipe form with nested ingredients
Action: submit with quantity blank or -1 if the app validates it
Expected outcome: form does not save (or ingredient error shown); recipe or ingredient remains invalid
Manual steps (browser):
- From the recipe list page, click on the Edit button for a recipe Alice owns
- Click the “Add ingredient” button and add one ingredient row with the name “Salt”, quantity “-1”, unit “pinch”
- Click “Update Recipe” button
- Read errors on the nested fields with the text “Quantity must be greater than 0”
Test folder: test/system/ for nested forms; model test if you only validate Ingredient attributes in isolation; test/integration/ if you only assert PATCH + redirect without a browser
Recipe steps #
Instructions to create a recipe (nested under a recipe form).
Add a step to a recipe
Stay signed in as alice@example.com. Add at least one step (order + instruction) on create or edit. Write your scenario and browser steps, then reveal the answer.
Write this down before you open the answer.
Example answer (cross-check only)
Actor: signed-in user (alice@example.com)
Starting point: you are signed in; recipe form with nested steps
Action: fill recipe title and one step instruction, submit
Expected outcome: step appears on show page in order (for example “1. Preheat oven…”)
Manual steps (browser):
- Stay signed in as Alice
- From the recipe list page, click on the “Edit” button for a recipe Alice owns
- Click the “Add step” button and add one step row with the instruction “Boil the water” and Order “1”
- Click “Update Recipe” button
- Confirm redirect to
/recipes/:idand the step “1. Boil the water” appears in the steps section
Test folder: test/system/ or model test on Step validations
Step with blank instruction fails
Still signed in as Alice. Submit a step with an empty instruction. The app should refuse or show a field error.
Use this worksheet, then compare with the example answer.
Example answer (cross-check only)
Actor: signed-in user (alice@example.com)
Starting point: you are signed in; recipe with nested steps on the form
Action: leave step instruction empty, submit
Expected outcome: validation error on step; recipe not saved or step highlighted
Manual steps (browser):
- From the recipe list page, click on the “Edit” button for a recipe Alice owns
- Click the “Add step” button and add one step row with the instruction “”, Order “2”
- Click “Update Recipe” button
- Read the error on the step with the text “Instruction can’t be blank”
Test folder: test/models/ on Step presence; system if you assert visible error
Share recipe email (Mailer) #
Trigger email from the UI by sharing the recipe with a friend; read it in the browser via letter_opener.
Share recipe sends an email with the title in the subject
Stay signed in as alice@example.com. From a recipe detail page you own, use the share action. The email should open in a new browser tab via letter_opener. Write the scenario and clicks before you reveal the answer.
Authorization #
Who is allowed to change which recipe? Switch users or sign out here. This is the only group that needs it.
Another user cannot edit my recipe
This group is where you change who is signed in. Use alice@example.com as User A and bob@example.com as User B. Emails and passwords for both are on the sign-in page.
Sign in as Alice, create or pick a recipe she owns. Sign out, sign in as Bob (credentials on the sign-in page). Try to edit Alice’s recipe. Write your scenario and browser steps, then reveal the answer.
Guest is turned away from edit
Sign out so you are not logged in. Try to open an edit URL for any recipe. Write your scenario, then reveal. (After this drill you can sign back in as Alice if you want to keep exploring.)
You just practised the scenario-first habit, which is what makes later automated tests easier to trust.
Scenario-first thinking is the real unlock.
That habit is what every automated test in this guide builds on. If these drills helped, fund the next chapter and keep the guide free.
One-time support via Stripe. No account required.
How the rest of the guide uses this chapter #
Everything after this chapter assumes you are not jumping straight from a feature idea to assert_* lines. The order is deliberate, and it matches how most of us actually debug: you decide what should happen, you prove it by hand once, and only then you ask Minitest to repeat that proof on every run.
That is not slower for its own sake. It is slower in the moment so you waste less time later. When a test fails, the failure message is only useful if you already know which story you were telling. “Expected true, got false” does not help if you never wrote down who was signed in or what the page was supposed to show.
The guide keeps the same three passes in later chapters. They map cleanly to what you practiced here:
- Scenario in plain language (this chapter). Who acts, what was true before, what they do, what “good” looks like after. You wrote that in the drill worksheets and in the habit loop above.
- Manual check in the browser or console (this chapter, using the reference clone). You ran the steps yourself before peeking at the example answers. That is the proof the scenario is real.
- Automated test in the right folder under
test/(starting in Chapter 5, in your own Recipes app). The test file is the robot script for a manual check you already trust.
Chapter 5 is where the robot shows up in your Recipes app you generated in Chapter 1. You will still write the scenario first, then automate. Fixtures, system tests, mailers, and jobs later all reuse the same habit. The folder under test/ changes (models vs integration vs system vs mailers), but the order does not.
Two apps, one habit. This chapter used the reference clone so you could practice auth, ownership, and share-email flows without your own app having those features yet. From Chapter 5 onward you change your Recipes app and add tests there. When a later chapter says “write the scenario first,” it means the same worksheet questions you used in the drills, not a new ritual though you will write them in the test file itself instead of a worksheet.
When a test feels too big, too vague, or stuck on the wrong failure, come back here before you add more assertions. Shrink the story to one actor, one starting point, one action, and one outcome. Run the manual steps again in the browser or console. If you cannot prove it by hand, the test is not ready yet.
Chapters on writing your first test, CRUD system tests, mailers, background jobs, and authorization will point you at this chapter on purpose. They will not re-teach scenario building from scratch every time. They will remind you to use the loop you already practiced.
What is next #
You have the map from Chapter 2, the toolbox names from Chapter 3, and a repeatable loop from this chapter. You also have muscle memory from the drills: who acts, what was true before, what they did, what should have happened, and where that story would live under test/.
Next you will automate your first scenarios in the Recipes app you generated in Chapter 1: a model validation that fails then passes, integration tests for Recipes over HTTP, and one system smoke test. Same habit, new file extensions.
Continue to Your first test.
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 feedbackDisclaimer: 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.