Debugging a Mysterious Cypress Failure: It Was the return All Along

Jie HanJie Han
10 min read

This is another "post-mortem" blog after debugging a mysterious automation testing issue. TL;DR: Our test, which validates database entries, didn’t fail even when the backend service responsible for writing those entries was offline. The culprit? Cypress skipped the validation code entirely because the command wasn’t properly queued—thanks to the missing return keyword and misuse of chainables. This post is the final wrap-up of what we learned. Blog credits go to GPT-4.1 and Claude Sonnet 4.0 for helping organize the thoughts, and credits for the painful lesson go to myself, and readers of this blog lol.


Cypress's command queue system is fundamentally different from standard JavaScript async/await patterns. Understanding why the return keyword is critical and how command queue execution works with and without proper returns can make the difference between reliable tests and flaky, unpredictable ones.


The Cypress Command Queue: A Different Beast

Cypress doesn't execute commands immediately. Instead, it builds a queue of commands that execute sequentially, with automatic retries and intelligent waiting. This queue-based execution model is what makes Cypress powerful, but it also requires developers to understand how to properly integrate custom logic into this flow.


Chainables vs Promises: Understanding the Fundamental Difference

Many developers new to Cypress assume that Cypress commands return Promises because they have .then() methods. This is a critical misconception that leads to incorrect usage patterns.

What Are Chainables?

Cypress chainables are not Promises. They're special objects that:

  • Get added to Cypress's internal command queue
  • Execute sequentially, one after another
  • Have built-in retry logic and intelligent waiting
  • Can be chained using .then(), .should(), .and(), etc.
  • Are managed entirely by Cypress's test runner

What Are Promises?

JavaScript Promises are:

  • Native JavaScript objects for handling async operations
  • Execute immediately when created
  • Resolve or reject once, then remain settled
  • Work with async/await syntax
  • Managed by the JavaScript event loop

Key Differences in Practice

Promise behavior:

// This executes immediately
const promise = fetch('/api/data');
promise.then(response => console.log('Got response'));
console.log('This logs first, before the response');

Chainable behavior:

// This gets queued, doesn't execute immediately
const chainable = cy.request('/api/data');
chainable.then(response => console.log('Got response'));
cy.log('This logs first in the queue');
// Output order: "This logs first in the queue", then "Got response"

The .then() Method: Similar but Different

Both Promises and chainables have .then() methods, but they behave differently:

  • Promise .then(): Executes when the Promise resolves
  • Chainable .then(): Gets added to the command queue and executes in sequence

This is why you can't use async/await with Cypress commands - they're not Promises that can be awaited.


The Critical Importance of the "return" Keyword

Without "return": Breaking the Chain

When you forget to return a Cypress chainable, you're essentially telling Cypress: "Don't wait for this operation, just move on." This breaks the command queue and can lead to race conditions.

❌ Bad - Without Return:

Cypress.Commands.add('badValidation', (expectedData, clientId, dbName) => {
    // This logging happens, but Cypress doesn't wait for the DB query
    cy.task('log', { message: 'Starting validation...' });

    // Cypress immediately moves to the next command in the test
    // The DB query might still be running when the test continues
    cy.task('queryDataMySql', {
        sql: `SELECT * FROM ${dbName}.all_data WHERE client_id = '${clientId}'`
    }).then((result) => {
        // This might execute AFTER the test has already finished!
        expect(result.resultData.length).to.be.greaterThan(0);
    });
    // No return = Cypress doesn't wait = broken chain
});

✅ Good - With Return:

Cypress.Commands.add('goodValidation', (expectedData, clientId, dbName) => {
    cy.task('log', { message: 'Starting validation...' });

    // The "return" tells Cypress: "Wait for this entire chain to complete"
    return cy.task('queryDataMySql', {
        sql: `SELECT * FROM ${dbName}.all_data WHERE client_id = '${clientId}'`
    }).then((result) => {
        expect(result.resultData.length).to.be.greaterThan(0);
        // Must return a chainable here too, for further chaining
        return cy.wrap(result);
    });
});

Command Queue Execution: With vs Without Return

Scenario 1: Without Return - Race Conditions

it('demonstrates broken chaining', () => {
    cy.visit('/survey');

    // This command doesn't return properly
    cy.badValidation(expectedData, clientId, dbName);

    // This might run BEFORE the validation completes!
    cy.get('#submit-button').click();

    // Test might pass/fail unpredictably
});

Queue execution:

  1. cy.visit('/survey') - executes and completes
  2. cy.badValidation() - starts but Cypress doesn't wait
  3. cy.get('#submit-button').click() - executes immediately
  4. Database query from step 2 might still be running

Scenario 2: With Return - Proper Sequencing

it('demonstrates proper chaining', () => {
    cy.visit('/survey');

    // This command returns properly
    cy.goodValidation(expectedData, clientId, dbName);

    // This waits for validation to complete
    cy.get('#submit-button').click();

    // Reliable, predictable execution
});

Queue execution:

  1. cy.visit('/survey') - executes and completes
  2. cy.goodValidation() - executes completely (including DB query and assertions)
  3. cy.get('#submit-button').click() - executes after validation is done
  4. Predictable, reliable test

Why You Must Return Chainables in .then() Blocks

Inside .then() blocks, you have two choices:

  1. Return a Cypress chainable (like cy.wrap(), cy.task(), etc.)
  2. Return a primitive value (string, number, boolean, object)

If you're doing async work inside .then(), you must return a Cypress chainable to maintain the queue.

The Problem with Primitive Returns

❌ This breaks chaining for async operations:

return cy.task('queryDataMySql', { sql: '...' }).then((result) => {
    let passed = true;
    try {
        // Async assertions happen here
        expect(result.resultData.length).to.be.greaterThan(0);
    } catch (err) {
        cy.task('log', { message: `Failed: ${err.message}` }); // This is async!
        passed = false;
    }
    return passed; // ❌ Primitive return while async work is happening
});

✅ This maintains the chain:

return cy.task('queryDataMySql', { sql: '...' }).then((result) => {
    let passed = true;
    try {
        expect(result.resultData.length).to.be.greaterThan(0);
    } catch (err) {
        cy.task('log', { message: `Failed: ${err.message}` });
        passed = false;
    }
    return cy.wrap(passed); // ✅ Chainable return ensures proper queuing
});

When and Why to Use cy.wrap(): Bringing Values into the Queue

One of the most important but misunderstood concepts in Cypress is knowing when to use cy.wrap(). This function is your bridge between the synchronous JavaScript world and Cypress's asynchronous command queue.

What Does cy.wrap() Do?

cy.wrap() takes any JavaScript value (primitive, object, array, etc.) and wraps it in a Cypress chainable. This allows you to:

  • Bring non-Cypress values into the command queue
  • Enable chaining with .then(), .should(), etc.
  • Ensure proper sequencing in your tests

When You MUST Use cy.wrap()

1. Returning Primitive Values from .then() Blocks with Async Work

❌ This breaks the chain:

Cypress.Commands.add('validateData', (expectedFields, clientId, dbName) => {
    return cy.task('queryDataMySql', { sql: '...' }).then((result) => {
        let passed = true;
        try {
            expect(result.resultData.length).to.be.greaterThan(0);
        } catch (err) {
            // This cy.task() is async, but we're returning a primitive!
            cy.task('log', { message: `Failed: ${err.message}` });
            passed = false;
        }
        return passed; // ❌ Primitive return while async work is happening
    });
});

✅ This maintains the chain:

Cypress.Commands.add('validateData', (expectedFields, clientId, dbName) => {
    return cy.task('queryDataMySql', { sql: '...' }).then((result) => {
        let passed = true;
        try {
            expect(result.resultData.length).to.be.greaterThan(0);
        } catch (err) {
            cy.task('log', { message: `Failed: ${err.message}` });
            passed = false;
        }
        return cy.wrap(passed); // ✅ Wrapped primitive maintains the queue
    });
});

2. Working with Regular JavaScript Values

❌ This doesn't work for chaining:

Cypress.Commands.add('processArray', (items) => {
    const processed = items.map(item => item.toUpperCase());
    return processed; // ❌ Not chainable
});

// Usage fails:
cy.processArray(['a', 'b', 'c']).should('have.length', 3); // Error!

✅ This enables chaining:

Cypress.Commands.add('processArray', (items) => {
    const processed = items.map(item => item.toUpperCase());
    return cy.wrap(processed); // ✅ Now it's chainable
});

// Usage works:
cy.processArray(['a', 'b', 'c']).should('have.length', 3); // Success!

3. Conditional Logic with Chainables

Cypress.Commands.add('conditionalValidation', (shouldValidate, data) => {
    if (shouldValidate) {
        return cy.task('validateData', data);
    } else {
        // Must wrap to return a chainable consistently
        return cy.wrap('skipped');
    }
});

When You DON'T Need cy.wrap()

1. Returning Cypress Commands Directly

Cypress.Commands.add('getElement', (selector) => {
    return cy.get(selector); // ✅ Already a chainable
});

2. Simple Primitive Returns (No Async Work)

Cypress.Commands.add('calculateTotal', (items) => {
    return cy.wrap(items).then((itemList) => {
        const total = itemList.reduce((sum, item) => sum + item.price, 0);
        return total; // ✅ No async work, primitive is fine
    });
});

Quick Decision Guide: Do I Need cy.wrap()?

Ask yourself:

  1. Am I returning from a custom command? → Probably yes
  2. Am I doing async work (cy.task, cy.request, etc.) before returning a primitive? → Definitely yes
  3. Do I want to chain Cypress methods on my return value? → Yes
  4. Am I returning a Cypress command directly? → No, it's already chainable
  5. Am I just passing through a value with no async work? → Maybe not needed

Real-World Example: The Evolution of a Command

Let's trace through how the validateNADataRecordMatch command evolved to understand the importance of proper returns:

Version 1: Broken Chain (Common Mistake)

Cypress.Commands.add('validateNADataRecordMatch', (expectedFields, clientId, dbName) => {
    cy.task('log', { message: `Starting validation for ${clientId}` });

    cy.task('queryDataMySql', {
        sql: `SELECT * FROM ${dbName}.na_data WHERE client_id = '${clientId}'`
    }).then((result) => {
        // Validation logic here...
        expect(result.resultData.length).to.be.greaterThan(0);
    });
    // ❌ No return = Cypress doesn't wait for the DB query and validation
});

What happens: The test continues immediately after calling this command, potentially before the database query completes.

Version 2: Fixed Chain with Proper Returns

Cypress.Commands.add('validateNADataRecordMatch', (expectedFields, clientId, dbName) => {
    cy.task('log', { message: `Starting validation for ${clientId}` });

    return cy.task('queryDataMySql', {
        sql: `SELECT * FROM ${dbName}.na_data WHERE client_id = '${clientId}'`
    }).then((result) => {
        let passed = true;
        try {
            expect(result.resultData.length).to.be.greaterThan(0);
            const row = result.resultData[0];
            Object.entries(expectedFields).forEach(([key, expectedVal]) => {
                if (expectedVal === null) {
                    expect(row[key]).to.be.null;
                } else if (expectedVal === '_ANY_') {
                    expect(row[key]).to.not.be.oneOf([null, undefined, '']);
                } else {
                    expect(row[key]).to.eq(expectedVal);
                }
            });
        } catch (err) {
            // This cy.task() call is async!
            cy.task('log', { message: `❌ Validation failed: ${err.message}` });
            passed = false;
        }

        // Without cy.wrap(), Cypress won't wait for the logging task above
        return cy.wrap(passed); // ✅ Essential for proper sequencing
    });
});

Key improvements:

  1. Returns the main chain - Cypress waits for the entire operation
  2. Uses cy.wrap(passed) - Maintains chainability even with async error logging
  3. Proper error handling - Logs errors without breaking the chain

Why async/await Doesn't Work with Cypress

Understanding why you can't use async/await with Cypress commands reinforces the importance of proper returns:

❌ Don't do this:

Cypress.Commands.add('brokenAsyncCommand', async (clientId) => {
    await cy.visit('/page'); // ❌ This doesn't work as expected
    const result = await cy.task('queryDataMySql', { sql: '...' }); // ❌ Breaks the queue
    return result;
});

✅ Do this instead:

Cypress.Commands.add('properCommand', (clientId) => {
    return cy.visit('/page').then(() => {
        return cy.task('queryDataMySql', { sql: '...' });
    });
});

Why this matters:

  1. Cypress commands don't resolve immediately - they're queued
  2. await expects a Promise - Cypress commands return chainables
  3. Mixing async/await breaks the queue - Cypress loses control of execution order

Common Mistakes and How to Fix Them

Mistake 1: Forgetting cy.wrap() in Error Handling

// ❌ Common mistake - async logging but primitive return
.then((result) => {
    if (result.error) {
        cy.task('log', { message: 'Error occurred' }); // Async!
        return false; // Primitive return = broken chain
    }
    return result.data; // Also primitive = broken chain
});

// ✅ Fixed with cy.wrap()
.then((result) => {
    if (result.error) {
        cy.task('log', { message: 'Error occurred' }); // Async!
        return cy.wrap(false); // Chainable return = maintained chain
    }
    return cy.wrap(result.data); // Chainable return = maintained chain
});

Mistake 2: Treating Chainables Like Promises

// ❌ Wrong approach
Cypress.Commands.add('badExample', async (url) => {
    const response = await cy.request(url); // Doesn't work!
    return response.body;
});

// ✅ Correct approach
Cypress.Commands.add('goodExample', (url) => {
    return cy.request(url).then((response) => {
        return cy.wrap(response.body);
    });
});

Key Principles for Reliable Cypress Commands

  1. Always return the outermost Cypress command in custom commands
  2. Return cy.wrap() for primitive values when async work is involved
  3. Use .then() instead of async/await for accessing command results
  4. Let Cypress manage the queue - don't try to control timing manually
  5. Chain everything - keep all async work in the Cypress command queue

Debugging Tip: Identifying Chain Breaks

If your tests are flaky or behaving unpredictably, look for these patterns:

  • Custom commands without return statements
  • .then() blocks that return primitives while doing async work
  • Mixing Cypress commands with native Promises or async/await
  • Logic that depends on timing rather than Cypress's queue

Conclusion

The return keyword in Cypress isn't just good practice—it's essential for reliable tests. It ensures that:

  • Commands execute in the correct order
  • The test waits for async operations to complete
  • Assertions run at the right time
  • Commands can be properly chained together

Master the command queue, respect the chainables, and always return what Cypress needs to maintain control. Your tests will be more reliable, predictable, and maintainable.

0
Subscribe to my newsletter

Read articles from Jie Han directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Jie Han
Jie Han