How to Write Cypress Tests That Don’t Suck

At Atomic, we use Cypress for end-to-end testing on many projects. It’s a powerful tool that helps ensure our applications work as intended. However, as much as we appreciate its capabilities, writing Cypress tests can become a pain if not approached thoughtfully. End-to-end tests in general are tricky to write well, because they’re testing much more than just a single function. But following some best practices can significantly reduce the friction. Here’s how to write Cypress tests that don’t suck.

1. Develop Features Through Cypress

Too often, testing is treated as an afterthought—something done when the feature is “complete.” This approach leads to brittle tests, uncovered edge cases, and a mad scramble to retrofit tests into an already complex implementation. Instead, make Cypress an integral part of your development workflow. Here’s why and how:

Catch Issues Early

When you write tests as you develop, you’re forced to consider both happy paths and edge cases from the outset. This early focus can uncover potential bugs before they make it into your implementation. For example:

  • Testing a login page might reveal that your API doesn’t handle special characters in usernames correctly.
  • Simulating a user navigating through a multi-step form might surface issues with form state management.

Catching these problems early saves time and effort compared to debugging and refactoring after the feature is “done.”

Clarify Requirements

Writing tests forces you to think critically about what the feature should and shouldn’t do. If you find yourself struggling to write a clear test for a particular behavior, it might indicate that:

  • The requirements are ambiguous.
  • The feature’s behavior isn’t intuitive.
  • The implementation is overly complicated.

This feedback loop between testing and development helps ensure that features are well-defined, user-friendly, and robust.

Build With Testability in Mind

Developing alongside Cypress naturally leads to better design choices. For example:

  • Smaller, reusable components: Testing smaller UI elements individually encourages modular design, which improves both testability and maintainability.
  • Clear separation of concerns: When you need to test specific functionality, you’ll gravitate towards keeping business logic in services or APIs rather than coupling it with the UI.
  • Accessible UI: Cypress tests can easily interact with well-structured, semantic elements (e.g., proper button tags with accessible attributes), encouraging accessible design.

Avoid Flaky, Overcomplicated Tests

When tests are written post-hoc, they often become overcomplicated as they attempt to retrofit assertions onto a feature that wasn’t designed with testability in mind. By writing tests as you build:

  • You can shape the DOM structure or API responses to align with your testing needs.
  • You’ll create clean and predictable tests because the feature was built to support them from the start.

2. Embrace the Given-When-Then Pattern

Using a consistent structure like the Given-When-Then pattern (inspired by Cucumber) or Arrange/Act/Assert can make your tests easier to write and understand. Each test should follow this flow:

  • Given: Set up preconditions, such as database records or configurations
  • When: Perform user actions, such as logging in, clicking buttons, or typing into fields
  • Then: Assert expected outcomes, such as verifying that the user sees a “Success” message upon successful submission

The easiest way to implement this flow is by creating literal given, when, and then objects in your test file, and defining their contents as human-readable functions that perform the desired behavior:


it("allows users to add comments to posts", () => {
  given.aPostExists();
  given.theUserIsLoggedIn();

  when.theUserClicksOnThePost();
  when.theUserAddsACommentOnThePost();

  then.theUserSeesTheirCommentOnThePost();
});

This structure makes it easy to understand tests at a glance.

3. Keep Tests Modular

Each test should verify only one behavior. A sprawling test that tries to cover too much is harder to debug and maintain. Break down complex workflows into smaller tests that focus on specific actions and results. This modularity ensures that when a test fails, diagnosing the problem is straightforward.

A good pattern to help keep your tests modular is to avoid following assertions with more actions:


// ❌ this test verifies multiple behaviors
it("allows users to edit comments", () => {
  given.aPostExists();
  given.aCommentExistsOnThePost();
  given.theUserIsLoggedIn();

  when.theUserClicksOnThePost();
  when.theUserClicksOnTheEditCommentButton();

  then.theUserSeesTheEditCommentForm();

  when.theUserTypesInANewComment();
  when.theUserClicksSave();

  then.theUserSeesTheirNewCommentOnThePost();
});

// ✅ each test verifies a single behavior
it("opens the edit comment form when the edit button is clicked", () => {
  given.aPostExists();
  given.aCommentExistsOnThePost();
  given.theUserIsLoggedIn();

  when.theUserClicksOnThePost();
  when.theUserClicksOnTheEditCommentButton();

  then.theUserSeesTheEditCommentForm();
});
it("allows users to edit comments", () => {
  given.aPostExists();
  given.aCommentExistsOnThePost();
  given.theUserIsLoggedIn();

  when.theUserClicksOnThePost();
  when.theUserClicksOnTheEditCommentButton();
  when.theUserTypesInANewComment();
  when.theUserClicksSave();

  then.theUserSeesTheirNewCommentOnThePost();
});

4. Only Seed the Data You Need

When setting up Cypress for the first time, many teams create a comprehensive “seed” script to initialize database data for the test suite. While this approach might seem convenient initially, it quickly turns into a maintenance nightmare. New tests often require specific data setups, leading to either:

  • Writing additional seed scripts.
  • Modifying the existing seed script—potentially breaking or invalidating earlier tests.

Instead, adopt an abstraction like Blueprints to define only the data you care about for each test. Blueprints allow you to:

  • Dynamically create test data with just the attributes you need.
  • Rely on sensible defaults for all unspecified fields.
  • Avoid coupling tests to large, fragile data setups.

Depending on your implementation, you can even create a type-safe cy.blueprint command that and raises type errors when trying to use invalid properties for a given blueprint:


const postId = faker.string.uuid();

const given = {
  aPostExists: () => {
    cy.blueprint("post", { id: postId });
  },
  aCommentExistsOnThePost: () => {
    cy.blueprint("comment", { postId });
  },
  aCommentWithAnInvalidAttributeExists: () => {
    cy.blueprint("comment", { foo: "bar" }); // TypeError: foo does not exist on type Comment
  },
};

This pattern ensures your test focuses on the data that matters for its scenario, making it easier to adapt as requirements evolve. It also minimizes the risk of unintended side effects from unrelated data.

5. Test Non-Existence Carefully

While it’s easy to write tests to ensure that something does happen, or does exist on the page, testing for something that doesn’t happen or exist can be deceptively tricky. A poorly designed “absence” test can lead to false positives that undermine the reliability of your suite. For example, simply asserting than an element isn’t visible without verifying the conditions under which it should be visible can result in tests that don’t actually validate the right behavior.

Here’s how to test for both existence and absence effectively:

  • Complement negative tests with positive ones. For every test that checks if something is absent, include a corresponding test that verifies its presence under the right conditions. Use shared constants to ensure consistency between the tests. For example:
    
    it('shows the "Manage Users" button for admin users', () => {
      when.userLogsInAsAdmin();
      then.theAdminButtonExists();
    });
    
    it('does not show the "Manage Users" button for non-admin users', () => {
      when.userLogsInAsGuest();
      then.theAdminButtonDoesNotExist();
    });
    
    // Shared constant that can be shared across both assertions
    const adminButtonText = "Manage Users";
    
    const when = {
      // ...
    };
    
    const then = {
      theAdminButtonExists: () => {
        cy.contains(adminButtonText).should("exist");
      },
      theAdminButtonDoesNotExist: () => {
        cy.contains(adminButtonText).should("not.exist");
      },
    };
    
  • Validate the preconditions. Before asserting that something doesn’t exist, confirm the test is in the correct state to evaluate the absence. This ensures the absence isn’t due to a failure earlier in the test flow. For instance:
    
    it('does not display the "Delete" button for a guest user', () => {
      when.userLogsInAsGuest();
      then.theUserSeesTheDashboardPage(); // Validate the user is on the dashboard, where the Delete button would be visible for an admin user
      then.theUserDoesNotSeeTheDeleteButton(); // Assert absence
    });
    

Conclusion

Writing good, maintainable Cypress tests requires intentionality and structure. By integrating Cypress into your regular development workflow, embracing patterns like Given-When-Then, keeping tests modular, seeding only necessary data, and carefully testing for absence, you can create a test suite that’s both robust and developer-friendly.

Remember, the goal of testing isn’t just to validate features—it’s to build confidence in your application and streamline the development process. With these best practices, your Cypress tests will become a valuable asset rather than a source of frustration.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *