Useful State Management — Playwright

Veera.
5 min readNov 14, 2023

Background

Many a time in the past, when I was involved in developing automated tests, knowingly or unknowingly I tended to create a lot many sessions — as and when authenticating for every test I write with a specific user, and it never stops growing, even if I’d to test 100 tests or 1000 scenarios using the same user (in another word, using the same credentials). I look back now, thinking how shamelessly I’d polluted the app’s session management just for the purpose of testing. After all, this doesn’t happen or reflect anywhere close to users’ behaviors in a real-world situation.

In this blog post, I aim to illustrate optimal ways to handle app authentication and manage user sessions while running automated tests. It has never been easier with the advent of tools like Playwright and Chrome DevTools that offer sophisticated ways of manipulating Web API. Though I use Playwright for illustration purposes, however, I emphasize more on lean thinking rather than processes per se in handling such scenarios in your work.

Authenticate Once —

Let’s begin with a simple use case in which an app under test is authenticated once at the beginning so all other tests on a suite skip the authentication safely. In the illustration below, it can be noted that I authenticate to this test app as a basic user ( more on the user type later in this blog ) by using browser UI with a sequence of steps and saving the authenticated state to a JSON file, that typically has information such as cookies, localStorage, sessionStorage etc using standard Playwright API: storageState

let setAuthState = async (browser, url, loginConfig) => {
let context = await browser.newContext();
let page = await context.newPage(devices['Desktop Chrome']);
await page.goto(url);
await page.locator(`//button[.='Continue']`).click();
await page.getByRole('link', { name: 'Log In' }).click();
await page.locator(`[name='email']`).fill(loginConfig.userName);
await page.locator(`[name='password']`).fill(loginConfig.password);
await page.locator('form button').click();
await expect(page.getByText('5v1988')).toBeVisible({ timeout: 999});
await context.storageState({path:`state/${loginConfig.type}.json`});
await context.close();
}

The state is set successfully now, and let’s figure out that as to how the remaining tests are structured or organized by effectively skipping the authentication flows.

test('Login as basic user', async () => {
let userType = config.users.find(user => user.type === 'basic').type;
let newContext = await browser.newContext({ storageState: `state/${userType}.json` });
let newPwPage = await newContext.newPage();
await newPwPage.goto(config.url);
await expect(newPwPage.getByText('5v1988')).toBeVisible({ timeout: 5000 });
await newContext.close();
}, timeOut.halfSecond);

From the above code snippet, it can be noticed that, while creating context, the stored state from local is re-used to create a new session, and therefore the user sees the home screen soon after the app URL is loaded on the browser and a sequence of login related actions are skipped.

Separate states by user types —

More often than not, I use more than one user credentials that belong to different roles/groups in testing scopes to validate different user journeys, and their related access and entitlements. In such cases, I maintain a single JS file centrally ( or any configuration file for that matter ) that holds the details such as user types, and user credentials.

const stateConfig = {
url: 'https://giphy.com',
users: [
{
type: 'basic',
userName: 'basic.user@gmail.com',
password: '**********'
},
{
type: 'premium',
userName: 'premium.user@gmail.com',
password: '**********'
}
]
}

After grouping all available users for the app under test in a configuration, I use these configurations to set up states for each of these users one time before the actual tests so they are readily available to consume for the rest of the tests, irrespective of user types throughout the runtime.

...
//Set the states before the tests for each type
let basicUserConfig = config.users.find(user => user.type === 'basic');
await setAuthState(browser, config.url, basicUserConfig);
...
//Re-use the state by filtering type
let userType = config.users.find(user => user.type === 'basic').type;
let newContext = await browser.newContext({ storageState: `state/${userType}.json` });

Switch from API to Browser context —

While testing especially micro-service-based apps, I intend to switch between API and UI contexts seamlessly within the user’s session to make tests resilient and reduce execution time. In the following code snippets, I authenticate to the app by sending a post API call with credentials and once successfully authenticated, I store the session information to the local state JSON file for further uses.

let setAuthStateWithApi = async (url, loginConfig) => {
let loginRequest = await request.newContext({
baseURL: url
});
let response = await loginRequest.post(`/api/v1/users/login`, {
headers: {
'accept': '*/*',
'accept-language': 'en-GB'
},
multipart: {
"email": "basic.user@gmail.com",
"password": "***********",
}
});
expect(response.status()).toBe(200);
await loginRequest.storageState({ path: `state/${loginConfig.type}.json` });
await loginRequest.dispose();
}

While running the actual tests based on UI, I create browser context just by re-using the session information saved on the previous API call. Upon loading the app’s URL, I see that the home page is loaded directly, continuing with the search. This is yet another way of setting up session states but using API calls so we can bypass the authentication steps of other tests in a suite.

test('Search as basic user after api authentication', async () => {
let userType = config.users.find(user => user.type === 'basic').type;
let newContext = await browser.newContext({ storageState: `state/${userType}.json` });
let newPwPage = await newContext.newPage();
await newPwPage.goto(config.url);
await expect(newPwPage.getByText('5v1988')).toBeVisible({ timeout: 5000 });
await newPwPage.locator('#continue-button').click();
await newPwPage.locator('div.giphy-search-bar input').fill('be serious');
await newPwPage.locator('div.giphy-search-bar div svg').click();
await expect(newPwPage.getByRole('heading', { name: 'be serious' }))
.toBeVisible({ timeout: 5000 });
await newContext.close();
}, timeOut.oneSecond);

Switch from Browser to API context —

Let’s say that the app under test is already authenticated in the browser, and I’d like to make a few API calls in the same test using the information at hand. Let’s consider the possibilities as to how generally API’s are authenticated on modern apps.

  1. API Key — In this approach, it is straightforward as the key is to be passed to the runtime as an environment variable.
API_KEY=3iFPqabDx71SMoOemSHiYfh9FY0nzO9x

2. Access Token — In an authenticated state, mostly an access token is persisted on the cookies or sometimes on session storage. In such cases, we can directly retrieve the relevant keys as shown in the following snippet.

let accessToken;
for (let cookie of await newContext.cookies()) {
if (cookie.name == 'access_token') {
accessToken = obj.value;
}
}

I believe that you find this blog as a useful resource. By the way, all the code examples used in this blog are here in this repository for any further reference. thanks for reading!

Veera.

--

--

Veera.

I'm a Software QE professional with over 14 years of industry experience; https://www.linkedin.com/in/5v1988