Bhavik Bamania

Programming

Detective Mode: Finding Out Why a Cypress Test Failed

Bhavik Bamania

8 min read

Share On:

Copy linkShare on FacebookShare on LinkedInShare on ThreadsShare on X
Detective Mode_ Debugging Test Failures
Detective Mode: Finding Out Why a Cypress Test Failed

Debug Cypress tests like a detective: read errors, time-travel the Command Log, use cy.pause() and .debug(), and capture screenshots and videos.

A red test is a crime scene. Something went wrong, and the test is the only witness. Most beginners stare at the error for a minute, change something at random, and hope. Detectives don't hope. They collect clues.

The good news is that Cypress leaves more clues behind than almost any testing tool. It records every step, keeps a snapshot of the page at each moment, and can freeze a test halfway through so you can look around. Today we'll learn to use all of it, by planting a bug of our own and catching it.

Catching Up? Here's the Road So Far

This is part 9 of the Cypress Tutorial Series. If you're new, these come first:

  1. A Beginner's Guide to Cypress: End-to-End Testing Made Easy
  2. Understanding Cypress Basics: Core Features and Syntax Explained
  3. Cypress Stubs, Spies, and Clocks: Take Control of Your Tests
  4. Mocking Network Requests in Cypress with cy.intercept()
  5. Why Your Cypress Tests Break: Selectors and Test Design Best Practices
  6. Cypress Custom Commands: Write Less, Test More
  7. Stop Logging In a Hundred Times: Cypress Sessions, Tokens, and Protected Pages
  8. One Piece at a Time: Component Testing with Cypress

Here's our case file for today:

  • Read the error properly, before touching any code.
  • Rewind time with the Command Log.
  • Freeze the test with cy.pause() and .debug().
  • Collect evidence with screenshots and videos for runs you can't watch.

Plant a Bug, Then Read the Note It Leaves

To practice, we need a crime. Create cypress/e2e/debugging.cy.ts with a test that looks fine at first glance:

describe('Debugging Playground', () => {
  it('should show an error for a wrong password', () => {
    cy.visit('/');
    cy.mockLogin('login-invalid.json', 401);
    cy.login('testuser', 'wrongpassword');

    cy.getByCy('error-messsage').should('have.text', 'Invalid username or password');
  });
});

Run it in the runner. After about four seconds, it turns red.

Step 1: Read the Whole Error

The error box is not decoration. It's a note from the witness, and it usually tells you most of the story. Ours says something like this:

Timed out retrying after 4000ms: Expected to find element:
[data-cy="error-messsage"], but never found it.

Read it slowly, one piece at a time:

  • Timed out retrying after 4000ms. Cypress didn't give up straight away. It kept looking for four seconds (remember retry-ability from part 5?).
  • Expected to find element. The problem is finding the element, not its text. So the assertion never even got a chance to run.
  • [data-cy="error-messsage"] is the exact selector Cypress searched for. Look closely at it.

Under the message, Cypress shows the line of your test that failed, with a small arrow pointing at it. Next to it are a few handy buttons. Print to console dumps the full details into DevTools, and View stack trace shows how the code got there.

Maybe you've already spotted the typo. If not, don't worry. The next clue makes it obvious.

Rewind the Tape

The Command Log is the list of steps on the left side of the runner. Every visit, type, click, request, and assertion gets its own row, in order. Think of it as the security camera footage of your test.

Step 1: Hover to Time-Travel

Move your mouse slowly down the Command Log. As you hover over each row, the app preview on the right changes to show the page exactly as it was at that moment. This is time-travel, and it's the most useful debugging trick Cypress has.

Hover over the click row from cy.login(). You'll see the Login button highlighted at the moment it was clicked.

Step 2: Pin a Moment and Look Closer

Click a row instead of hovering, and Cypress pins that snapshot so it stays on screen. For actions like click and type, you'll also see Before and After buttons on the preview, so you can compare the page just before and just after the action.

Now click the last row, the red get that failed. The preview shows the page at the moment Cypress gave up, and there it is: the error message, sitting right on the page. So the element exists. Cypress just couldn't find it with our selector.

Right-click that message in the preview and choose Inspect. DevTools opens on the element, and its attribute reads data-cy="error-message". One extra s in our test. Case closed.

Fix the typo to error-message, save, and watch the test turn green.

Step 3: Get the Full Story in DevTools

With DevTools open, click any row in the Command Log. Cypress prints everything it knows about that step into the Console: the selector, the elements it found, how many there were, and what it yielded. For a request row, you'll see the full request and response. When the log feels too short, the console fills in the gaps.

Tip: A row with a number badge next to it, like get [data-cy=...] 2, means the selector matched more than one element. That's a classic cause of "it clicked the wrong thing" bugs.

Freeze the Scene

Time-travel shows you snapshots. But sometimes you want the test to stop, with the real, live page in front of you, so you can click around and poke at things yourself. Cypress gives you a few ways to do that.

cy.pause(): Stop and Step

Drop cy.pause() anywhere in a test:

  it('should show an error for a wrong password', () => {
    cy.visit('/');
    cy.mockLogin('login-invalid.json', 401);
    cy.pause(); // Everything stops here
    cy.login('testuser', 'wrongpassword');

    cy.getByCy('error-message').should('have.text', 'Invalid username or password');
  });

Run it, and the test freezes right after the mock is set up. The page is live, so you can type into it, open DevTools, or check local storage. At the top of the Command Log, two buttons appear:

  • Resume (the play button) runs the rest of the test normally.
  • Next runs just one command, then pauses again. Use it to walk through cy.login() one keystroke at a time.

.debug(): Stop and Inspect One Thing

.debug() hangs off a command and pauses right after it, handing you whatever that command found:

cy.getByCy('error-message').debug();

Open DevTools before running the test, because .debug() only pauses when DevTools is open. When it stops, the console shows the element as subject. Type subject into the console and press Enter to explore it.

debugger: For When You're Inside .then()

If you're already working with a value inside .then(), the classic JavaScript debugger keyword works too:

cy.getByCy('login-button').then(($button) => {
  debugger; // DevTools stops here with $button ready to inspect
});

Note: Never put debugger directly in the test body, outside .then(). Cypress commands are queued first and run later (remember the cy.fixture() lesson from part 6?), so the debugger would stop before anything has actually happened.

cy.log(): Leave Yourself a Note

Sometimes you don't need to stop at all. You just want a message in the Command Log:

cy.log('Mock is ready, now logging in');

It shows up in the log in the right order, between the real commands. That makes long tests much easier to follow.

Evidence From Runs You Didn't Watch

Everything so far needs you sitting in front of the runner. But many tests run headless, with no window at all, on your terminal or on a build server. When one of those fails at 2 a.m., you need evidence that waited for you. That's what screenshots and videos are for.

Step 1: Run Headless

npx cypress run --spec cypress/e2e/debugging.cy.ts

Put the typo back in for a moment (error-messsage) and run this. Keep the dev server running in another terminal, as always. The test fails in the terminal, with no runner to look at.

Step 2: Find the Automatic Screenshot

Open the cypress/screenshots folder. Cypress has already saved a picture of the page at the exact moment the test failed. You didn't ask for it. In headless runs, Cypress does this for every failure by default.

You can also take one on purpose, at any step:

cy.screenshot('before-login');

Step 3: Turn On Video

Videos are switched off by default, because they take time and disk space. Turn them on in cypress.config.ts:

export default defineConfig({
  video: true,
  e2e: {
    baseUrl: 'http://localhost:5173',
  },
  // ...the rest of your config stays the same
});

Run the headless command again, then open cypress/videos. You'll find a recording of the whole spec, from the first command to the failure. A screenshot shows you the scene. A video shows you how it got there.

Now fix the typo again, for good this time.

Tip: Add cypress/screenshots and cypress/videos to your .gitignore. They're evidence for today's case, not something to keep in Git forever.

Your Detective's Toolkit

When a test fails, reach for the tools roughly in this order:

Clue you need

Tool

Works headless?

What went wrong, in one sentence

The error message

Yes, in the terminal

What the page looked like at each step

Time-travel in the Command Log

No

Every detail about one step

Click the row, read the DevTools console

No

A live page to poke at

cy.pause()

No

One element, up close

.debug() or debugger inside .then()

No

A marker between steps

cy.log()

Yes, it appears in the video

Proof from an unwatched run

Screenshots and videos

Yes

Habits That Waste a Detective's Time

  • Changing code before reading the error. Read the whole message first. It usually names the selector, the command, and how long Cypress waited.
  • Using console.log() to print a Cypress value. Like debugger, it runs before the commands do. Use cy.log(), or log inside .then().
  • Forgetting to remove cy.pause(). It's harmless in headless runs, but it will stop you every time in the runner. Clean up once the case is solved.
  • Reaching for a longer timeout first. If an element is never found, a longer wait only makes the failure slower. Time-travel first, and check whether the element ever appeared.

Case Closed

A failing test is no longer a mystery to stare at. It's a scene to investigate. We read the error like a witness statement, rewound the test frame by frame in the Command Log, froze it with cy.pause() and .debug(), and collected screenshots and videos from runs nobody was watching.

The next step is to let those headless runs happen on their own, every time someone pushes code. In the next article, we'll put our tests on autopilot with Cypress in CI/CD, using GitHub Actions, parallel runs, and retries. Stay tuned, and happy testing!

CypressTypeScriptEnd-to-End TestingDebuggingCypress TutorialTest Automation

Share On:

Copy linkShare on FacebookShare on LinkedInShare on ThreadsShare on X

Written by Bhavik Bamania

Bhavik Bamania is a software engineer and writer exploring frontend engineering, web architecture, technology, history, psychology, astrology, and culture.

This site uses cookies for basic analytics (page views, no ads). You can accept or decline.