Reading:
Writing custom Cypress plug-ins that solve common software testing problems
See you at TestBash Brighton 2025. image
Proud TestBash sponsor! Visit the Test Exchange to meet our expert software testing team.

Writing custom Cypress plug-ins that solve common software testing problems

Turn flaky test frustrations into reliable, reusable Cypress plug-ins that strengthen your test automation and contribute towards the quality community

Writing custom Cypress plug-ins that solve common software testing problems image

Why does the software testing community need custom plug-ins?

Flaky tests are a silent threat to any Agile development process. They are the tests that pass, then fail, then pass again on the same code, eroding the team's trust in the CI / CD pipeline and causing countless hours of wasted debugging. I once found myself battling a test suite plagued by this very issue, a struggle that ultimately led me on a rewarding journey: creating a custom Cypress plug-in to solve the problem for good. 

This is a practical guide based on that experience. It shares the technical details, key decisions, and lessons learned in hopes that it can help you transform a testing challenge into a valuable community contribution.

Why we started building our own plug-ins

Cypress is a powerful tool for end-to-end testing, but its true potential is unlocked through plug-ins. Plug-ins extend the core functionality of Cypress, allowing you to perform actions beyond what’s possible in the browser, such as interacting with the file system, connecting to a database, or triggering external processes.

Stop waiting around: Using state-aware synchronisation instead of wait statements 

The application under test was a modern, single-page application built with a heavy emphasis on dynamic user experience. It included data grids populated with GraphQL responses, animations triggered by user actions, and spinners. 

The resulting asynchronicity was the root cause of the flakiness in test results. Cypress, executing commands at lightning speed, would often try to interact with an element that hadn't finished loading or was temporarily obscured by a spinner overlay.

Our initial, knee-jerk solution was to use cy.wait(500). This is a significant anti-pattern because it forces a choice between two bad options: either you set a long wait time and slow down the entire test suite, or you set a short wait time that fails under variable network or server load. The tests were not just slow; they were brittle. The core problem wasn't a need for arbitrary pauses, but a need for state-aware synchronization. The tests needed to wait intelligently for the application to be ready, just as a human user would.

Moving from isolated commands to a single plug-in

The first step toward a robust solution was creating project-specific custom commands. Inside the support/commands.js file, a simple command can be added like this:

JavaScript

// In cypress/support/commands.js
Cypress.Commands.add('waitForSpinnerToDisappear', () => {
  cy.get('.loading-spinner', { timeout: 10000 }).should('not.exist');
});

This worked well for isolated cases. However, the logic became repetitive across different projects. More importantly, custom commands are limited to the browser context. They can't easily interact with the Node.js backend of Cypress, which is essential for more complex tasks like managing files or logging server-side events.

During a pair-programming session, I highlighted this limitation and suggested the next logical step: "This logic is reusable and could be more powerful. It should be a plug-in." A plug-in can not only contain custom commands but it can also tap into the Cypress Node process using cy.task(). This opens up a new world of possibilities, allowing the test to perform actions that a simple browser-side command cannot. We decided to refactor the custom commands into a formal, distributable plug-in.

Eight valuable lessons from our journey

The development process was a continuous learning experience. To make these takeaways more concrete for the reader, here’s how each lesson applied directly to the creation of the intelligent-wait plug-in.

Lesson 1: Define a precise scope

The plug-in aimed to provide a rich set of commands for intelligent synchronization. It wouldn't be a general-purpose utility library. Instead, it would focus on commands like cy.waitUntilStable(), which could cause the runner to pause until several conditions were true: no active network requests, visible spinners, or DOM mutations.

Lesson 2: Build an extensible architecture

No plug-in can anticipate every use case. Therefore, the core commands were designed to be extensible. The main waitUntilStable command was built to accept an optional callback function, allowing testers to add their own project-specific checks to the wait condition.

JavaScript

// Example of an extensible command
cy.waitUntilStable(() => {
  // Custom assertion to run after the page is stable
  cy.get('#my-special-element').should('be.visible');
});

Lesson 3: Craft an intuitive and debuggable API

A key to developer adoption of a plug-in is user experience. So we chose self-explanatory command names for greater ease of use. Also, the plug-in used the Cypress.log() API extensively to provide rich, detailed output in the Cypress command log. This allowed for the creation of custom, collapsible log entries that showed exactly what the plug-in was waiting for, the timeout settings, and the outcome. This made the task of debugging flaky tests far easier.

Lesson 4: Implement comprehensive tests

A testing tool must be rigorously tested. The strategy involved two layers:

  • Unit tests: All pure utility functions such as logic for parsing options were unit-tested in isolation using Jest.
  • End-to-end tests: A separate Cypress instance was configured to run tests against a simple, local HTML file. These tests would call the plug-in's own commands to verify that they worked as expected in a real browser environment.

Lesson 5: Start small and iterate

The first version of the plug-in, v0.1, was simple. It solved only one problem: waiting for loading spinners to disappear. It contained a single custom command.

JavaScript

// The entire logic for v0.1
Cypress.Commands.add('waitForSpinner', () => {
  cy.log('Waiting for spinner to disappear...');
  cy.get('.loading-spinner', { timeout: 10000 }).should('not.exist');
});

It was tempting to build a complex system to track network requests from day one, but releasing this tiny, focused version provided immediate value. Then, based on early feedback, v0.2 added a command to wait for network requests to cease, cy.waitForNetworkIdle()

This iterative approach, starting with the most frequent pain point and adding features based on real-world needs, ensured the tool remained practical and didn't become bloated with unused functionality.

Lesson 6: Documentation is a core feature

Good documentation goes beyond simply listing commands; it shows users how to solve their problems. The README.md file became a key feature, especially the "Recipes" section. A common challenge testers faced was handling modal dialogs that animate into view.

Before the plug-in, the code was ugly and brittle:

JavaScript

cy.get('#open-modal-btn').click();
cy.wait(500); // Wait for animation to finish... maybe?
cy.get('.modal-content').should('be.visible');
cy.get('.modal-title').should('contain', 'Important Info');

The "Recipe" shows how our plug-in simplified this:

JavaScript

// The recipe in the documentation
cy.get('#open-modal-btn').click();
cy.waitUntilStable().then(() => {
    cy.get('.modal-content').should('be.visible');
    cy.get('.modal-title').should('contain', 'Important Info');
});

This before and after format was invaluable, demonstrating the plug-in's utility at a single glance and directly addressing a user's pain point.

Lesson 7: Embrace community-driven improvement

Shortly after publishing, a user filed a GitHub issue: waitForNetworkIdle() was unreliable on apps with frequent, overlapping network calls. The plug-in would detect the end of the first call and proceed, only for the second call to start immediately, causing the test to fail.

I couldn't have predicted this edge case on my own. But another community member saw the issue and submitted a pull request, adding a settleTime option. This allowed users to specify a "cool-down" period, ensuring no new network requests started for a given duration.

JavaScript

// Community-suggested feature
cy.waitForNetworkIdle({ settleTime: 250 }); // Wait until network is idle for at least 250ms

This feature, born from community collaboration, made the plug-in dramatically more robust in real-world applications.

Lesson 8: Automate maintenance

An ongoing commitment requires robust automation. My GitHub Actions workflow was configured to run the plug-in's test suite against Cypress's stable, nightly, and pre-release versions. 

When Cypress v12 was in pre-release, my CI pipeline suddenly failed. The logs showed that Cypress had changed how the Cypress.log() API handled certain arguments.

Because the failure was caught before the official release, I had two weeks to fix the plug-in's code, test it against the pre-release version, and publish an update. The day Cypress v12 was officially released, a compatible version of the plug-in was already available. This proactive maintenance, driven entirely by automation, is crucial for building trust and ensuring the tool remains reliable for its users.

Creating your own Cypress plug-in: Practical steps

The essential components

A Cypress plug-in is composed of two primary parts that work in tandem:

Node.js environment (index.js)

This code runs in the backend Node.js process that orchestrates the Cypress test runner. It’s where you can access the file system (fs), run server-side code, and register tasks using cy.task().

Browser environment (support.js)

This code is injected into the browser where your application is being tested. It’s used to create custom commands (Cypress.Commands.add()) that can be chained off the cy object in your tests.

Steps for creating a plug-in 

Following is the technical, step-by-step walkthrough for creating a modern Cypress plug-in, aligned with the standards and practices of Cypress v14.5.3.

Step 1: Create a modern plug-in file structure

For a distributable and maintainable plug-in, the following structure is recommended. This keeps your Node-side and browser-side code neatly separated.

Bash

your-cool-plugin/
├── package.json
├── README.md
├── types.d.ts         # Optional: For TypeScript support
└── src/
    ├── index.js       # Node.js process code (tasks, backend logic)
    └── support.js     # Browser-side custom commands

Step 2: Set up the Node.js side (cy.task)

The src/index.js file is the entry point for your plug-in's backend logic. Here, you export a function that gets access to Cypress's on and config objects. You use the on('task', { ... }) event listener to define tasks that can be called from your tests.

Key rule: Every task must return a value, even if it's just null, or a promise that resolves. Failing to return a value will cause Cypress to hang.

JavaScript

// your-cool-plugin/src/index.js

// This function will be exported and used in cypress.config.js
module.exports = (on, config) => {
  on('task', {
    // Example task that logs a message to the terminal
    logMessage(message) {
      console.log(`[Plugin Log]: ${message}`);
      return null; // Always return something!
    },
    // Add other tasks here
  });

  // It's good practice to return the config object
  return config;
};

Step 3: Set up the browser side (custom commands)

The src/support.js file is where you define custom commands that make your tests more readable and reusable.

JavaScript

// your-cool-plugin/src/support.js

Cypress.Commands.add('login', (username, password) => {
  cy.log(`Logging in as ${username}`);
  cy.visit('/login');
  cy.get('#username').type(username);
  cy.get('#password').type(password);
  cy.get('#submit').click();
});

Step 4: Enhancing the developer experience with TypeScript

To provide a superior developer experience with auto-completion and type-checking, include a type definition file (types.d.ts) with your plug-in.

TypeScript

// your-cool-plugin/types.d.ts
declare namespace Cypress {
  interface Chainable {
    /**
     * Custom command to log in a user.
     * @example cy.login('testuser', 'password123')
     */
    login(username: string, password?: string): Chainable<void>;
  }
}

Steps for using the plug-in

To use your plug-in, a user must register it in their project's Cypress configuration.

Step 1: Configure cypress.config.js

The user imports your plug-in's Node.js logic (index.js) and hooks it into the setupNodeEvents function.

JavaScript

// In the user's cypress.config.js
import myCoolPlugin from 'your-cool-plugin/src/index';

export default {
  e2e: {
    setupNodeEvents(on, config) {
      // This runs the plug-in's Node.js logic and registers its tasks
      return myCoolPlugin(on, config);
    },
  },
};

Step 2: Import custom commands

The user imports your plug-in's browser-side logic (support.js) into their project's global support file.

JavaScript

// In the user's cypress/support/e2e.js
import 'your-cool-plugin/src/support';

Practical examples of custom Cypress plug-ins

Let's refactor some examples into a modern, distributable plug-in format.

Example 1: Reading a JSON file 

A plug-in can dynamically read test data from external JSON files located in a custom folder without using fixtures.

Create the plug-in's Node-side logic

Here we define a readJson task. We use config.projectRoot provided by Cypress to reliably construct the absolute path to the data file.

JavaScript

// read-json-plugin/src/index.js
const fs = require('fs');
const path = require('path');

module.exports = (on, config) => {
  on('task', {
    readJson(filePath) {
      const absolutePath = path.resolve(config.projectRoot, filePath);
      console.log(`[Read JSON Plugin]: Reading file from ${absolutePath}`);
      return JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
    }
  });

  return config;
};

Note: This plug-in doesn't require a support.js file since it has no custom commands.

Register the plug-in in a project

Install the plug-in and update your cypress.config.js.

JavaScript

// cypress.config.js
import readJsonPlugin from 'read-json-plugin/src/index';

export default {
  e2e: {
    setupNodeEvents(on, config) {
      return readJsonPlugin(on, config);
    },
  },
};

Use the task in a test

In any test, you can now call this task to fetch data.

JavaScript

it('should populate form with data from JSON file', () => {
  // Assumes a 'test-data/userData.json' file exists at the project root
  cy.task('readJson', 'test-data/userData.json').then((data) => {
    cy.get('input#name').type(data.name);
    cy.get('input#email').type(data.email);
  });
});

Example 2: Clean out folder before running new tests

To automatically delete the contents of a folder (such as old reports or screenshots) before a test suite runs.

Create the plug-in's Node-side logic

This task uses the fs-extra library for its convenient emptyDirSync function.

JavaScript

// clean-folder-plugin/src/index.js
const fs = require('fs-extra');
const path = require('path');

module.exports = (on, config) => {
  on('task', {
    cleanFolder(folderPath) {
      const absolutePath = path.resolve(config.projectRoot, folderPath);
      console.log(`[Clean Folder Plugin]: Cleaning directory ${absolutePath}...`);
      fs.emptyDirSync(absolutePath);
      return null;
    }
  });

  return config;
};

Register the plug-in in a project

JavaScript

// cypress.config.js
import cleanFolderPlugin from 'clean-folder-plugin/src/index';

export default {
  e2e: {
    setupNodeEvents(on, config) {
      return cleanFolderPlugin(on, config);
    },
  },
};

Use the task in a "before" hook

This is perfect for setup tasks that need to run once before all tests in a spec file.

JavaScript

describe('Reporting Suite', () => {
  before(() => {
    // Clean the reports folder before any tests run
    cy.task('cleanFolder', 'cypress/reports');
  });

  it('generates a new report', () => {
    // ...test logic that creates report files...
  });
});

Best practices for plug-in success

Achieving long-term success with a plug-in goes beyond the initial code release. It also involves building trust and providing a reliable tool for the community. A commitment to established best practices is what separates a short-lived utility from an indispensable part of a developer's toolkit. 

Here are some essential best practices for ensuring a plug-in's success:

  • Descriptive naming: Select a clear, intuitive name that communicates the plug-in's purpose. For Cypress, prefixing with cypress- is a standard convention that aids discoverability.
  • Semantic versioning: Strictly follow the Major.Minor.Patch (for example, 2.1.5) standard. This transparently communicates the impact of updates to users: Major versions for breaking changes, Minor for new features, and Patch for bug fixes.
  • Comprehensive documentation: Create a thorough README.md file with installation instructions, API references, and practical code examples. Excellent documentation is non-negotiable and is often the first thing a potential user evaluates.
  • Encourage contributions: Foster a welcoming community by creating a CONTRIBUTING.md file to guide others. Be responsive to GitHub issues and pull requests to show that the project is actively maintained.
  • Automated maintenance: Set up a CI / CD pipeline to automatically run tests against new versions of the host application (like Cypress), catching breaking changes early and ensuring long-term reliability.

To wrap up

Our initial struggle with flaky tests evolved from a daily annoyance into a significant opportunity for professional growth and community engagement. The journey from identifying a specific problem to developing and sharing a distributable plug-in was a powerful lesson in the value of transforming our frustrations into solutions. It highlights a core principle in the QA community: our unique challenges are often shared, and the solutions we build can empower others facing the same hurdles.

I hope this guide demystifies the process of creating Cypress plug-ins and encourages you to look at your own testing obstacles not as roadblocks, but as the potential beginning of your own plug-in. By starting small, documenting your process, and sharing your work, you can not only solve a problem but also make a lasting and valuable contribution to the collaborative world of software quality.

What do YOU think?

Got comments or thoughts? Share them in the comments box below. If you like, use the questions and actions below as starting points for reflection and discussion.

Questions to discuss:

  • Have you ever faced flaky tests that slowed down your automation work? How did you handle them?
  • Have you tried creating custom Cypress commands or plug-ins before? What challenges did you encounter?
  • What features or improvements would you want in a plug-in that helps make tests more reliable?

Actions to take:

  • Experiment: Pick a common source of flakiness in your tests, like unstable network calls, loading spinners, or animations, in your project and brainstorm if and how a Cypress plug-in could solve it.
  • Share your work: If you’ve built or used a plug-in that solved a real problem, post it in the comments or on GitHub for others to learn from.
  • Learn from others: Explore community-built Cypress plug-ins to see what’s already out there and how you could extend or improve on them.
VP Delivery & Head Operations
As VP of Delivery at BugRaptors, I lead QA strategies for client projects and share insights through blogs on automation and manual testing to educate and empower our audience.
Comments
Sign in to comment
See you at TestBash Brighton 2025. image
Proud TestBash sponsor! Visit the Test Exchange to meet our expert software testing team.
Explore MoT
Plymouth Meetup image
Tue, 14 Oct
Second Plymouth Software QA and Testing Meetup Group in Southway
MoT Foundation Certificate in Test Automation image
Unlock the essential skills to transition into Test Automation through interactive, community-driven learning, backed by industry expertise
This Week in Testing image
Debrief the week in Testing via a community radio show hosted by Simon Tomes and members of the community
Subscribe to our newsletter
We'll keep you up to date on all the testing trends.