DEV Community

Viktorija Filipov
Viktorija Filipov

Posted on

Cypress Workshop Part 10: API, Working on different environments

Working on different environments

In real-life project, you will often have to test applications on different environments, not just one. Usually that environment won’t be production, but some sort of test environment.

For our Demo QA application, we unfortunately don’t have a test environment, but I will show you how you can write the code same as if you had tested one.

Firstly, with Cypress 10, you can manage your environments in different ways: through full definition in cypress.config.json file, or you can define it in different config files and then call it in the main one. Since we don’t have a large configuration data here, I will show you the simple way or storing environment info only in cypress.config.json. With that in mind, let’s edit that file and write this code in:

const { defineConfig } = require("cypress");

module.exports = defineConfig({
  defaultCommandTimeout: 5000,
  video: false,
  screenshotOnRunFailure: false,
  chromeWebSecurity: false,
  retries: 0,
  viewportWidth: 1920,
  viewportHeight: 1080,
  e2e: {
    setupNodeEvents(on, config) {
      if (config.env.prod) {
        return {
          baseUrl: "https://demoqa.com",
          env: {
            env: "prod",
            apiUrl: "https://demoqa.com",
            apiGenerateToken: "/Account/v1/GenerateToken",
            apiLogin: "/Account/v1/Login",
            apiAuthorized: "/Account/v1/Authorized",
            apiUser: "/Account/v1/User",
            login: "/login",
            profile: "/profile",
            books: "/books"
          },
        };
      } else
      return {
        //Change these values with values from another environment
        baseUrl: "https://demoqa.com",
        env: {
          env: "staging",
          apiUrl: "https://demoqa.com",
          apiGenerateToken: "/Account/v1/GenerateToken",
          apiLogin: "/Account/v1/Login",
          apiAuthorized: "/Account/v1/Authorized",
          apiUser: "/Account/v1/User",
          login: "/login",
          profile: "/profile",
          books: "/books"
        },
      };
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Code explanation

  • Line 11 is where environment configuration begins
  • Lines 12 to 27: we are defining what will happen if we run tests in production environment. Meaning, which baseUrl will be used, how we will name that environment, which environment variables will be used (URL extensions etc.)
  • Lines 28-46: Here it is defined what will be used in any other case, meaning in for example staging environment. Notice I used same baseUrl and env variables because we don’t actually have staging environment, this is just to show you how to write it for different environments.
  • All the extensions for URLs/pages will be later used in tests or to call some API method, so you will see later how it is being called in the test.

Next, in order for Cypress to know on which environment it should run the tests, we need to edit commands in package.json file and provide env variable names pointing to certain environment, in this case - production. So let’s edit that file as well to look like this:

{
  "name": "cypress-workshop",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "cypress-cli-prod": "cypress open --env prod=1",
    "cypress-headed-prod": "cypress run --headed -b chrome --env prod=1",
    "cypress-headless-prod": "cypress run --headless -b chrome --env prod=1",
    "cypress-cli-staging": "cypress open",
    "cypress-headed-staging": "cypress run --headed -b chrome",
    "cypress-headless-staging": "cypress run --headless -b chrome",
    "eslint": "eslint cypress",
    "eslint-fix": "eslint cypress --fix"
  },
  "author": "",
  "license": "ISC",
  "husky": {
    "hooks": {
      "pre-commit": "npm run eslint-fix"
    }
  },
  "devDependencies": {
    "cypress": "^10.0.0",
    "eslint": "^8.16.0",
    "eslint-config-airbnb": "^19.0.4",
    "eslint-config-prettier": "^8.5.0",
    "eslint-plugin-chai-friendly": "^0.7.2",
    "eslint-plugin-cypress": "^2.12.1",
    "eslint-plugin-import": "^2.26.0",
    "eslint-plugin-jsx-a11y": "^6.5.1",
    "eslint-plugin-prettier": "^4.0.0",
    "eslint-plugin-react": "^7.30.0",
    "husky": "^8.0.1",
    "prettier": "^2.6.2"
  },
  "dependencies": {
    "cypress-file-upload": "^5.0.8"
  }
}
Enter fullscreen mode Exit fullscreen mode

Code explanation

  • We added more commands with the order to Cypress to run production configuration (Lines 7-9).
  • If we run commands on lines 10,11,12 - Cypress will run default environment - in this case also production since we don’t have others.

Next, since we have predefined URLs in the config file we can refactor navigation.js Bookstore page object file and call those environment variables in appropriate places, instead of hard-coding them.

export class NavigateTo {
  login() {
    cy.visit(Cypress.env('login'));
  }

  profile() {
    cy.visit(Cypress.env('profile'));
  }

  bookStore() {
    cy.visit(Cypress.env('books'));
  }
}

export const navigateTo = new NavigateTo();
Enter fullscreen mode Exit fullscreen mode

Code explanation

  • Refactored lines - 3,7,11 - we replaced hard-coded values with definition of URL extensions from environment variables, so Cypress will read these values from cypress.config.js file.

Before we jump to the next part of the lesson, let’s add one file called utils.js under support folder. Util files are being used to store some very generic “mini” functions that are helpful to perform some operations in other parts of the project. In this case, we will put one function in utils.js, that will - once called - generate a few random numbers. This will be useful later to create random usernames for our users.

export const randomFiveNumbers = () =>
  (Math.floor(Math.random() * 100000) + 100000).toString().substring(1);
Enter fullscreen mode Exit fullscreen mode

ℹ️ Learn more about environment variables in Cypress: env vars

API methods as helpers or shortcuts in UI testing

api

Since this workshop is not about backend development or testing, we will not go too into learning API. We just need to know what it stands for and how we can use it for now.

API stands for Application Programming Interface. In the context of APIs, the word Application refers to any software with a distinct function. Interface can be thought of as a contract of service between two applications. This contract defines how the two communicate with each other using requests and responses. Their API documentation contains information on how developers are to structure those requests and responses.

A REST API (also known as RESTful API) is an application programming interface (API or web API) that conforms to the constraints of REST architectural style and allows for interaction with RESTful web services. REST stands for representational state transfer.

ℹ️ Read more about API testing and Postman software: api testing

In very simple terms: API is a layer of software below UI level. It is part of backend architecture. Why are we interested in it? Because, even though we are learning here web UI automation testing, we still want to use API layer to speed up things. For example - actions defined in preconditions and postconditions of the tests. There is no need to do them through UI, since they are not really our core test or test subject. We can achieve the same result calling APIs that will do the same operation as if we were using UI. For example, login user. Can be done in 10 seconds via UI or 1 second via API in our case. Definitely better to do that precondition as API method and speed up our tests. The core of the test will always be UI interaction for this type of testing automation.

With all that in mind, you can see that our book store app has open API documentation from where we can see API definition and translate that into automated code.

Visit swagger docs

Thought process here is:

  • Check what kind of actions we performed in preconditions and postconditions
  • Look in docs above for API that can do the same thing
  • If doc is unclear, perform the action manually with opened web developer tools and network tab and see which APIs are triggered.

Example: I just performed login and saw which API and with which parameters was fired

Image description

  • Test API manually in Postman to figure out how it works
  • Write api script in cypress

Note: It will not always be this simple, like fire one API and it resolves all your problems. For this login action, it won’t. From experience, I know that app is keeping tokens, session data, cookies and things like that, so I look for those APIs in docs and I figured out way how to automate login fully. I can’t explain this thought process in detail since it is mostly experience based, but if you are stuck with defining API actions DO ASK A BACKEND DEVELOPER on the project, and they will explain exactly which API with which parameters and in which order you should hit, in order to perform some action.

For the purposes of this demo I will refactor preconditions with API related to user creation, login, and I also added postcondition to delete user after every test.

So, let’s create api folder under support folder. Under api folder, create auth.js file, since all our API definitions are related to authentication for now. Write this code:

export const generateToken = (username, password) =>
  cy
    .request({
      method: 'POST',
      url: `${Cypress.env('apiUrl')}${Cypress.env('apiGenerateToken')}`,
      body: {
        userName: username,
        password,
      },
    })
    .then((response) => {
      cy.setCookie('token', response.body.token);
      cy.setCookie('expires', response.body.expires);
    });

export const createUser = (username, password) =>
  cy
    .request({
      method: 'POST',
      url: `${Cypress.env('apiUrl')}${Cypress.env('apiUser')}`,
      body: {
        userName: username,
        password,
      },
    })
    .then((response) => {
      cy.setCookie('userID', response.body.userID);
      cy.setCookie('UserName', response.body.username);
    });

export const deleteUser = (username, password) => {
  cy.request({
    method: 'POST',
    url: `${Cypress.env('apiUrl')}${Cypress.env('apiLogin')}`,
    body: {
      userName: username,
      password,
    },
  }).then((response) => {
    cy.request({
      method: 'DELETE',
      headers: {
        authorization: `Bearer ${response.body.token}`,
      },
      url: `${Cypress.env('apiUrl')}${Cypress.env('apiUser')}/${response.body.userId}`,
    });
  });
};
Enter fullscreen mode Exit fullscreen mode

Code explanation:

  • You can see 3 functions generateToken, createUser, deleteUser.
  • Each of these functions will call certain API methods to perform desired action.
  • generateToken function is calling API to generate user token so that user stays logged in
  • createUser function is calling API to create new user
  • deleteUser function is calling two APIs, first it is calling login API to get response with user id, and then it is calling delete user API method using that id to refer to which user to delete.
  • In generateToken and createUser we are storing cookies that will let Cypress know that user is logged in and keep it that way.

Note: don’t stress if you don’t understand API code here or you didn’t work with APIs, some basic API knowledge is a precondition to understand this. Just try to learn more about how API works and what it is for and ask backend developers for help on project you are working on if you want to do things like this. Every project is different, every architecture is different and API definitions are different on all applications.

ℹ️ Learn more about Cypress request method: request

In order to use these functions more naturally and easily, we can add them to our custom commands and create custom commands for them. So add 3 custom commands to commands.js file to look like this:

// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
import 'cypress-file-upload';
import * as api from './api/auth';
import * as utils from './utils';

const username = `user${utils.randomFiveNumbers()}`;
const password = 'Test123456!';

Cypress.Commands.add('verifyWindowAlertText', (alertText) => {
  cy.once('window:alert', (str) => {
    expect(str).to.equal(alertText);
  });
});

Cypress.Commands.add('elementVisible', (locator) => {
  cy.wrap(locator).each((index) => {
    cy.get(index).then((el) => {
      cy.get(el).should('be.visible');
    });
  });
});

Cypress.Commands.add('textExists', (text) => {
  cy.wrap(text).each((index) => {
    cy.contains(index).should('exist');
  });
});

Cypress.Commands.add('createUser', () => {
  api.createUser(username, password);
});

Cypress.Commands.add('generateToken', () => {
  api.generateToken(username, password);
});

Cypress.Commands.add('deleteUser', () => {
  api.deleteUser(username, password);
});
Enter fullscreen mode Exit fullscreen mode

Code explanation:

  • We added 3 commands to call our already defined API methods. (lines 53-63)
  • on lines 31 and 32 we defined how our username and password should look like. For username, in order to be unique we are always generating new one with different combination or letters and numbers (calling function from utils to generate random number combination)

Now that we have everything in place we can finally …

…REFACTOR OUR EXISTING TESTS…

Refactoring: addBookToProfile.cy.js

/// <reference types="Cypress" />

import { bookActions } from '../../support/bookstore_page_objects/book_store';
import { navigateTo } from '../../support/bookstore_page_objects/navigation';

describe('Collections: Add Book To Collection', () => {
  // Perform login
  beforeEach('Perform login', () => {
    cy.createUser();
    cy.generateToken();
  });

  // Delete user
  afterEach('Delete user', () => {
    cy.deleteUser();
  });

  it('Check adding book to profile collection', () => {
    // Navigate to book store
    navigateTo.bookStore();
    // Load books fixture
    cy.fixture('books').then((books) => {
      // Add first books to collection
      bookActions.addBookToCollection(books.collection1.Git);
      // Handle alert and verify alert message
      cy.verifyWindowAlertText(`Book added to your collection.`);
      // Navigate to user profile and verify that book is in collection table
      navigateTo.profile();
      cy.get('.rt-tbody').find('.rt-tr-group').first().should('contain', books.collection1.Git);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Code explanation:

We refactored:

  • Precondition - to create user and login via API
  • Edited postcondition - delete user via API (no need to delete book, we can just delete this user all together)
  • Navigation name to navigate to bookstore

Refactoring checkBookInfo.cy.js:

/// <reference types="Cypress" />

import { bookActions } from '../../support/bookstore_page_objects/book_store';
import { profileActions } from '../../support/bookstore_page_objects/profile';
import { navigateTo } from '../../support/bookstore_page_objects/navigation';

describe('Collections: Check Book Info', () => {
  // Perform login
  beforeEach('Perform login', () => {
    cy.createUser();
    cy.generateToken();
  });

  // Add book to book collection
  beforeEach('Add book to profile collection', () => {
    navigateTo.bookStore();
    cy.fixture('books').then((books) => {
      bookActions.addBookToCollection(books.collection1.DesignPatternsJS);
      cy.verifyWindowAlertText(`Book added to your collection.`);
    });
  });

  // Delete user
  afterEach('Delete user', () => {
    cy.deleteUser();
  });

  it('Check book info from profile table', () => {
    // Navigate to user profile
    navigateTo.profile();
    // Load books fixture
    cy.fixture('books').then((books) => {
      // Click on book in collection to open book info
      profileActions.checkBookData(books.collection1.DesignPatternsJS);
    });
    // Define book info elements
    const bookDataElements = [
      '#ISBN-label',
      '#title-label',
      '#subtitle-label',
      '#author-label',
      '#publisher-label',
      '#pages-label',
      '#description-label',
      '#website-label',
    ];
    // Check book info elements
    cy.elementVisible(bookDataElements);
    // Define data about the book
    const bookData = [
      '9781449331818',
      'Learning JavaScript Design Patterns',
      `A JavaScript and jQuery Developer's Guide`,
      'Addy Osmani',
      `O'Reilly Media`,
      '254',
    ];
    // Check data about the book
    cy.textExists(bookData);
  });
});
Enter fullscreen mode Exit fullscreen mode

Code explanation:

We refactored:

  • Precondition - to create user and login via API
  • Edited postcondition - delete user via API(no need to delete book, we can just delete this user all together)
  • Navigation name to navigate to bookstore

*Refactoring deleteBookFromProfile.cy.js: *

/// <reference types="Cypress" />

import { bookActions } from '../../support/bookstore_page_objects/book_store';
import { profileActions } from '../../support/bookstore_page_objects/profile';
import { navigateTo } from '../../support/bookstore_page_objects/navigation';

describe('Collections: Delete Book From Collection', () => {
  // Perform login
  beforeEach('Perform login', () => {
    cy.createUser();
    cy.generateToken();
  });

  // Add book to collection
  beforeEach('Add book to profile collection', () => {
    navigateTo.bookStore();
    cy.fixture('books').then((books) => {
      bookActions.addBookToCollection(books.collection1.SpeakingJS);
      cy.verifyWindowAlertText(`Book added to your collection.`);
    });
  });

  // Delete user
  afterEach('Delete user', () => {
    cy.deleteUser();
  });

  it('Check deleting book from profile collection - confirm deletion', () => {
    cy.fixture('books').then((books) => {
      // Navigate to user profile
      navigateTo.profile();
      // Check if book is in the collection table
      cy.get('.rt-tbody')
        .find('.rt-tr-group')
        .first()
        .should('contain', books.collection1.SpeakingJS);
      // Delete book from table - confirm deletion
      profileActions.deleteBookFromTable(books.collection1.SpeakingJS, 'ok');
      // Handle delete alert and verify message
      cy.verifyWindowAlertText(`Book deleted.`);
      // Verify that book is no longer in collection table and that table is empty
      cy.get('.rt-tbody').should('not.contain', books.collection1.SpeakingJS);
      cy.get('.rt-noData').should('contain', 'No rows found').should('be.visible');
    });
  });

  it('Check deleting book from profile collection - decline deletion', () => {
    cy.fixture('books').then((books) => {
      // Navigate to user profile
      navigateTo.profile();
      // Check if book is in the collection table
      cy.get('.rt-tbody')
        .find('.rt-tr-group')
        .first()
        .should('contain', books.collection1.SpeakingJS);
      // Cancel book deletion
      profileActions.deleteBookFromTable(books.collection1.SpeakingJS, 'cancel');
      // Verify that book is still in the table
      cy.get('.rt-tbody').should('contain', books.collection1.SpeakingJS);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Code explanation:

We refactored:

  • Precondition - to create user and login via API
  • Edited postcondition - delete user via API (no need to delete book, we can just delete this user all together)
  • Navigation name to navigate to bookstore

Refactoring login.cy.js:

/// <reference types="Cypress" />

import { auth } from '../../support/bookstore_page_objects/auth';
import { navigateTo } from '../../support/bookstore_page_objects/navigation';

describe('Auth: Login user', () => {
  // Navigate to login page
  beforeEach('Navigate to Login page', () => {
    navigateTo.login();
  });

  it('Check valid user credentials', () => {
    // Load users fixture
    cy.fixture('users').then((users) => {
      // Perform login
      auth.login(users.user2.username, users.user2.password);
    });
    // Verify that user is redirected to profile page (user is logged in)
    cy.url().should('contain', Cypress.env('profile'));
  });

  it('Check invalid user credentials', () => {
    // Perform login
    auth.login('invalid345', 'invalid345');
    // Verify that user is still on login page (user is not logged in)
    cy.url().should('contain', Cypress.env('login'));
    // Verify that error message is displayed
    cy.get('#output').should('contain', 'Invalid username or password!');
  });

  it('Check login with invalid username and valid password', () => {
    // Load users fixture
    cy.fixture('users').then((users) => {
      // Perform login
      auth.login('invalid345', users.user2.password);
    });
    // Verify that user is still on login page (user is not logged in)
    cy.url().should('contain', Cypress.env('login'));
    // Verify that error message is displayed
    cy.get('#output').should('contain', 'Invalid username or password!');
  });

  it('Check login with valid username and invalid password', () => {
    // Load users fixture
    cy.fixture('users').then((users) => {
      // Perform login
      auth.login(users.user2.username, 'invalid345');
    });
    // Verify that user is still on login page (user is not logged in)
    cy.url().should('contain', Cypress.env('login'));
    // Verify that error message is displayed
    cy.get('#output').should('contain', 'Invalid username or password!');
  });
});
Enter fullscreen mode Exit fullscreen mode

Code explanation:

We refactored:

  • URL assertions not to be hardcoded but to read values from environment variables from cypress.config.js file

Refactoring logout.cy.js:

/// <reference types="Cypress" />

import { auth } from '../../support/bookstore_page_objects/auth';
import { navigateTo } from '../../support/bookstore_page_objects/navigation';

describe('Auth: Log out user', () => {
  // Perform login
  beforeEach('Perform login', () => {
    cy.createUser();
    cy.generateToken();
  });

  // Delete user
  afterEach('Delete user', () => {
    cy.deleteUser();
  });

  it('Check logging out user', () => {
    // Navigate to user profile
    navigateTo.profile();
    // Perform log out
    auth.logout();
    // Assert that user is on login page
    cy.url().should('contain', Cypress.env('login'));
  });
});
Enter fullscreen mode Exit fullscreen mode

Code explanation:

We refactored:

  • Precondition - to create user and login via API
  • Edited postcondition - delete user via API
  • URL assertion to refer to env variables

Refactoring searchBookstore.cy.js:

/// <reference types="Cypress" />

import { bookActions } from '../../support/bookstore_page_objects/book_store';
import { navigateTo } from '../../support/bookstore_page_objects/navigation';

describe('Bookstore: Search For Book', () => {
  // Perform login
  beforeEach('Perform login', () => {
    cy.createUser();
    cy.generateToken();
  });

  // Delete user
  afterEach('Delete user', () => {
    cy.deleteUser();
  });

  it('Check searching for existing book in book store', () => {
    // Navigate to bookstore
    navigateTo.bookStore();
    // Load books fixture
    cy.fixture('books').then((books) => {
      // Perform book search
      bookActions.searchCollection(books.collection1.DesignPatternsJS);
      // Verify that there is a book in filtered table (in search result)
      cy.get('.rt-tbody')
        .find('.rt-tr-group')
        .first()
        .should('contain', books.collection1.DesignPatternsJS);
    });
  });

  it('Check searching for non-existing book in book store', () => {
    // Define invalid book name
    const invalid_book_name = 'Game of Thrones';
    // Navigate to bookstore
    navigateTo.bookStore();
    // Perform book search
    bookActions.searchCollection(invalid_book_name);
    // Assert that there are no search results (no book in the table and table is empty)
    cy.get('.rt-tbody').should('not.contain', invalid_book_name);
    cy.get('.rt-noData').should('contain', 'No rows found').should('be.visible');
  });
});
Enter fullscreen mode Exit fullscreen mode

Code explanation:

We refactored:

  • Precondition - to create user and login via API
  • Added postcondition - delete user via API
  • Bookstore navigation

HOMEWORK:

  • Refactor preconditions/postconditions to be fully with API not UI. Figure out how to add/delete book through API. Use swagger docs as reference.
  • Learn more about APIs
  • Read info sections

Don’t forget to push everything you did today on Github 😉 Remember git commands?

git add .

git commit -am "add: api refactoring, env variables"

git push

SEE YOU IN LESSON 11!

Completed code for this lesson

If you have learned something new, feel free to support my work by buying me a coffee ☕

Buy Me A Coffee

Top comments (0)