When building applications with real-time features or collaborative workflows, you'll often encounter scenarios where actions by one user must trigger updates for another. For example:
- Testing a chat app where a message from one user should instantly appear for another
- Verifying permission boundaries in admin/user portals
- Validating notifications or assignments triggered by another account
- Testing multiplayer games where player actions affect others
Traditionally, automating these scenarios can seem daunting - you might imagine needing multiple browser tabs, separate user sessions, or even multiple parallel test runners. However, Cypress was designed with a single-browser, single-tab architecture, meaning it doesn't natively support true multi-tab or multi-window testing. Fortunately, there's a much simpler and more reliable way: by leveraging Cypress's API commands, you can simulate multi-user behavior efficiently, all within a single test.
Let's break down how this works using a simple, chat application scenario: we have two users and want to verify that when User 2 sends a message, User 1 receives it instantly. The pattern follows three main steps:
- Authenticate both users and store sessions
- Track the active API request user
- Dynamically set API request headers for any API request
Step 1: Authenticate Both Users and Store Sessions
The foundation of multi-user testing is the ability to switch between users on demand. To do this, you'll first need to authenticate all relevant users up front and securely store their authentication details for later use.
Below is an example login custom command. It logs in as a specific user and saves the necessary authentication cookies. It utilizes cy.session command for session persistence across tests (this is optional but highly recommended for a performative test suite long-term).
Additionally, this setup uses the cypress-wait-until plugin to extend dynamic waiting on the cy.getCookie command which natively doesn't retry. This is also optional but without, it's possible the cy.getCookie
command will be performed before the application has created all the appropriate tokens.
import 'cypress-wait-until'
// cy.loginViaAuth0UI custom command
Cypress.Commands.add('loginViaAuth0UI', (user: LoginUsers) => {
Cypress.log({ message: `Logging in as ${user}` })
// Set initial value if undefined
!Cypress.env('userSessionCookies') && Cypress.env('userSessionCookies', {})
// This variable will indicate if we are creating or restoring a session
// Either way we will be able to store off the current user's cookies
let initialSessionToken = null
// Your user credentials
// Here we assume they are stored in cypress.config.js as an env var
const { username, password } = Cypress.env('USERS')[user]
cy.session(username, () => {
cy.visit('/') // App landing page redirects to Auth0.
// Replace with your actual Auth0 or authentication provider URL
cy.origin(
'https://your-auth0-url/',
{ args: { username, password } },
({ username, password }) => {
cy.get('input#username').type(username)
cy.get('input#password').type(password, { log: false })
cy.contains('button', 'Continue').click()
},
)
// Ensure we are redirected to the homepage after login
cy.url().should('include', Cypress.env('HOMEPAGE_URL'), { timeout: 15_000 })
// After initial session creation, store cookies
waitUntilTokensExist(user)
initialSessionToken = 'initialSessionToken'
})
// If restoring a session, store off restored cookies
if (!initialSessionToken) {
waitUntilTokensExist(user)
}
// Switch the current user to be the api request user
Cypress.env('apiRequestUser', user)
}
You'll notice that whether a brand-new session or existing session is restored, the login commands waitUntilTokensExist
. Let's check out this helper below
const addTokenIfNotExists = ({ user, cookies }: { user: LoginUsers; cookies: any }) => {
const tokenStore = Cypress.env('userSessionCookies')
// Add our cookies to the Cypress.env('userSessionCookies') env var
if (!tokenStore[user]) {
tokenStore[user] = {
cookies,
}
}
}
// Helper function that waits until cookies are populated for the current user
const waitUntilTokensExist = (user: LoginUsers) => {
// Utilizes the `cypress-wait-until' plugin
// This guarantees the authenticated cookies exist before storing them
cy.waitUntil(() => cy.getCookie('XSRF-TOKEN').then((cookie) => Boolean(cookie && cookie.value)), {
timeout: 25_000,
}).then(() => {
cy.getAllCookies().then((cookies) => {
// Save off cookies if not already added
addTokenIfNotExists({ user, cookies })
})
})
}
waitUntilTokenExists
waits until Cypress detects a key authentication cookie (XSRF-TOKEN
, for example) after login. Only then does it grab all cookies for the user and save them. This avoids race conditions where Cypress would try to use cookies before they're ready.
Finally, it saves off each user's authentication cookies to a shared storage (userSessionCookies
) with a final addTokenIfNotExist
helper. By indexing cookies by user, you ensure each user's credentials are available whenever you need to act as that user.
Step 2: Track the Active API Request User
Once you have multiple users authenticated, you need a mechanism to decide which user is "sending" an API request at any given time. This is accomplished by keeping track of the "active" API request user via an environment variable (like Cypress.env('apiRequestUser')
).
By creating a simple custom command, cy.switchAPIRequestUser()
, which just updates the apiRequestUser
variable, switching between users is trivial. Additionally, since this is a custom command, it's accessible throughout all our specs and tests.
Cypress.Commands.add('switchAPIRequestUser', (user) => {
Cypress.log({ message: `switchAPIRequestUser: ${user}` })
Cypress.env('apiRequestUser', user)
})
Step 3: Dynamically Set API Request Headers
Lastly, now that our user cookies are stored, whenever we need to send a authenticated API request (e.g., simulating User 2 sending a message), we need to set up each API request with the right authentication headers. These two helpers do the heavy lifting:
const getAuthenticationHeaders = () => {
// Retrieve the XSRF token and cookies from the environment variables
// Uses the current request user to get the correct tokens
const { cookies } = Cypress.env('userSessionCookies')[Cypress.env('apiRequestUser')]
// Pull key authentication tokens from cookies
// This might change based on your application's authentication mechanism
const token = cookies.find((cookie: any) => cookie.name === 'XSRF-TOKEN')?.value
const session = cookies.find((cookie: any) => cookie.name === 'SESSION')?.value
// Example of setting authentication headers
return {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': token,
SESSION: session,
cookie: `XSRF-TOKEN=${token}; SESSION=${session}; JSESSIONID=${session}`,
}
}
This function fetches the cookies for the currently selected user (from Step 2) and formats them as headers for an authenticated API request. It abstracts away the manual management of tokens for every request.
const sendRequest = (
options: Partial<Cypress.RequestOptions>,
): Cypress.Chainable<Cypress.Response<any>> =>
cy.request({
...options,
headers: {
...getAuthenticationHeaders(), // Set authentication headers using the current api request user
...options.headers,
},
url: `your base api url/${options.url}`, // Replace with your actual base API URL
})
Lastly, this is a wrapper around cy.request
that automatically injects the correct authentication headers. Now, every API call you make can easily impersonate whichever user you've selected with cy.switchAPIRequestUser
.
Step 4: Simulate Multi-User Interactions in Your Test
Here's how it all fits together in a real-world scenario - simulating two users in a chat app test:
it('should receive a real-time message from another user', () => {
cy.intercept('GET', '/api/v1/notifications/incoming').as('interceptGETIncomingMessage')
// Authenticate with BOTH users first
cy.loginViaAuth0UI(LoginUsers.user2)
cy.loginViaAuth0UI(LoginUsers.user1)
cy.visit('/') // Your app url
// Stay logged in as user1 but switch the API request user to user2
cy.switchAPIRequestUser(LoginUsers.user2)
// Send a message using the API from user2
cy.sendMessage({ message: 'test message' }).then(() => {
// Wait on some received API call from user1's perspective
// Optional based on your application
cy.wait('@interceptGETIncomingMessage')
// Validate as user1 we get a real-time incoming message
cy.get('.message-box').should('be.visible').and('contain', 'test message')
})
})
Remember these steps:
- Authenticate and store both users using your login helper.
- Visit your app as User 1 (the main UI context).
- Switch API context to User 2 and perform some action via the API.
- Wait for the real-time message event and validate it appears for User 1 in the UI.
Using this pattern, you can reliably test real-time, cross-user features - all within one Cypress runner.
Conclusion
Multi-user end-to-end testing in Cypress doesn't have to be complicated. By authenticating all necessary users up front, securely storing their session tokens, and dynamically switching API request context, you can confidently validate real-time and collaborative features.
This approach is faster, more maintainable, and less brittle than juggling multiple browser sessions or tabs. It can easily be extended to more than two users or more complex workflows.
If you're building applications where user-to-user interactions matter, this pattern belongs in your Cypress toolkit.
As always, happy testing!
Top comments (0)