Test a Recipe Search App with Cypress

Write Cypress E2E tests for a recipe search app, including API stubbing.

Introduction

30 Second Summary

You build a web app and click around to make sure it works. Every time you change a line of code, you repeat the same clicks all over again.

In this project, you will build a recipe search page and write automated tests using Cypress that click through the app for you. Your tests will type into the search box, verify the results, and even simulate what happens when the server goes down.

What You'll Build

Run one command and watch a browser open by itself, type "chicken" into your search box, click the button, and prove that the right recipe cards appear in seconds.

By the end of this project, you'll have:

  • A recipe search page where you type a meal name and see matching recipe cards with images and titles from a live API.
  • A passing Cypress test that opens your page, types "chicken" into the search box, clicks the button, and confirms recipe cards appear with green checkmarks.
  • Stubbed API tests that fake the network response so your tests run instantly and never flake. You will also simulate empty results and server errors to prove your app handles them gracefully.
  • An optional Secret Mission to push your testing skills even further.

Are there any prerequisites?

Basic JavaScript knowledge and familiarity with HTML are all you need. Some exposure to testing concepts like describe and it blocks is helpful but not required.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Project and Install Cypress

Before you can write your first test, you need two things. A project folder to work in, and Cypress installed as your testing framework.

In this step, you'll create a folder, initialize it with npm, and install Cypress. Then you'll open the Cypress Launchpad and watch it scaffold the project structure your tests will live in.

In this step, get ready to:
  • Verify your Node.js version and create your project folder.
  • Install Cypress as a dev dependency.
  • Open the Cypress Launchpad and scaffold the project structure.
Verify your Node.js version and create your project folder

Cypress requires Node.js version 20 or higher. Let's confirm your version before going further.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check your Node.js version by running this command:
node --version

✔️ I see version 20 or higher

Your Node.js version meets Cypress's requirements. You're ready to continue.

ⓧ I see a version lower than 20

You'll need to upgrade Node.js to version 20 or higher.

  • Visit the Node.js download page and download the latest LTS version.
  • Run the installer and follow the prompts.
  • Close and reopen your terminal, then run node --version again to confirm the update.

ⓧ Command not found

Node.js is not installed on your machine yet.

  • Visit the Node.js download page and download the latest LTS version.
  • Run the installer and follow the prompts.
  • Close and reopen your terminal, then run node --version to confirm it is installed.

Now let's create a project folder on your Desktop and initialize it.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir recipe-cypress
cd recipe-cypress
  • Initialize your project with npm by running this command:
npm init -y

What does this command do?

The -y flag tells npm to accept all the default values without asking questions. It creates a package.json file that tracks your project's name, version, and dependencies.

  • Confirm package.json was created by running:
ls

You should see package.json listed in the output.

Don't see package.json?

  • Make sure you ran npm init -y from inside the recipe-cypress folder, not from your Desktop.
  • Run pwd (macOS) or Get-Location (Windows) to check your current directory. The path should end with recipe-cypress.

Still stuck? Help me figure out why package.json wasn't created.

Install Cypress as a dev dependency

Cypress is the testing framework that opens a real browser and runs your tests visually. Installing it as a dev dependency means it is available for testing but won't ship in production code.

  • Install Cypress by running this command:
npm install cypress --save-dev

What's happening during install?

This command does two things. It downloads the Cypress npm package into your node_modules folder. It also downloads the Cypress binary, which is the desktop application that opens a browser and runs your tests.

The binary download may take a minute or two depending on your internet speed. Be patient and let it finish.

When the install completes, you should see output confirming packages were added.

Install seems stuck or failed?

  • The Cypress binary download can take a few minutes. Give it time before cancelling.
  • If the install finishes but mentions the binary was not downloaded, run this command to download it manually:
npx cypress install

Need more help? Help me troubleshoot my Cypress installation.

Open Cypress for the first time and scaffold the project

With Cypress installed, it is time to open it. The first time you launch Cypress, it walks you through a setup wizard called the Launchpad. The Launchpad generates the folders and config files your tests need.

Before you run this, what do you think will happen when you open Cypress for the first time? Will it jump straight to running tests, or is there setup to do first?

  • Open the Cypress Launchpad by running this command:
npx cypress open

After a moment, a new window appears. This is the Cypress Launchpad.

What is the Cypress Launchpad?

The Launchpad is a setup wizard you see the first time you open Cypress in a project. It walks you through three steps: choosing a testing type, reviewing the generated configuration files, and picking a browser.

You only go through this full setup once. On future opens, Cypress remembers your choices.

The Launchpad shows two options: E2E Testing and Component Testing. End-to-end testing visits your whole app in a real browser the way a user would. That is what you need for this project.

  • Click E2E Testing.

Cypress generates a set of configuration files and shows you a list of what it created. You should see files like cypress.config.js, cypress/support/e2e.js, and the cypress/e2e/ and cypress/fixtures/ folders.

  • Click Continue to accept the generated configuration.

What did Cypress just create?

  • cypress.config.js is the main config file. It tells Cypress where to find your tests and support files.
  • cypress/e2e/ is where your test files will live.
  • cypress/fixtures/ stores sample data you can load into tests.
  • cypress/support/e2e.js runs before every test file, letting you add shared commands or setup.

The Launchpad now shows a list of browsers it found on your system.

  • Pick any browser from the list (Chrome is a great default).
  • Click Start to launch the browser with Cypress.

A browser window opens with the Cypress test runner. It shows an empty spec list because you have not created any test files yet. That is exactly right.

Launchpad didn't open?

  • Make sure you are running npx cypress open from inside the recipe-cypress folder.
  • If you see an error about the Cypress binary not being found, run npx cypress install first, then try npx cypress open again.
  • On Windows, make sure you are using PowerShell (not Command Prompt).

Still stuck? Help me get the Cypress Launchpad to open.

Your testing environment is ready and your project structure is in place. Next up, you'll build a recipe search page that gives Cypress something real to test.

Build a Simple Recipe Search Page

Your project folder is set up and Cypress is installed. Now you need a real web page for Cypress to actually test.

In this step, you will build a simple recipe search page that fetches live data from TheMealDB API. This gives you a working app that Cypress can visit, interact with, and verify in the next step.

In this step, get ready to:
  • Create an index.html file with a styled search form, results grid, and message areas.
  • Add JavaScript that fetches recipes from TheMealDB and displays them as cards.
Create the HTML file

Every recipe search needs a page with a search box, a place to display results, and areas for status messages like "no results" or errors.

  • In VS Code, select the **Explorer** view in the Activity Bar on the left.
  • Click the New File button at the top of the Explorer pane.
  • Type index.html and press Enter.
  • Paste the following code into index.html to set up the document head and styles:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Recipe Search</title>
  <style>
    /* Reset and page layout */
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
    h1 { margin-bottom: 20px; }
    /* Search form */
    .search-form { display: flex; gap: 8px; margin-bottom: 20px; }
    .search-form input { flex: 1; padding: 8px 12px; font-size: 16px; border: 1px solid #ccc; border-radius: 4px; }
    .search-form button { padding: 8px 16px; font-size: 16px; background: #0070f3; color: white; border: none; border-radius: 4px; cursor: pointer; }
    .search-form button:hover { background: #005bb5; }
    /* Recipe card grid */
    .results { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
    .recipe-card { border: 1px solid #ddd; border-radius: 8px; overflow: hidden; cursor: pointer; }
    .recipe-card img { width: 100%; height: 150px; object-fit: cover; }
    .recipe-card h3 { padding: 8px 12px; font-size: 14px; }
    /* Status messages */
    .no-results { padding: 20px; color: #666; text-align: center; }
    .error-message { padding: 20px; color: #d32f2f; text-align: center; }
    .hidden { display: none; }
  </style>
</head>

What do these styles do?

The CSS sets up a centered page layout, a flexbox search form, and a responsive grid for recipe cards. The .hidden class hides elements by default. You will use it to toggle the "no results" and error messages on and off.

  • Right below the </head> line you just pasted, add the body section:
<body>
  <h1>Recipe Search</h1>
  <!-- Search form -->
  <form class="search-form" data-testid="search-form">
    <input type="text" data-testid="search-input" placeholder="Search for a meal..." />
    <button type="submit" data-testid="search-button">Search</button>
  </form>
  <!-- Status messages (hidden by default) -->
  <div data-testid="no-results" class="no-results hidden">No recipes found.</div>
  <div data-testid="error-message" class="error-message hidden">Something went wrong. Please try again.</div>
  <!-- Recipe cards appear here -->
  <div data-testid="results" class="results"></div>
  <script>
  </script>
</body>
</html>

What are the data-testid attributes?

Each element has a data-testid attribute like search-input or recipe-card. These are stable selectors that Cypress uses to find elements on the page. Unlike class names or tag names, data-testid attributes are reserved for testing and will not change when you restyle the page.

  • Save index.html.
  • Open the file in your browser by right-clicking index.html in the Explorer and selecting Reveal in File Explorer (Windows) or Reveal in Finder (macOS), then double-clicking the file.
  • You should see a page titled "Recipe Search" with a text input and a blue Search button.

Don't see the search form?

  • Make sure both code blocks are pasted in order. The </head> closing tag should appear right before the <body> opening tag.
  • Check that you saved the file before opening it in the browser.
  • If you see raw HTML text instead of a rendered page, make sure the file extension is .html and you opened it in a browser, not in a text editor.

Having trouble? Help me figure out why my HTML page isn't rendering correctly.

Add the recipe search JavaScript

The page looks good, but it does not do anything yet. You need JavaScript that listens for the form submission, fetches matching recipes from TheMealDB, and renders each result as a card.

  • In index.html, find the empty <script> tag near the bottom of the file.
  • Paste the following JavaScript between the opening <script> and closing </script> tags:
    // Grab references to all page elements
    const form = document.querySelector('[data-testid="search-form"]');
    const input = document.querySelector('[data-testid="search-input"]');
    const resultsContainer = document.querySelector('[data-testid="results"]');
    const noResults = document.querySelector('[data-testid="no-results"]');
    const errorMessage = document.querySelector('[data-testid="error-message"]');

    // Handle form submission
    form.addEventListener('submit', async (e) => {
      e.preventDefault();
      const query = input.value.trim();
      if (!query) return;

      // Clear previous results and messages
      resultsContainer.innerHTML = '';
      noResults.classList.add('hidden');
      errorMessage.classList.add('hidden');

      try {
        const response = await fetch(
          `https://www.themealdb.com/api/json/v1/1/search.php?s=${encodeURIComponent(query)}`
        );
        const data = await response.json();

        // Show message if no meals match
        if (!data.meals) {
          noResults.classList.remove('hidden');
          return;
        }

What does this code do?

  • The five const lines grab references to the form, input, results container, and both message divs using their data-testid selectors.
  • When the form is submitted, e.preventDefault() stops the page from reloading so the search stays on the same page.
  • The fetch call hits TheMealDB's search endpoint with the query. If the API returns meals: null, it means no recipes matched, so the "No recipes found" message becomes visible.
  • Save index.html.
  • Directly below the closing } of the if (!data.meals) block you just pasted, add the code that renders each recipe as a card:
        // Create a card for each recipe
        data.meals.forEach((meal) => {
          const card = document.createElement('div');
          card.className = 'recipe-card';
          card.setAttribute('data-testid', 'recipe-card');
          card.setAttribute('data-id', meal.idMeal);
          card.innerHTML = `
            <img src="${meal.strMealThumb}" alt="${meal.strMeal}" />
            <h3 data-testid="recipe-title">${meal.strMeal}</h3>
          `;
          resultsContainer.appendChild(card);
        });
      } catch (err) {
        // Show error message if fetch fails
        errorMessage.classList.remove('hidden');
      }
    });

What does this code do?

  • The forEach loop creates a div for each meal, sets its class and data-testid attributes, and fills it with an image thumbnail (strMealThumb) and the meal name (strMeal).
  • The catch block handles network errors by showing the error message div. This covers cases where the API is unreachable.

Before you test, what do you think will happen when you type "chicken" in the search box and click Search?

  • Save index.html.
  • Switch to your browser and refresh the page.
  • Type chicken in the search box and click Search.

You should see recipe cards appear in a grid, each with a food thumbnail and a meal name like "Chicken Handi" or "Chicken Couscous". The data is live from TheMealDB.

No recipe cards showing up?

  • Open DevTools with F12 and check the Console tab for JavaScript errors. A typo in the code will show up here.
  • Make sure both JavaScript blocks are pasted inside the <script> tags, not outside them.
  • Confirm you have an internet connection. The page fetches data from TheMealDB's live API.
  • If you see the "Something went wrong" error message, the API request failed. Check the Network tab in DevTools to see if the request to search.php went through.

Still stuck? Help me debug why my recipe search page isn't showing results when I search for chicken.

\u2714\ufe0f Awesome, I've got everything!

Great. Double check you have saved index.html and your search for "chicken" returned recipe cards.

\u24e7 I'd like to double check the full code

Your index.html file is made up of four blocks you pasted above. Scroll up and confirm each one is present in this order:

  • The document head with styles (from <!DOCTYPE html> through </head>).
  • The body structure with the form, message divs, results container, and empty <script> tags (from <body> through </html>).
  • The DOM references and fetch logic inside <script> (the five const lines through the if (!data.meals) block).
  • The render loop and error handler (the forEach loop through the closing });).

If any block is missing or out of order, scroll up to the matching chunk and re-paste it.

Your recipe search app is live and pulling real data. Next up, you will write your first Cypress test to automate what you just did manually.

Write Your First Cypress E2E Test

Your recipe search page works when you test it manually. You type a meal name, click search, and see the cards appear. But every time you change the code, you have to repeat that entire check by hand.

An E2E test automates that exact manual flow. In this step, you will write a Cypress test that visits your page, types a search term, clicks the button, and checks that recipe cards appear.

In this step, get ready to:
  • Set up Cypress to visit your local HTML file.
  • Write a test that searches for "chicken" and asserts that results appear.
  • Run the test in the Cypress Test Runner and watch it execute in real time.
Set up Cypress to visit your local HTML file

Before writing a test, Cypress needs to know where your page lives. Since this project uses a plain HTML file with no dev server, Cypress can serve it directly from your project folder.

How does Cypress find your HTML file?

When no baseUrl is configured, Cypress automatically serves local files from your project root. Calling cy.visit('index.html') tells Cypress to load index.html from the same folder that contains your cypress.config.js.

This works well for small projects and example apps. For production apps, you would typically run your own dev server and point Cypress at it.

Your existing cypress.config.js already has everything it needs. No changes required.

Create your first test file

Cypress looks for test files inside the cypress/e2e/ folder. Files must end with .cy.js for Cypress to recognize them as specs.

  • Create a new file called recipe-search.cy.js inside the cypress/e2e/ folder.
  • Add the following test code to recipe-search.cy.js:
// Group related tests under a describe block
describe('Recipe Search', () => {
  // Visit the page before each test runs
  beforeEach(() => {
    cy.visit('index.html')
  })

  // Test the search flow against the live TheMealDB API
  it('searches for a meal and displays results from the live API', () => {
    // Type "chicken" into the search input
    cy.get('[data-testid="search-input"]').type('chicken')
    // Click the search button to submit the form
    cy.get('[data-testid="search-button"]').click()
    // Wait up to 10 seconds for recipe cards to appear
    cy.get('[data-testid="recipe-card"]', { timeout: 10000 })
      .should('have.length.greaterThan', 0)
  })
})

What does this code do?

  • describe('Recipe Search', ...) groups all your recipe search tests under one label. This keeps the Command Log organized.
  • beforeEach(...) runs before every test inside the describe block. Here it visits index.html so each test starts on a fresh page.
  • cy.get('[data-testid="search-input"]') selects an element using a CSS attribute selector. Using data-testid attributes is a best practice because they are stable and won't break when you restyle your page.
  • { timeout: 10000 } gives the real API up to 10 seconds to respond. Without it, Cypress would time out at its default threshold before the network request finishes.
  • .should('have.length.greaterThan', 0) is an assertion. It checks that at least one recipe card appeared on the page.
  • Save cypress/e2e/recipe-search.cy.js.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file.

ⓧ I'd like to double check the full code

Here is the complete cypress/e2e/recipe-search.cy.js file so far:

describe('Recipe Search', () => {
  beforeEach(() => {
    cy.visit('index.html')
  })

  it('searches for a meal and displays results from the live API', () => {
    cy.get('[data-testid="search-input"]').type('chicken')
    cy.get('[data-testid="search-button"]').click()
    cy.get('[data-testid="recipe-card"]', { timeout: 10000 })
      .should('have.length.greaterThan', 0)
  })
})
Run the test in the Cypress Test Runner

Time to see Cypress in action. You will launch the Test Runner, pick your test file, and watch Cypress drive the browser like a real user.

  • Open your terminal in your project folder and start Cypress by running:
npx cypress open
  • In the Cypress Launchpad, click E2E Testing.
  • Choose a browser (such as Chrome or Edge) and click Start E2E Testing in Chrome.

You should see recipe-search.cy.js listed in the spec list.

Before you click it, think about what you expect to happen. Will Cypress type into the search box? Will you see recipe cards appear? How long do you think it will take?

  • Click recipe-search.cy.js in the spec list to run your test.

The test runs in the browser. On the left, the Command Log shows each command executing in order. On the right, you see your actual recipe search page. Cypress types "chicken" into the search box, clicks the button, and green checkmarks appear as each assertion passes.

What is time-travel debugging?

The Command Log on the left is interactive. Hover over any command (like TYPE or CLICK) to restore the page to the exact state it was in when that command ran. Cypress captures a DOM snapshot at every step.

Click a command to pin its snapshot. This locks the page preview so you can inspect the DOM without other commands changing it. Action commands like .click() even show a red hitbox at the exact coordinates where the click happened.

  • Try it now. Hover over the TYPE command in the Command Log. The page on the right reverts to the moment Cypress typed into the search box.
  • Click the GET command for [data-testid="recipe-card"] to pin the snapshot. You should see the recipe cards highlighted on the right.

Notice the delay?

You probably noticed the test paused for a few seconds while waiting for recipe cards to appear. That is the real TheMealDB API responding over the network. Your test depends on an external server being fast and available.

If TheMealDB were down, your test would fail even though your code is perfectly fine. This fragility is a problem you will solve in the next step.

Test not appearing or failing?

  • If recipe-search.cy.js does not appear in the spec list, confirm the file is inside cypress/e2e/ and ends with .cy.js.
  • If the test times out waiting for recipe cards, check that your index.html is in the project root (the same folder as cypress.config.js). Cypress serves files relative to that folder.
  • If you see an error about data-testid selectors not matching, double check that the selectors in your test exactly match the data-testid attributes in your index.html.

Still stuck? Help me debug why my Cypress test is not running or failing.

Your first E2E test is passing. Cypress can now verify your search flow automatically, every time. But you also felt the delay from hitting a real API. Next up, you will intercept that network request and stub the response so your tests run instantly.

Intercept the API and Stub Responses

Your test from the last step proves the recipe search works. But when you watched it run, you probably noticed a pause while Cypress waited for the real TheMealDB API to respond.

That delay is a real problem. If the API goes down or gets slow, your test fails for reasons that have nothing to do with your code. In this step, you will intercept the network request and replace the response with fake data, making your tests instant and bulletproof.

In this step, get ready to:
  • Create a fixture file with sample meal data.
  • Write a stubbed test that uses fake data instead of hitting the real API.
  • Test edge cases like empty results and network failures.
Create a fixture file with sample meal data

Cypress fixtures are JSON files that live in the cypress/fixtures/ folder. They contain fake data your tests can use instead of calling a real API.

  • Create a new file called meals.json inside your cypress/fixtures/ folder.
  • Paste the following sample meal data into the file:
{
  "meals": [
    {
      "idMeal": "52830",
      "strMeal": "Chicken Handi",
      "strCategory": "Chicken",
      "strArea": "Indian",
      "strInstructions": "Take a large pot or wok...",
      "strMealThumb": "https://www.themealdb.com/images/media/meals/wyxwsp1486979827.jpg"
    },
    {
      "idMeal": "52795",
      "strMeal": "Chicken Couscous",
      "strCategory": "Chicken",
      "strArea": "Moroccan",
      "strInstructions": "Chop the vegetables...",
      "strMealThumb": "https://www.themealdb.com/images/media/meals/qxytrx1511304021.jpg"
    }
  ]
}

What is this fixture data?

This JSON mirrors the exact shape TheMealDB returns when you search for meals. It has two meal objects, each with the fields your app uses: strMeal for the name and strMealThumb for the image.

Because you control this data, your tests always know exactly what to expect. Two meals in the fixture means you can assert exactly two recipe cards appear.

  • Save meals.json.
  • Confirm the file exists by running:
ls cypress/fixtures/

You should see meals.json listed in the output alongside the default example.json that Cypress scaffolded earlier.

Don't see meals.json?

  • Make sure you created the file inside cypress/fixtures/, not in the project root or inside cypress/e2e/.
  • Check the file is named exactly meals.json (all lowercase, no extra spaces).
  • Still stuck? Help me find where my meals.json file ended up.
Write a stubbed search test

Cypress provides a command called cy.intercept() that catches network requests before they leave the browser. Instead of waiting for the real API, you tell Cypress to return your fixture data instantly.

  • In recipe-search.cy.js, find the closing }) of the existing it('searches for a meal...') test.
  • Add the following nested describe block after it, but still inside the outer describe('Recipe Search') block:
  // Group all tests that use fake API responses
  describe('with stubbed API', () => {
    // Intercept search API calls and return fixture data instead
    beforeEach(() => {
      cy.intercept('GET', '**/search.php*', { fixture: 'meals.json' }).as('searchMeals')
    })

    it('displays recipe cards from stubbed data', () => {
      cy.get('[data-testid="search-input"]').type('chicken')
      cy.get('[data-testid="search-button"]').click()
      // Wait for the intercepted request to complete
      cy.wait('@searchMeals')
      // Assert exactly 2 cards appear (matching our fixture)
      cy.get('[data-testid="recipe-card"]').should('have.length', 2)
    })
  })

What does this code do?

  • cy.intercept('GET', '**/search.php*', { fixture: 'meals.json' }) tells Cypress to watch for any GET request whose URL contains search.php. When it spots one, Cypress returns your fixture data instead of calling the real API.
  • .as('searchMeals') gives this intercepted route an alias. You can then reference it with @searchMeals.
  • cy.wait('@searchMeals') pauses the test until the intercepted request finishes. This ensures the recipe cards have loaded before the assertion runs.
  • .should('have.length', 2) asserts exactly two recipe cards appear, matching the two meals in your fixture.

Why is the intercept inside a nested beforeEach?

The outer beforeEach runs cy.visit() for every test. The nested beforeEach registers the intercept only for tests inside the with stubbed API group.

This keeps the live API test untouched. It still calls the real TheMealDB, while every stubbed test gets the fake response automatically.

  • Save recipe-search.cy.js.

Before you run the tests, think about this: do you expect the new stubbed test to be faster or slower than the live API test from the last step?

  • Switch back to the Cypress Test Runner.
  • Click recipe-search.cy.js to run the test file.

You should see two tests run. The live API test pauses for a network round-trip, but the stubbed test finishes almost instantly. In the Command Log on the left, look for the intercepted route. It shows a stubbed badge next to the request instead of a real network response.

Test failing or no stubbed badge?

  • Check that meals.json is inside cypress/fixtures/. Cypress looks in this exact folder when you use { fixture: 'meals.json' }.
  • Make sure the nested describe block is inside the outer describe('Recipe Search') block, not after its closing bracket.
  • Verify the URL pattern **/search.php* has two asterisks at the start and one at the end.
  • My stubbed test is not showing the stubbed badge. Help me debug.
Test edge cases with stubbed responses

A real API can return unexpected things. It might come back with zero results, or it might fail entirely. Your app already handles both cases, but without tests, you have no proof those code paths actually work.

  • In recipe-search.cy.js, find the closing }) of the it('displays recipe cards from stubbed data') test.
  • Add the following test after it, still inside the describe('with stubbed API') block:
    it('shows a message when no recipes are found', () => {
      // Override the default stub with a "no results" response
      cy.intercept('GET', '**/search.php*', { body: { meals: null } }).as('emptySearch')
      cy.get('[data-testid="search-input"]').type('xyznonexistent')
      cy.get('[data-testid="search-button"]').click()
      cy.wait('@emptySearch')
      // Verify the "no results" message appears
      cy.get('[data-testid="no-results"]').should('be.visible')
      // Verify no recipe cards exist in the DOM
      cy.get('[data-testid="recipe-card"]').should('not.exist')
    })

What does this test do?

  • This test calls cy.intercept() again inside the test body. This new intercept overrides the one from beforeEach for this specific test only.
  • { body: { meals: null } } returns an inline response body directly. When TheMealDB finds no matches, it returns meals: null, so this simulates that exact scenario.
  • .should('not.exist') confirms that no recipe cards were added to the DOM at all.
  • Save recipe-search.cy.js.
  • Switch back to the Cypress Test Runner. It should automatically re-run.

You should see three tests pass. The new test shows "No recipes found." on the page when the stubbed API returns no meals.

New test failing?

  • Make sure the response body is { body: { meals: null } } with null, not the string "null" or an empty array [].
  • Verify the new test is inside the describe('with stubbed API') block, not outside it.
  • Help me debug why my empty results test is failing.

Now add one more test to cover the worst case: the API fails completely.

  • In recipe-search.cy.js, add the following test after the it('shows a message when no recipes are found') test, still inside the describe('with stubbed API') block:
    it('shows an error message when the API fails', () => {
      // Simulate a complete network failure
      cy.intercept('GET', '**/search.php*', { forceNetworkError: true }).as('networkError')
      cy.get('[data-testid="search-input"]').type('chicken')
      cy.get('[data-testid="search-button"]').click()
      cy.wait('@networkError')
      // Verify the error message appears
      cy.get('[data-testid="error-message"]').should('be.visible')
    })

What does forceNetworkError do?

{ forceNetworkError: true } tells Cypress to simulate a total network failure. The browser acts as if the server is unreachable. Your app's catch block kicks in and shows the error message.

Without this test, you would have no automated proof that your error handling works. You would need to manually disconnect your internet and try a search to verify it.

  • Save recipe-search.cy.js.

Before you check the Test Runner, predict: how many tests should pass now? Which ones will be fast and which will have a visible delay?

  • Switch back to the Cypress Test Runner and let the suite re-run.

All four tests should pass. The first test (live API) still has a noticeable delay. The three stubbed tests finish almost instantly, each showing a stubbed badge in the Command Log.

Network error test failing?

  • Make sure you wrote forceNetworkError: true (not networkError: true or forceError: true). The property name must be exact.
  • Click on the failing test in the Command Log. Use time-travel debugging to see the app's state at the point of failure.
  • Help me figure out why my network error test is not passing.

✔️ Awesome, I've got everything!

Great. Make sure you have saved both meals.json and recipe-search.cy.js.

ⓧ I'd like to double check the full code

Your cypress/fixtures/meals.json should look exactly like this:

{
  "meals": [
    {
      "idMeal": "52830",
      "strMeal": "Chicken Handi",
      "strCategory": "Chicken",
      "strArea": "Indian",
      "strInstructions": "Take a large pot or wok...",
      "strMealThumb": "https://www.themealdb.com/images/media/meals/wyxwsp1486979827.jpg"
    },
    {
      "idMeal": "52795",
      "strMeal": "Chicken Couscous",
      "strCategory": "Chicken",
      "strArea": "Moroccan",
      "strInstructions": "Chop the vegetables...",
      "strMealThumb": "https://www.themealdb.com/images/media/meals/qxytrx1511304021.jpg"
    }
  ]
}

Your recipe-search.cy.js is longer than 30 lines, so scroll through your file and confirm these sections are present in order:

  • The outer describe('Recipe Search') with a beforeEach that calls cy.visit('index.html').
  • The live API test: it('searches for a meal and displays results from the live API').
  • The nested describe('with stubbed API') with its own beforeEach calling cy.intercept().
  • Three tests inside the nested block: stubbed data, no results, and network error.

Your test suite now covers the happy path, empty results, and a total API failure. All three edge-case tests run instantly because they never touch the network. Next up, you will clean up the repeated setup code and organize your tests for long-term maintainability.

Organize Tests with beforeEach and Custom Assertions

Your test suite covers the happy path, empty results, and network errors. All four tests pass in the Cypress Test Runner, and the stubbed tests run instantly.

As a test suite grows, understanding how shared setup code is organized becomes critical. Your spec file already uses beforeEach hooks and nested describe blocks to keep things clean. In this step, you will walk through why that pattern matters and add a more precise assertion that verifies exact recipe content.

In this step, get ready to:
  • Understand how beforeEach hooks and nested describe blocks organize your test suite.
  • Add a chained assertion that verifies the first recipe card displays the correct meal name from your fixture data.
Understand the beforeEach pattern

Every test in your spec file needs to visit the search page before it can do anything. Without a shared setup hook, every single it() block would have to start with its own cy.visit('index.html') call.

  • Switch back to recipe-search.cy.js in VS Code.
  • Look at the top of the file. Find the outer beforeEach block:
describe('Recipe Search', () => {
  beforeEach(() => {
    cy.visit('index.html')
  })

What does this beforeEach do?

Cypress runs the outer beforeEach before every single test in the describe block. That means all four of your tests start by visiting index.html automatically. No test has to repeat that call.

  • Now look further down. Find the nested describe('with stubbed API') block and its own beforeEach:
  describe('with stubbed API', () => {
    beforeEach(() => {
      cy.intercept('GET', '**/search.php*', { fixture: 'meals.json' }).as('searchMeals')
    })

How do nested beforeEach hooks work?

Nested beforeEach hooks compose. For any test inside describe('with stubbed API'), Cypress runs the outer beforeEach first (visiting the page), then the inner beforeEach (setting up the intercept).

The live-API test sits outside the nested block, so it only gets the visit. It never gets the intercept. This separation means you can add dozens of stubbed tests without touching the live-API test.

Add a custom assertion to verify recipe content

Your stubbed happy-path test checks that two recipe cards appear, but it does not verify what those cards actually say. A card count alone cannot catch a bug where the cards render with the wrong meal name.

  • In recipe-search.cy.js, find the displays recipe cards from stubbed data test.
  • Find this line:
      cy.get('[data-testid="recipe-card"]').should('have.length', 2)
  • Add the following lines directly below it, before the closing }) of the test:
      // Verify the first card shows the correct meal name
      cy.get('[data-testid="recipe-card"]').first()
        .find('[data-testid="recipe-title"]')
        .should('have.text', 'Chicken Handi')

What does this assertion do?

  • .first() grabs the first recipe card from the results.
  • .find('[data-testid="recipe-title"]') drills into that card to find the title element nested inside it.
  • .should('have.text', 'Chicken Handi') checks the exact text content. This value comes from your meals.json fixture, so the test is fully deterministic.
  • Save recipe-search.cy.js.

Not sure where to add the lines?

The new lines go inside the it('displays recipe cards from stubbed data') test block, right after the .should('have.length', 2) line. Make sure they are still inside the closing }) of that test.

help me find where to add the custom assertion in my test file

✔️ Awesome, I've got everything!

Great. Double check you have saved recipe-search.cy.js.

ⓧ I'd like to double check the full code

Your recipe-search.cy.js file should look like this. Scroll through and confirm each section is present in order:

  • The outer describe('Recipe Search') with a beforeEach that calls cy.visit('index.html').
  • The live-API test: it('searches for a meal and displays results from the live API').
  • The nested describe('with stubbed API') with its own beforeEach that sets up cy.intercept().
  • The stubbed happy-path test with the new .first().find().should('have.text', 'Chicken Handi') assertion at the end.
  • The empty-results test and the network-error test.
Run the full test suite

Before you run the tests, think about what you expect to see. Will all four tests still pass? Will the new assertion change anything in the Command Log?

  • Return to the Cypress Test Runner.
  • Click recipe-search.cy.js in the spec list to re-run the tests.

All four tests pass with green checkmarks. In the Command Log, you can see the test groupings: the live-API test sits outside the with stubbed API block, and the three stubbed tests are grouped together inside it.

  • Click the ASSERT line in the Command Log for the displays recipe cards from stubbed data test. You should see the have.text assertion confirming Chicken Handi.

Why does the grouping matter?

The describe groupings are not just cosmetic. Each nested block scopes its own beforeEach hooks, so adding a new stubbed test later means zero setup code. Just write the it() block and the visit plus intercept happen automatically.

Seeing a test failure?

  • If the have.text assertion fails, check that meals.json has Chicken Handi as the strMeal value in the first meal object.
  • Make sure the new lines are inside the it('displays recipe cards from stubbed data') test, not accidentally placed outside the closing }).
  • Check for mismatched quotes or missing dots between .first(), .find(), and .should(). Each method chains onto the previous one with a dot.

help me debug why my Cypress assertion is failing

Your test suite is clean, organized, and thorough. Each test runs with just the setup it needs, and the custom assertion proves your app renders the right content. Next up, you will extend the app with a recipe detail view and write tests for multi-step navigation.

Secret mission

Add a Recipe Detail View and Test It

Your test suite covers search, empty results, and errors. But clicking a recipe card opens a detail view with full cooking instructions. Can you write a test that navigates this multi-step flow, stubs a second API endpoint, and verifies the back button works?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files for continued practice, or delete them entirely.

Resources you used:

  • Your project folder on the Desktop, containing index.html, Cypress test files, fixture files, configuration files, and the node_modules folder.

Keep everything running

No action needed. Choose this if you want to keep practicing with Cypress or extending your test suite.

  • Your test files, fixtures, and app code stay right where they are on your Desktop.
  • Run npx cypress open from your project folder anytime to reopen the Cypress Test Runner.

Pause - I'll come back to this later

There are no running processes or cloud services to pause. Everything in this project is local files on your machine.

  • Your project folder stays on your Desktop until you decide to delete it.
  • Nothing is running in the background and nothing will cost you while you are away.

Delete - I don't want to use this again

Remove all project files by deleting the project folder from your Desktop. This is the only cleanup needed.

  • Open Finder (macOS) or File Explorer (Windows) and navigate to your Desktop.
  • Right-click the project folder you created in Step 1.
  • Select Move to Trash (macOS) or Delete (Windows).

This removes your index.html, Cypress configuration, test files, fixtures, and the entire node_modules folder. No other cleanup is needed.

Nice Work!

Nice Work!

Nice work! You just built a recipe search app powered by TheMealDB and proved it works with a full Cypress test suite that covers the happy path, edge cases, and error handling.

You've learned how to:

  • Built a recipe search app and wrote your first E2E test that automates searching, clicking, and asserting on real page elements in the Cypress Test Runner.
  • Used cy.intercept() to stub API responses with fixtures, making your tests fast, deterministic, and completely independent of the network.
  • Tested edge cases like empty results and server failures by controlling what the API returns, and organized your suite with describe blocks and beforeEach hooks.
  • Completed an optional Secret Mission to push your testing skills further.

Ready to quiz yourself?