Overview
One of the MOST important rules in automation is having independent tests. This means tests should not rely on each other and you should be able to run any test from any suite at any time.
In this blog post, I am sharing some proven practices I've picked up over a decade in test automation. Cypress is used for the demos, but the concepts apply to any test automation stack.
Special thanks to @Sebastian Clavijo Suero for his great feedback and suggestions!
⚠️ Disclaimer: The techniques and practices shared in this article are based on real-world experience across multiple projects and teams. While they’ve worked well for me and many others, they’re not the only way to approach test isolation, and I’m not claiming these are the best approaches. There may be different valid solutions depending on your tech stack or infrastructure.
The Problem
Let’s look at an example of a simple API CRUD (Create, Read, Update, and Delete) test for a project entity in a sample application. Here are the available requests for its endpoint.
-
POST /project
> Create a new project -
GET /project/:id
> Returns all projects -
PUT /project/:id
> Update the project with projectId -
DELETE /project/:id
> Delete the project with projectId
You might write something like this:
describe('CRUD Tests for /project', () => {
let project;
it('Should create a project', () => {
cy.request('POST', '/project', { name: 'An Awesome Project' })
.then((res) => {
project = res.body;
});
});
it('Should read a project', () => {
cy.request('GET', `/project/${project.id}`).its('body.name') // assumes a project already exists 🤨
.should('include', 'Awesome');
});
it('Should update a project', () => {
cy.request('PUT', `/project/${project.id}`, { name: 'Updated Project' });
cy.request('GET', `/project/${project.id}`).its('body.name')
.should('eq', 'Updated Project');
});
it('Should delete a project', () => {
cy.request('DELETE', `/project/${project.id}`);
cy.request({
method: 'GET',
url: `/project/${project.id}`,
failOnStatusCode: false
}).its('status').should('eq', 404);
});
});
Looks simple, hah? It seems all tests pass even when running this code multiple times.
- But what happens if the first test fails? ❌ The other tests fail too because the project is undefined (we’re relying on the first test to create a project).
-
Can you run the update test by putting the
.only
annotation? ❌ No! Because there is no project to update!
Ok, Why do we need to solve this problem?
- No dependency on previous tests
- No test order dependency
- Clean environment for every test (no leftovers from previous tests)
- Easier debugging
- Less flaky tests
- Parallel execution
Side note: Environment Isolation
In this article, we are focusing on isolated tests, not environments. However, It’s worth to mentioning this distinction.
Consider this scenario: You have a project-crud.cy.js
test file. You want to run this on your local machine, you colleague is manually testing project creation, and 3 parallelized machines in CI are going to run the same test as a scheduled run; all in the same environment ( and even worse with the same user credentials).
Is it feasible? It’s like multiple people trying to drive the same car at the same time! This definitely prevents automation feasibility at all. So what are some solutions to resolve this issue?
- Create a new fresh environment (known as Ephemeral environments) for each test run, with a new deployment of the latest backend plus a pre-created database snapshot
- Using a mock server to completely bypass the backend. (Applicable for UI functional tests and required frontend project to build and run locally)
- Use separate test users (Multi-Tenancy)
- Use unique test data for each test
- etc.
Hooks:
Before discussing test isolation/independence techniques, let’s understand the different test run stages (known as hooks in the JavaScript world). Beyond the actual test, we need separate stages to set up preconditions and clean up afterward. Here are all the available hooks in Mocha and Cypress:
-
it()
: Test block where the actual test steps are defined -
specify()
: Just an alias for it() block. Behaves the same. However It is rarely used. -
describe()
: Where we organize test cases based on features or modules. Known asTest Suite
. -
context()
: Just an alias fordescribe()
, used when grouping tests based on conditions (or/and) with nested describe blocks. -
before()
: It runs once before the test suite (describe or context). Known asSuite Setup
. -
beforeEach()
: It runs before each test. Known asTest Setup
-
after()
: Runs one time after the test suite. Known asSuite Teardown
-
afterEach
: It runs after each test. Known asTest Teardown
To run something before everything else in cypress, place it in
before()
hooks withincypress/support/e2e.js
, as this file runs during Cypress initialization.
Scope
When mixing and nesting hooks, be mindful of their scopes. The more outer scope is, the broader its effect. To understand it better, try to run this test file in cypress and check the sequence of actual cypress logs!
➡️ Nesting and complicating test files is usually a bad practice and this example is just for learning purposes only.
// Root level hooks
before( () => {
cy.log('Root level suite SETUP')
})
after( () => {
cy.log('Root level suite TEARDOWN')
})
beforeEach(() => {
cy.log('Root level test SETUP')
})
afterEach(() => {
cy.log('Root level test TEARDOWN')
})
describe('Describe Block1', () => {
before( () => {
cy.log('Run BEFORE describe block1')
})
after( () => {
cy.log('Run AFTER describe block1')
})
beforeEach(() => {
cy.log('Test SETUP1')
})
afterEach(() => {
cy.log('Test TEARDOWN1')
})
it('Test 1 inside Describe Block1', () => {
cy.log('Test 1 inside Describe Block1')
})
it('Test 2 inside Describe Block1', () => {
cy.log('Test 2 inside Describe Block1')
})
})
describe('Describe Block2', () => {
before( () => {
cy.log('Run BEFORE describe block2')
})
after( () => {
cy.log('Run AFTER describe block2')
})
beforeEach(() => {
cy.log('Test SETUP2')
})
afterEach(() => {
cy.log('Test TEARDOWN2')
})
it('Test 1 inside Describe Block2', () => {
cy.log('Test 1 inside Describe Block2')
})
it('Test 2 inside Describe Block2', () => {
cy.log('Test 2 inside Describe Block2')
})
context('Context Block1', () => {
it('Test 1 inside Context Block1 which is inside Describe Block2', () => {
cy.log('Test 1 inside Context Block1')
})
})
})
context('Context Block2', () => {
specify('Test 1 inside Context Block2', () => {
cy.log('Test 1 inside Context Block2')
})
})
Here’s how it all runs together:
AAA pattern in test automation:
A standard automation test has three main stages:
- Arrange: Everything you should do to prepare the environment for testing. For example logging in, creating required resources, cleaning up previous tests' leftovers, etc.
- Act: The actual test steps that interact with our application.
- Assert: Verify the application's expected behavior and reactions to Act interactions.
➡️ The
Arrange
is done inside the Setup stage, while theAct
andAssert
are done together during the test stage.
Keep this in mind since we will reference it again. Now let’s talk about the interesting part!
Now let's see in action: How to make tests independent?
It actually requires us to implement these minimum techniques:
1. Using Setup and Teardown
2. Use dynamic singleton variables - file-scoped or framework-scoped
3. Use random data (string/UUIDs, etc. )
1. Setup / Teardown
Setup: Initial actions taken before a test suite or individual test case runs, ensuring a clean and prepared environment for testing. As mentioned in Hooks, we use before()
and beforeEach()
for this.
Teardown: Cleans up resources and resets the environment to its original state after the test case completes. In Mocha (Cypress), after()
and afterEach()
handle this.
➡️ Teardown stages always run regardless of test status (fail/pass).
Let’s come back to our initial example of CRUD operation on the project entity. So in order to make tests independent, we should create a dedicated project for each test and then delete it after the test; right?
Here’s how we can use setup/teardown hooks to achieve this:
describe('CRUD Tests for /project', () => {
let project;
beforeEach('Create a project before each test', () => {
// Arrange
cy.request('POST', '/project', { name: 'An Awesome Project' })
.then((res) => {
project = res.body;
});
})
afterEach('Delete the project after each test', () => {
cy.request('DELETE', `/project/${project.id}`)
.then((res) => {
expect(res.status).to.eq(200)
});
})
it('Should create a project', () => {
// Act
cy.request('POST', '/project', { name: 'An Awesome Project' })
.then((res) => {
project = res.body;
// Assert
expect(res.status).to.eq(201)
});
});
it('Should read a project', () => {
// Act and Assert
cy.request('GET', `/project/${project.id}`).its('body.name')
.should('include', 'Awesome');
});
it('Should update a project', () => {
// Act and Assert
cy.request('PUT', `/project/${project.id}`, { name: 'Updated Project' })
.then((res) => {
expect(res.status).to.eq(201)
});
cy.request('GET', `/project/${project.id}`).its('body.name')
.should('eq', 'Updated Project');
});
it('Should delete a project', () => {
// Act and Assert
cy.request('DELETE', `/project/${project.id}`)
.then((res) => {
expect(res.status).to.eq(200)
});
cy.request({
method: 'GET',
url: `/project/${project.id}`,
failOnStatusCode: false
}).its('status').should('eq', 404);
});
});
Better now; each test has its own project in place and deletes it afterward.
But can we make it better somehow? Do you spot any repeating for example?
Following are some points to consider:
The
CREATE
test itself (first one) doesn’t need a project created in setup, since it tests the actual project creation functionality. So we should find a way to run the setup stage conditionally. This is where context helps us separate tests based on conditions.It seems like we are repeating the CREATE operation in each test setup! Well, actually it is correct, but here is one of the trade-offs in test automation world. Having some repeats to make tests independent is acceptable (when we can't use solutions like separate environments or database snapshots). We need a fresh project (and other environment values) each time if we want to reach the goal of independence. But then what is the difference between CREATE in setup versus CREATE in test? The answer is; the first one (test setup) doesn't include
Assert
.Teardown in Setup: Yes, that is correct. As a good practice in test automation, we should do as much as we can in Test Setup, instead of Teardown. For example deleting the created projects (test leftovers) in
beforeEach()
instead ofafterEach()
. Why?
If the test fails in the middle, teardown might break (we don't have that created data to cleanup), or not be needed at all. So it is better to have initial Cleanup (setup) rather than doing it post-test.
What we’re doing during cleanups is actually one of the most important functionality of our application. If there’s a bug in the cleanup process itself, it’s better to catch it early during Setup and halt the test run. That way, we can investigate the issue before running the actual tests—no point in continuing when the app might already be in a broken state.
You need a clean state before each test, regardless of previous test outcomes, since the next test may doesn't know what happened in previous tests. Leftover data might mess with the next tests, so we need to clean up in Setup even if we are doing that in Teardown. This often makes teardown redundant.
Faster feedback and cleaner report/logs. You don’t really need to populate a lot of logs after the actual test phase. We want a clean report to debug when something is failed. So any preparation would be better to be in Setup instead of Teardown.
That’s why we have Arrange / Act / Assert, meaning we should handle environment preparation in Arrange.
However, while it's best to rely on setup to create clean state, teardown is still essential and needed in specific situations. Including:
- Logging screenshots or any debug artifacts when the test is failed
- Resetting feature flags or general state of the system, environment or application. (e.q. theme changes, a banner activated in UI, etc)
- Cleanup Data you put in DATABASE (that can be mess with the next test) or Files (e.g. PDF, Log) you have created during the test.
- Releasing shared cloud resources. For example, you are using an inhouse mobile device farm and you booked the only
galaxys25-15Huj
device in the farm for that test to check an specific Galaxy-AI driven functionality of your app.
Now let’s change our test suite and consider these improvements:
describe('CRUD Tests for /project', () => {
let project;
beforeEach('Delete the project after each test', () => {
cy.request({
method: 'DELETE',
url: '/projects',
failOnStatusCode: false,
}).then((res) => {
if (res.status !== 200 && res.status !== 404) {
throw new Error(`Unexpected DELETE status: ${res.status}`);
}
});
})
context('CREATE and READ', () => {
it('Should CREATE and READ a project', () => {
// CREATE a Project
cy.request('POST', '/project', { name: 'My Awesome Project' })
.then((res) => {
project = res.body;
expect(res.status).to.eq(201)
}).then(() => {
// READ the Project: we verify READ operation here by combining two tests.
cy.request('GET', `/project/${project.id}`).its('body.name')
.should('include', 'Awesome');
})
});
})
context('UPDATE and DELETE', () => {
beforeEach('Create a project before each test', () => {
// Arrange
cy.request('POST', `/project/${project.id}`, { name: 'My Awesome Project' })
.then((res) => {
project = res.body;
});
})
it('Should update a project', () => {
// Act and Assert
cy.request('PUT', `/project/${project.id}`, { name: 'Updated Project' })
.then((res) => {
expect(res.status).to.eq(201)
});
cy.request('GET', `/project/${project.id}`).its('body.name')
.should('eq', 'Updated Project');
});
it('Should delete a project', () => {
// Act and Assert
cy.request('DELETE', `/project/${project.id}`)
.then((res) => {
expect(res.status).to.eq(200)
});
cy.request({
method: 'GET',
url: `/project/${project.id}`,
failOnStatusCode: false
}).its('status').should('eq', 404);
});
})
});
Did you notice the changes?
-
We have now two
context()
s inside thedescribe()
. One for tests that don’t need a project setup, another for tests that do. We're grouping tests based on their setup requirements (condition).Then we moved that initial
beforeEach()
hook (which is for creating project as a setup stage) inside the secondcontext()
. Instead of deleting the created project after tests in
afterEach()
as a teardown, we now delete all the projects in the database in the describe-scopedbeforeEach()
hook, which runs before the context'sbeforeEach()
hook. (Sounds confusing? Don’t worry, I will explain it in a short ))-
We’ve combined READ and CREATE tests! You might say: “Hey! Each test should have a single responsibility and test one thing!”, You are absolutely right, however, we already use the READ API to verify CREATE and UPDATE operations. So why have a separate READ test when we'd just recreate the same steps?
Considering that we need to CREATE a project again and then READ it; Doesn’t it look like exactly the same as the first test which is CREATE and READ for assertion? That’s why we usually end up with not having a separate test for READ. As a rule of thumb in automation, if you end up with two tests that their ARRANGEs, ACTs and ASSERTs look the same, there is a high chance that you are repeating the same test with just another name!
Let's look at how the hooks execute in sequence when we run the "Should update a project" test:
-
beforeEach()
in describe block -
beforeEach()
in second context() block it('Should update a project')
Authentication?
Until now, we've simplified our examples by omitting headers. However, each API call typically requires an authentication token (like JWT Bearer). Let's say we have a programmatic login method that handles authentication and retrieves the auth token. In real project, We usually add these commands inside cy.session()
and keep them as custom commands (cypress/support/commands.js
).
We want to execute this method once before our tests begin. This means we need something that:
- Runs one time before anything
-
Has a scope of the entire
describe()
What fits these requirements? Exactly, a before()
hook inside the describe() hook. Let's implement it:
describe('CRUD Tests for /project', () => {
let project;
let token
before('Login programmatically and fetch the token', () => {
cy.request('POST', '/token', { username: Cypress.env('USERNAME'), password: Cypress.env('PASSWORD') })
.then((res) => {
expect(res.status).to.eq(201)
token = res.body.authToken;
})
})
beforeEach('Delete the project after each test', () => {
cy.request({
method: 'DELETE',
url: '/projects',
headers: { Authorization: token },
failOnStatusCode: false,
}).then((res) => {
if (res.status !== 200 && res.status !== 404) {
throw new Error(`Unexpected DELETE status: ${res.status}`);
}
})
})
context('CREATE and READ', () => {
it('Should CREATE and READ a project', () => {
// CREATE a Project
cy.request('POST', '/project', {'Authorization': token}, { name: 'ABC Project' })
// ...
});
})
context('UPDATE and DELETE', () => {
beforeEach('Create a project before each test', () => {
cy.request('POST', '/project', {'Authorization': token}, { name: 'ABC Project' })
.then((res) => {
project = res.body;
});
})
it('Should update a project', () => {
// Act and Assert
cy.request('PUT', `/project/${project.id}`, {'Authorization': token}, { name: 'Updated Project' })
// ...
cy.request('GET', `/project/${project.id}`).its('body.name')
.should('eq', 'Updated Project');
});
it('Should delete a project', () => {
// Act and Assert
cy.request('DELETE', `/project/${project.id}`, {'Authorization': token})
// ...
});
})
});
- We defined a global variable called
token
- We defined a
before()
hook inside ourdescribe()
block that runs once before all tests. - Inside this hook, we get the authentication token from the response and assign it to our token variable >
token = res.body.authToken
- We then use this token in all subsequent request calls by adding it to the headers: >
{'Authorization': token}
Is there any other way instead of using different contexts?
Sure there are! For example we can filter tests based on other attributes like test titles. While this approach makes tests organization appear simpler, it's not recommended since it creates a dependency on elements likely to change. Additionally, future users may not maintain this practice.
describe('CRUD Tests for /project', () => {
let project;
let token
before('Login programmatically and fet the token', () => {
//...
})
beforeEach('Delete the project after each test', () => {
// STEP 1: delete projects
cy.request('DELETE', `/projects`, {'Authorization': token})
// STEP 2: Create a project only if the test is not a CREATE operation
if (Cypress.currentTest.title.includes('|CREATE|')) return;
cy.request('POST', '/project', {'Authorization': token}, { name: 'ABC Project' })
})
it('Should |CREATE| and |READ| a project', () => {
//...
});
it('Should |UPDATE| a project', () => {
//...
});
it('Should |DELETE| a project', () => {
//...
});
});
We removed the context()
blocks and moved the project creation setup into our single beforeEach()
hook. This hook now has two operations: 1. deleting any existing projects (which runs for every test), and 2. adding a new project for each test IF it's not testing project creation itself. How can we implement this condition?
Notice that line > if (Cypress.currentTest.title.includes('|CREATE|')) return;
, it says if the test title contains |CREATE|, stop at this point and return! otherwise continue and create a new project.
2. Use dynamic variables
2.1 Varibles inside the tests:
Did you notice those dynamic variables project
and token
we have defined in the beginning of the describe block?: As I mentioned, file-scoped or framework-scoped variables help keep tests independent and dynamic. Here we have this project variable that stores project object we create for each test. Take a look at the entire file to see how we use it for various operations.
2.2 Variables available globally
Sometimes we need something to be available in entire framework for all tests, that variable can be defined in cypress config file and is accessible using Cypress.config('VARIABLE')
, if it is something needed to be changed based on environment, we can use env variables as well and access using Cypress.env('VARIABLE')
2.3 Run something before everything else!
Don't forget that we have global hook (cypress/support/e2e.js
) that runs when cypress initializes before any tests start running. Think of it like a pre-test stage in CI, but internal to Cypress. It’s a great place to set up anything environment-related or handle global authentication if all tests use the same credentials.
This is also a good place to add an uncought:exception
handler like the snippet below o prevent Cypress from failing when an unhandled exception occurs in the application:
Cypress.on('uncaught:exception', (e, runnable) => {
console.log(e)
// ignore the error
return false
})
We can also use before()
or beforeEach()
in this file. These hooks will run globally across all tests, just like they do within individual test files, but with a broader scope that applies to the entire test framework.
3. Use random data (string/UUIDs, etc. )
When it comes to isolation, unique test data is crucial. We often have some hardcoded constants test data like fixtures
or mock objects that can be used without any concern. For example assuming our project
has a property called customersMetrics
which is a complicated JSON object that we know and agreed can remain constant across all calls. We simply store this in a file named customer-metric-data.json
in our fixture folder and use it in our test.
But what about projectTitle
?
Consider when you're developing UI tests with approaches from this article (the same Setup stages with API but Test stages with UI). You should locate the project by its title using the locator [data-cy='project-title-GIVENNAME']
, where GIVENNAME
, is something that we should put in the body of the project create post call (thanks to our frontend guy for making this dynamic attribute assigning).
Can we use the same name each time? As long as we operate on One project, we are good. But let's say we want to write the "pagination" or "table filter" tests that require 10 or 20 projects created in test Setup. Then it becomes problematic.
Or Imagine a test fails and you need to debug by checking the logs to find that specific project. With 10 identical project names, how do you know which one failed?!
Or even more complicated, think about that situation I mentioned at the beginning of this article > You, your colleague and those 3 CI machines testing at the same time ….
To solve this issue, we should generate random values for variables and test data. Many libraries like fakerjs or uuid can help with this., or you can create your own random data following some specific patterns. Here’s some examples:
Random String:
const randomString = Math.random().toString(36).substring(2, 10); // oyxbwrhk
const randomEmail= `cypress-test-${randomString}@email.com` // [email protected]
Random valid UUID:
import { v4 as uuidv4 } from 'uuid';
const envId= uuidv4() // 23f73210-68a0-4fbb-b261-8c0301a58dbe
Dynamic locator variable (as mentioned above)
support/utils.js
// Utility method to generate a random name for entities
const generateRandomString = (postfix) => {
const randomString = Math.random().toString(36).substring(2, 10);
return {
generatedString: `Cypress Test ${postfix} ${randomString}`,
locatorValue: `${postfix.toLowerCase()}${randomString}`.replace(/\s/g, '-')
};
}
page-objects/project-page.js
class ProjectPageObjects {
//...
static projectTitle = (str) => `[data-cy='project-title-${str}']`
}
export default ProjectPageObjects;
e2e/api/project-crud.cy.js
import ProjectPageObjects from '../../support/page-objects/project-page.js'
import { generateRandomString } from '../../support/utils.js';
// ...
it('Should |UPDATE| the Project from UI', () => {
// ...
const randomStringForCreate = generateRandomString('Project Updated Name')
const randomProjectNewName = randomStringForCreate.generatedString
const createdProjectLocatorValue = randomStringForCreate.locatorValue
cy.get(ProjectPageObjects.titleEditInput).clear().type(randomProjectNewName)
cy.get(ProjectPageObjects.editSubmitButton).click()
// ...
cy.get(ProjectPageObjects.titleName(createdProjectLocatorValue)).should('be.visible').should('have.text', randomProjectNewName)
})
Cypress Test Isolation option:
There are a bunch of things that often get left behind by previous tests, like cookies, local storage, page context, etc.; not just the data or changes we create by the test. To ensure a truly clean state and full test independence, we need to handle those as well. That’s why Cypress, by default, resets the test environment between tests to make sure each one runs in complete isolation, free from any leftover state. This behavior can be turned off if needed, but it's generally recommended to keep it on. Check out the official documentation and also this blog post for more details.
Thank you for taking the time to read! ❤️ 🙏
If you enjoyed the post, please leave your reactions, comments, and questions. I’d love to hear your feedback!
Follow me on LinkedIn: https://www.linkedin.com/in/monfared/
Happy testing ✌️