Warren Day
Warren Day

Warren Day · Tech Lead

5 min read

Cypress Testing Best Practices

Cypress Testing Best Practices cover photo.

Cypress tests may seem like a silver bullet, but there are a few pitfalls that you should be aware of. This is a guide to writing Cypress tests that are easy to read, maintain and extend.

Mocking

Our platform uses GraphQL as a network layer which makes mocking extremely easy. Most importantly, by using the right tooling mocks can be simple and concise.

In our case we use the library @warrenday/cypress-graphql-mock-network which intercepts our GraphQL requests and sends them to a mocked GraphQL server.

In doing so we can define only the data needed to get the test to pass and use the auto-mocking feature of graphql-tools to fill in the rest.

Take a look at the following test. Here we provide data only relevant to the test case. All other data related to the graph is not needed since it will be auto mocked.

it('Shows a failure snack bar when an automation job can not be triggered', () => {
  const mocks: MockResolvers = {
    Query: {
      // We provide only the query which we want to test.
      findAutomationJob: () => ({
        // We provide only the data needed for the test
        latestProof: null,
        despatchable: true,
      }),
    },
    // We provide only the mutation which we want to test.
    Mutation: {
      triggerAutomationJob: () => {
        throw new Error('Fail')
      },
    },
  }
  cy.mockNetworkAdd(mocks)

  visitPage()
  cy.getByTestId('trigger-button').click()

  cy.getByTestId('failure-snack-bar')
    .contains(/Sorry, the automation job failed to trigger./)
    .should('exist')
})

What's the benefit of this? It's easy to read and maintain. Imagine in contrast large fixtures which return the full payload. As your schema changes these would break. The approach is very brittle. By auto-mocking the schema, changes will reflect in the test automatically.

A note on auto-mocking

Auto mocking is possible because GraphQL is a strictly typed language. This means that the types of the data returned by the server are known and can be filled in automatically.

For example if a query returns a field of type String then the mock server can provide a random value with the correct type like "hello-world".

Do's and don'ts of mocking

  1. Do mock only the data you need. Mocking only the data you need means tests are easy to read and maintain.

  2. Do mock anything that could break the test if the value changed. To reduce variability you should make sure you mock any field/resolver which could break the test. For example if a boolean being false causes undesired functionality, you should mock it to always be true.

  3. Don't mock more than you need. Overmocking makes it difficult to understand what is being tested. It also makes it hard to change the test in the future as it's un-clear what data is relevant.

  4. Don't share mocks between tests. Sharing mocks like large fixtures between tests binds them together, changing the fixture to update one test can cause undesired results in another test. For this reason it's better to provide mock data on a per test basis.

Sensible defaults

There may be common data which is needed for all tests. For example information about the current user.

For these cases defaultMocks can be provided by wrapping cy.mockNetwork in your own handler. These can always be overidden on a per test basis.

// support/commands.ts

// Here we set the currentUser to always be a super-admin
const defaultMocks = {
  Query: {
    currentUser: () => ({
      name: 'Mike',
      email: 'mike@email.com',
      roles: RoleEnum.SuperAdmin,
    }),
  },
}

const mockNetworkStart = () => {
  cy.readFile('./types/generated/introspection.json').then((schema) => {
    cy.mockNetwork({ schema, mocks: defaultMocks })
  })
}
Cypress.Commands.add('mockNetworkStart', mockNetworkStart)
describe('Accounts', () => {
  beforeEach(() => {
    // Start the mock server and load defaults
    cy.mockNetworkStart()
  })

  after(() => {
    cy.mockNetworkStop()
  })

  // We can now add per test mocks with the guarantee that
  // the user will always provide consistent data.
  it('loads the current users account information', () => {
    const mockResolvers: MockResolvers = {
      Query: {
        accountDetails: () => ({
          title: 'My Account',
        }),
      },
    }
    cy.mockNetworkAdd(mockResolvers)

    // ...
  })
})

IFrames

During testing I have noticed that iframes loaded on screen with no url will instead load under the same domain as the parent.

In our case when we load the application we check if a user session is available. If this is not the case we redirect to the login screen.

In the case of an iframe it could load the application under localhost, see that a user session does not exist and issue a redirect. This redirect can cause a redirect in the parent iframe.

This is because cypress itself uses an iframe to load your web application. So redirects seems to leak out to the parent iframe.

https://github.com/cypress-io/cypress/issues/969
https://github.com/cypress-io/cypress/issues/19234

Targeting elements

When targeting elements to interact with or make assertions on, it's always better to opt for highly specific targets. Test IDs are a great way to acheive this.

The benefit being that even if the structure of your HTML changes, the testid can remain. This decouples your tests from your application code.

Even in the case of loops you can generate testids with some unique identifier appended.

Note: always add unique parts to the end of testids. This way if you search the codebase for an id like list-item it's easy to find results.

<div>
  <span data-testid="list-item-0">List Item 0</span>
  <span data-testid="list-item-1">List Item 1</span>
</div>
// Bad - Any change to the html will break this test
cy.get('li').eq(1).contains('List item 1')

// Good - We don't care about the html structure
cy.getByTestId('list-item-1').contains('List item 1')

A cypress helper can be implemented to easily search by testId

// support/commands.ts

const getByTestId = (testId: string) => {
  return cy.get(`[data-testid="${testId}"]`)
}
Cypress.Commands.add('getByTestId', getByTestId)

Conclusion

Tools like cypress and auto-mocking are great ways to write tests that can mimic the behaviour of real users. But it's important to recognise that you need to take care when writing tests to strike the balance between maintainability and reliability.

CreateTOTALLY  is a next-generation marketing execution platform, uniquely connecting media planning to campaign automation.