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"
},
};
},
},
});
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
andenv
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"
}
}
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();
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);
ℹ️ Learn more about environment variables in Cypress: env vars
API methods as helpers or shortcuts in UI testing
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
- 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}`,
});
});
};
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
andcreateUser
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);
});
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);
});
});
});
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);
});
});
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);
});
});
});
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!');
});
});
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'));
});
});
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');
});
});
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 ☕
Top comments (0)