Some time ago, I started looking for a better way to handle login in my E2E tests using Playwright.
At the time, I was using a setup test that executed a basic login flow (opening the app and entering a username and password). After that, I’d save the storageState to a .json file and use it in my tests like this:
test.use({ storageState: './storage/userStorageState.json' });
There’s nothing particularly special about this setup. It's actually one of the recommended approaches in the Playwright documentation.
The issue I ran into was that, even though this login test only ran once at the start of the test suite, it still took some time. Since I have several tests that require different users, this delay became a bit annoying.
Another problem was that occasionally the storageState would become invalid, and I’d have to run the setup again.
At other companies, I used to handle this more efficiently: just make an API request, get the auth token, inject it into the session, and start testing. But in this case, the application needed a lot more than just a token, and some of it came from Cognito, and the rest had to be pulled from my backend.
So, if your app also uses Cognito and you're facing similar issues, the example below might be helpful:
After these changes, our login execution time dropped from over a minute to just 9 seconds. We had 8 users that needed to be authenticated before running the tests.
require('dotenv').config();
const { Auth } = require('aws-amplify');
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const { getBaseURL } = require('./env-config');
const { getCognitoConfig } = require('./cognito-pull-config');
const users = require('./users-config');
async function loginCognitoSetup() {
const { appURL, apiURL } = getBaseURL();
const {user_pool_id, frontend_client_id} = getCognitoConfig();
const awsconfig = {
aws_user_pools_id: user_pool_id,
aws_user_pools_web_client_id: frontend_client_id,
};
Auth.configure(awsconfig);
const makeKey = (cognitoUser, name) =>
`CognitoIdentityServiceProvider.${cognitoUser.pool.clientId}.${cognitoUser.username}.${name}`;
for (const [key, { username, password }] of Object.entries(users)) {
try {
const cognitoUser = await Auth.signIn(username, password);
const { data: cognitoMonolithUser } = await axios.get(
`${apiURL}/api/cognitouser`,
{
headers: {
Authorization: `Bearer ${cognitoUser.signInUserSession.idToken.jwtToken}`,
},
}
);
const storageState = {
cookies: [],
origins: [
{
origin: appURL,
localStorage: [
{
name: makeKey(cognitoUser, 'idToken'),
value: cognitoUser.signInUserSession.idToken.jwtToken,
},
{
name: 'user',
value: JSON.stringify(cognitoMonolithUser),
},
{
name: 'isAuth',
value: 'true',
},
{
name: 'company',
value: JSON.stringify(cognitoMonolithUser.company),
},
{
name: 'tokens',
value: JSON.stringify(cognitoUser.signInUserSession),
},
{
name: makeKey(cognitoUser, 'refreshToken'),
value: cognitoUser.signInUserSession.refreshToken.token,
},
{
name: makeKey(cognitoUser, 'clockDrift'),
value: '0',
},
{
name: 'amplify-signin-with-hostedUI',
value: 'false',
},
{
name: makeKey(cognitoUser, 'accessToken'),
value: cognitoUser.signInUserSession.accessToken.jwtToken,
},
{
name: `CognitoIdentityServiceProvider.${cognitoUser.pool.clientId}.LastAuthUser`,
value: cognitoUser.username,
},
{
name: makeKey(cognitoUser, 'userData'),
value: JSON.stringify(cognitoUser.attributes),
},
],
},
],
};
const fileName = `${key}StorageState.json`;
const filePath = path.resolve(__dirname, '..', 'storage' , fileName);
fs.writeFileSync(filePath, JSON.stringify(storageState, null, 2));
console.log(`Storage created to ${key} (${username}) → ${fileName}`);
} catch (error) {
console.error(`Error to create the user ${username}:`, error.message);
}
}
}
module.exports = loginCognitoSetup;
⚠️ The attributes used in the code above were required for the specific app I was working with. That doesn't mean you'll need all of them, and depending on your setup, you might even need additional ones. That’s why it's crucial to understand exactly how your app manages authentication.
You’ll notice the code above expects some values from files, like appURL, apiURL, username, password, etc.
That’s because, in addition to handling Cognito logins, I also needed the project to support multiple environments. It might not be the most elegant solution out there, but it works and it’s fairly organized. ;)
At the ./config
level, create a file called users-config.js
, and inside it, add:
require('dotenv').config();
module.exports = {
admin: {
username: process.env.MY_USER,
password: process.env.MY_PASS,
}
};
Create another file in the same directory called env-config.js
:
const environments = {
automation: {
app: 'https://localhost.com',
api: 'https://api.locahost.com',
}
};
function getBaseURL() {
const ENV = process.env.NODE_ENV || 'dev';
const config = environments[ENV];
if (!config) {
console.error(`Environment '${ENV}' not found!`);
process.exit(1);
}
return {
ENV,
appURL: config.app,
apiURL: config.api,
};
}
module.exports = { getBaseURL };
Once that's done, you just need to add the login-cognito-setup.js
file as the setup script for your tests.
In your playwright.config.js
file, add this line:
{
name: 'setup',
testMatch: '**/*.cognito.js',
},
{
name: 'e2e-tests',
testMatch: '**/*.spec.js',
dependencies: ['setup'],
},
🔖 One thing to note: we didn’t need to change the test.use
calls in our test files. That’s because we’re saving the new storageState to the same location as before, so everything continues to work as expected.
To run just the login setup:
"test:login-setup": "NODE_ENV=local playwright test --project=setup --config=playwright.config.js"
To run login + tests:
"test:local": "NODE_ENV=local playwright test --project=e2e-tests --config=playwright.config.js"