Cypress (Part 2) – In-depth Guide to Cypress in a real front-end application

 

Before we start, we want to remind you that this blog post is Part 2 of a two-part series. If you haven’t read Part 1 yet, we highly recommend starting there to get a comprehensive understanding of Cypress’s introduction, end-to-end test configuration and how to set up your testing environment. You can find Part 1 here.


In Part 1, we introduced E2E testing, Cypress configuration and how to run Cypress tests to completion. In Part 2, we will delve deeper into numerous aspects of Cypress, seeding test data, testing APIs, and implementing end-to-end (E2E) best practices.

Seed data

Seed data in end-to-end (E2E) testing plays a crucial role in priming the system before running the test. This preloaded data is used to ensure that the system is in a known state and provides the necessary foundation for running comprehensive tests that mimic real-world scenarios.

1. Objective

To ensure that E2E testing does not affect other data in the development environment, it is necessary to create isolated end-to-end test data. For each test case initiated, the test data needs to be seeded once.

2. Implementation

By using cy.task( ), before each mock action, the data specific for end-to-end testing is written into the database.

3. Walk through

Step 1: Start by writing a function based on the backend requirements to handle the necessary operations for seeding data. In this example, we call the backend API to write the test data into the database, and upload the content of the page into an S3 bucket.



export const seedCouncil = (council: any, envConfig: any): (() => Promise<void>) => {
  return async (): Promise<void> => {
    await updateCouncilOnBackend(envConfig.blApiUrl, council);
    await uploadLocalFileToS3()

Step 2: Based on the business logic and different test data requirements, establish several seed data tasks.



export const TASKS = {
  seedCouncilWithPayment:
namespacedTask(‘seedCouncilWithPayment’),
  seedCouncilWithoutPayment: namespacedTask(‘seedCouncilWithoutPayment’),
  seedCouncilWithCouncilPaymentGateway: namespacedTask(‘seedCouncilServiceWithCouncilPaymentGateway’)
};

Step 3: Asynchronously invoke the function for seeding data with different test data in different testing scenarios.

[TASKS.seedCouncilWithPayment]: async (): Promise<Council> => {
    await seedCouncil(seedCouncilWithPayment, config.env)();
    return seedCouncilWithPayment;
  },
  [TASKS.seedCouncilWithoutPayment]: async (): Promise<Council> => {
    await seedCouncil(seedCouncilWithoutPayment, config.env)();
    return seedCouncilWithoutPayment;
  },
  [TASKS.deletePastSessions]: async (council): Promise<void> => {
    await deleteBackendSessions(council, config.env)();
    return null;
  }

Step 4. Now that everything is ready, when can we start seeding data? The answer is before mock actions in every single testing unit by using cy.task().


it(‘allows booking under an unpaid scenario’, () => {
    cy.task(TASKS.seedCouncilWithoutPayment).then((council: Council) => {}
})

API

The Cypress API enables developers to control and manipulate web pages, simulate user interactions, and make assertions about the application’s behaviour. The key features provided by the Cypress API include test runner, selectors, actions and interactions, assertions, network requests and time travel. 

The following some common APIs have been used in our front end application end-to-end tests.

1. cy.task()

The cy.task event handler enables you to call events that either return a value or a promise. We can use this for interacting with databases, making API calls, accessing the file system, or performing any other custom logic outside of the browser context.

Syntax:

cy.task(event), 

cy.task(event, arg), 

cy.task(event, arg, options)

Examples:

  • Seeding data to your database.
  • Storing state in Node that you want persisted between spec files.
  • Performing parallel tasks, like making multiple http requests outside of Cypress.
  • Running an external process.

Project implementation:

Use the task plugin event handler to seed data of the test council. The task then returns the test council, which can be used in the test case.

  it(‘allows booking under an unpaid scenario’, () => {
    cy.task(TASKS.seedCouncilWithoutPayment).then((council: Council) => {
      UI.Booking.visit();
      UI.Booking.fillAndSubmitWithoutPayment();
      UI.Booking.verifySuccess();
    });
  });

2. cy.intercept()

cy.intercept() is a command that allows you to intercept and modify network requests made by your application under test. With cy.intercept(), you can stub or mock network requests, modify request and response data, delay or throttle requests, and more.

Syntax:

cy.intercept(method, url)

cy.intercept(method, url, staticResponse)

Examples:

// spying and response stubbing

cy.intercept(‘POST’, ‘/users*’, {

   statusCode: 201,

   body: {

       name: ‘Peter Pan’,

    },})

Project implementation:

Spy and stub the api requests for uploading files.

export const listenFileUpload = (councilId?: string): string => {
  const alias = `uploadDocument`;
  if (councilId) cy.intercept(‘POST’, `**/councils/${councilId}/file/upload`).as(alias);
  else cy.intercept(‘POST’, ‘**/file/upload’).as(alias);
  return alias;
};

 

3. cy.visit()

cy.visit() is a command which is used to navigate to a specific URL or webpage within your test.

Syntax:

cy.visit(method, url)

cy.visit(method, url, staticResponse)

Examples:

cy.visit(‘http://localhost:3000‘)

cy.visit({

  url: ‘/pages/hello.html’,

  method: ‘GET’

})

Project implementation:


export const visit = (): void => {
cy.visit(‘/application’);
};

 

4. API from @testing-library/cypress

Utilise the methods from @testing-library/cypress like findByRole, findByLabelText, findByText, findByTestId, and others to find the DOM elements.

@testing-library/cypress is a library that provides additional utilities and commands for Cypress tests when working with the testing library ecosystem. It extends the capabilities of Cypress by integrating with the testing library family of tools, which includes libraries like @testing-library/react, @testing-library/vue, and @testing-library/angular.

The @testing-library/cypress library helps you write tests that focus on the behaviour and user experience of your application rather than relying heavily on implementation details. It adds new commands to Cypress, such as findBy, findAllBy, queryBy, and queryAllBy, which allow you to locate elements based on text, attributes, or custom selectors using the testing library query methods.

Examples:

cy.findByRole(‘button’, { name: /Non-existing Button Text/i }).should(‘not.exist’)

cy.findByLabelText(/Label text/i, { timeout: 7000 }).should(‘exist’)

cy.findByRole(‘button’, { name: /confirm/i })

Project implementation:


cy.findByRole(‘textbox’, { name: /street address/i })


5. cy.wait()

Wait for a number of milliseconds or for an aliased resource to resolve before moving on to the next command.

Project implementation:

const clickEmail = () => {
  cy.wait(1000);
  cy.findByRole(‘button’, { name: ‘Email selected permits’ }).click({ force: true });
};

6. cy.readFile()

cy.readFile() is a command that allows you to read the contents of a file during your test execution. This command is particularly useful when you need to access data from a file, such as test fixtures or configuration files, and perform assertions or further processing based on the file’s contents.

Project implementation:

cy.intercept() and cy.readFile() have been used together to mock file upload.

 

export const listenFileUpload = (councilId?: string): string => {
  const alias = `uploadDocument`;
  if (councilId) cy.intercept(‘POST’, `**/councils/${councilId}/file/upload`).as(alias);
  else cy.intercept(‘POST’, ‘**/file/upload’).as(alias);
  return alias;
};
export const uploadDocument = (file: Document, _councilId: string, i: number): void => {
  cy.get(‘[data-testid=”file-upload-choose-file”]’).eq(i).click({ force: true });
  cy.readFile(file.filepath, null).then(contents => {});

Best Practices

1. Selecting elements before taking actions.

cy.get() by element CSS attributes, like id, class, tag or textContent. Targeting the element by CSS attributes is very volatile and highly subject to change. Those attributes are also easily changed by other team members or developers, causing the test cases to fail.

Good practice:

Solution 1: Add data-* attributes to get the elements which give us a targeted selector that’s only used for testing.

Solution 2: Use the Cypress Testing Library package and methods to find elements in Cypress test cases. Like findByRole, findByLabelText.

Example

 

export const uploadDocument = (file: Document, _councilId: string, i: number): void => {
  cy.get(‘[data-testid=”file-upload-choose-file”]’).eq(i).click({ force: true });
});

cy.findByRole(‘link’, { name: /enter address manually/i }).click({ force: true });
  cy.findByRole(‘textbox’, { name: /street address/i })
    .clear()
    .type(street!);

2. Getting the return value after getting or finding an element.

It’s not working in Cypress as we define a variable in Javascript:

const a = cy.get(’a’)

a.first().click()

Good practice:

Solution 1: Use Aliases:

     cy.get(‘a’).as(‘links’)

     cy.get(‘@links’).first().click()

Solution 2: Use Closure:

     cy.get(‘button’).then(($btn) => {

            // $btn is the object

      })

Example


export const listenFileUpload = (councilId?: string): string => {
  const alias = `uploadDocument`;
  if (councilId) cy.intercept(‘POST’, `**/councils/${councilId}/file/upload`).as(alias);
  else cy.intercept(‘POST’, ‘**/file/upload’).as(alias);
  return alias;
};

3. Creating multiple test cases.

We don’t want one test to rely on the state of a previous one, such as is outlined below:

describe(‘pet info’, () => {

     it(‘visits the page’, () => {

       cy.visit(‘/pet/new’)

     })

     it(‘requires registration number‘’, () => {

         cy.get(‘[data-testid=”pet-registration-number”]’).type(‘test-001’)

     })

     it(‘requires registration name’, () => {

         cy.get(‘[data-testid=”pet-registration-name”]’).type(‘Penny’’)

      })

     it(‘can submit a valid application’, () => {

          cy.get(‘form’).submit()

      })

  })

Good practice:

Solution 1: Put repeated code into before or beforeEach hooks.

Solution 2: Combine multiple tests into one.

Example

describe(‘Business self service’, () => {
  beforeEach(() => {
    clearBrowserSession();
  });
  it(‘can send permits list by email without refined search’, () => {
    UI.Application.visit();
    UI.Application.findAndEmailPermits({
      locationType: BusinessLocationType.All,
      allStructures: true
    });
    UI.Application.verifySuccess();
  });

 

4. Utilising baseURL

Hard code the fully qualified domain name URLs in commands.

cy.visit(‘http://localhost:3000/index.html’)

Good practice:

By adding baseURL in the configuration, not only does this create tests that can easily switch between domains, but also save some time during the initial startup of the cypress tests.

Example

 


const nonProdTestConfig = (appEnv: string) => ({
  baseUrl: `https://${appEnv}.www.test.au/business/permits`,
})

module.exports = defineConfig({
  defaultCommandTimeout: 30000,
  scrollBehavior: ‘center’,
  fixturesFolder: false,
  chromeWebSecurity: false
  video: false,
  e2e: {
    specPattern: “cypress/projects/**/*.cy.ts”,
    setupNodeEvents(on, config) {
      on(‘task’, {
        log(message) {
          console.log(message);
          return null;
        }
      });
      const testConfig = getTestConfig(config.env.appEnv || ‘local’);
      config.baseUrl = testConfig.baseUrl;
  }
})

To Wrap It Up

Throughout this blog post, we explored various aspects of Cypress, including its capabilities for handling data, testing APIs, and implementing E2E best practices. By adopting Cypress and following these best practices, developers can significantly improve their testing workflow and the overall quality of their web applications.

Enjoyed this blog?

Share it with your network!

Move faster with confidence