DEV Community

Cover image for Testing with Playwright: How to Make Authentication Less Painful and More Readable
a-dev
a-dev

Posted on

Testing with Playwright: How to Make Authentication Less Painful and More Readable

Playwright is a great tool for e2e-tests and more (we'll discuss this another time). It's easy to write tests for what your website displays, but it becomes much harder when you want to test what your application actually does. Especially when users take many steps to achieve their goals - your tests can grow exponentially, with a thousand lines not being uncommon.

This is where abstractions become necessary. Developers start to use POMs (Page Object Models) and custom fixtures. We'll talk about POMs another time (again), for now, I want to tell you how to create a very handy fixture.

Most complex apps have an authentication level (who is this user?) and an authorization level (what can this user do?). In most cases, this is built with role models (RBAC). It's great if you can test your app features with different users/roles.

You can read about the core concept of authentication in the official docs (which I highly recommend). These solutions are great for cases with one authorization level (e.g., admin), but become verbose when you want to change users in different tests or, for example, run one test (not a whole bunch of tests) for five different roles simultaneously.

Look at what the test might look like after we create this fixture:

// admin
test('that {role: admin} can delete any article', async () => {
  //...
});

// more roles
const ROLES = ['admin', 'owner', 'manager', 'guest'] as const;

for (const role of ROLES) {
  test(`that {role: ${role}} can buy a coffee`, async () => {
    //...
  });
}

// specific user
test('that {email: user-email@example.com} can do whatever they want', async () => {
  //...
});

Enter fullscreen mode Exit fullscreen mode

Looks great, doesn't it?

User-roles database

When I started struggling with multiple roles and different business logic expectations in e2e tests, I realized that a simple object defining users, their roles, and other necessary data would be ideal.
The shape of this object isn't crucial, you can create it as you see fit and then add an algorithm to extract data from it.

type Roles = 'admin' | 'owner' | 'manager' | 'guest';
type TestUsersData = {
  [key in Roles]: {
    email: string;
    password?: string; // Don't store real passwords here.
    // These email/password credentials should be created
    // for test purposes only in a test or dev environment/DB
    data: {
      // This isn't used in authorization,
      // but can be useful in tests
      name: string;
      status?: string;
    }
  };
}

const testUsersData: TestUsersData = {
  admin: {
    email: 'admin@example.com',
    password: 'admin',
    data: {
      name: 'Admin',
      status: 'active'
    }
  },
  owner: {
    email: 'owner@example.com',
    password: 'owner',
    data: {
      name: 'Owner',
      status: 'active'
    }
  },
// ... and so on
Enter fullscreen mode Exit fullscreen mode

I've created a simple function to get user data by role, which returns user credentials for authorization:

import { testUsersData } from './test-users-data';

export function defineTestUser({ role, email, password }: Props) {
  if (!(role || email)) return;

  return {
    email: email ?? (role ? testUsersData[role]?.email : undefined),
    password: password ?? 'default-password', // default password
  };
}
Enter fullscreen mode Exit fullscreen mode

The next important step is to create a method that parses data between curly braces from the test title and returns the data:

export function parseDataFromTestTitle(str: string): Record<string, string> {
  // Match all content between curly braces
  const regex = /{([\s\S]*?)}/g;
  const matches = str.match(regex) || [];

  const combinedObject: Record<string, string> = {};

  // Iterate through each matched set of curly braces
  for (const match of matches) {
    // Remove curly braces and split by comma 
    // to get individual key-value pairs
    const keyValuePairs = match.slice(1, -1).split(',');

    for (const pair of keyValuePairs) {
      // Split by colon and trim whitespace to separate key and value
      const [key, value] = pair.split(':').map((part) => part.trim());

      if (key && value) {
        combinedObject[key] = value;
      }
    }
  }

  return combinedObject;
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the idea is simple: you define a user in the test title, and then you can use this data in the fixture to authenticate.

Fixture

Before we create a fixture, we need to create a helper function to authenticate the user.
In my case, I use a JWT token, so I have a function to get a token by user credentials. For cookie-based authorization, the helper will be even simpler.

import { expect, type Page } from '@playwright/test';

export async function authenticateUser({ page, user }: {page: Page, user: UserType}) {
  // Get favicon - this is a small hack to ensure that Playwright
  // opens the browser and is ready to load the page.
  // You can use any other address, but it should load quickly.
  await page.goto('/favicon.ico');


  // Send a POST request to your backend server for authentication
  const response = await page.request.post('your_backend_api_endpoint/login', {
    data: { user },
    timeout: 30000,
  });

 // Handle errors
  if (response.status() === 401) {
    throw new Error(`๐Ÿ”ด Wrong credentials. Check role, email, or password: ${JSON.stringify(response)}`);
  } else if (!response.ok()) {
    throw new Error(`๐Ÿ”ด Failed to authenticate user. Status: ${response.status()}`);
  }

  // Ensure the response is successful
  expect(response.ok()).toBeTruthy();

  // Extract the authorization token from the response headers
  const token = response.headers().authorization;
  await page.evaluate(
    (token: string) => {
      if (!token) {
        throw new Error('No token received from the server');
      }

      // window.localStorage is used to store the JWT token
      localStorage.setItem('jwt', JSON.stringify(token));
    },
    token,
  );

  return { page };
}
Enter fullscreen mode Exit fullscreen mode

And now (finally!) we can create a fixture.

import { test as base } from '@playwright/test';
import path from 'node:path';
import * as fs from 'node:fs';
// don't forget to import authenticateUser, parseDataFromTestTitle and defineTestUser functions

const authenticateUserFixture = base.extend<{ workerStorageState: string | undefined }>({
  storageState: async ({ workerStorageState }, use) => use(workerStorageState),
  workerStorageState: [
    async ({ browser }, use, testInfo) => {
      // Construct the test title path string and get string
      // from test title and title of parent test.describe
      const allPathString = [
        test.info().titlePath.reverse().join(' + '),
        test.info().title,
      ].join(' + ');
      const dataParsedFromTitle = parseDataFromTestTitle(allPathString);
      const user = defineTestUser(dataParsedFromTitle);
      console.info('๐Ÿ parsed user credentials', user);
      if (!user) {
        await use(undefined);
        return;
      }

      // Define the file path for storing authentication state
      const fileName = path.resolve(
        testInfo.project.outputDir,
        `.auth/${user.email}.json`,
      );

      // Check if the authentication state file exists
      // and is fresh (less than 5 minutes old)
      const stats = fs.existsSync(fileName) ? fs.statSync(fileName) : null;
      if (stats && stats.mtimeMs > new Date().getTime() - 5 * 60 * 1000) {
        const origins = JSON.parse(fs.readFileSync(fileName, 'utf-8'))
          ?.origins[0];

        // And again, here I check JWT token in localStorage,
        // but you can check cookies or any other data
        const localStorage = origins?.localStorage;
        const hasJWTToken = Boolean(
          localStorage?.find((i: Record<string, string>) => i.name === 'jwt')
            ?.value,
        );
        if (origins?.origin === testInfo.project.use.baseURL && hasJWTToken) {
          console.info('๐ŸŸก Sign in skipped because token is fresh');
          await use(fileName);
          return;
        }
      }

      // Create a new browser page for authentication
      const page = await browser.newPage({
        storageState: undefined,
        baseURL: testInfo.project.use.baseURL,
      });

      // Authenticate the user and save the storage state
      await authenticateUser({ page, user, locale });
      await page.context().storageState({ path: fileName });
      console.info('[test authentication] ๐Ÿ‘ค ๐Ÿ“ง', user?.email, locale);
      await page.close();

      await use(fileName);
    },

    // You can use {scope: 'worker'} here, but in this case, 
    // authentication will depend on the worker, 
    // which could be faster but can make it harder 
    // to write tests where credentials change
    { scope: 'test' },
  ],
});

Enter fullscreen mode Exit fullscreen mode

Now you can use this fixture in your tests.
I have many fixtures in my project, so I have a helper function to use them. You can read more about this in the official docs.

// tests/index.ts or wherever you have your tests
import { mergeTests } from '@playwright/test';
import { authenticateUserFixture } from './fixtures/authenticate-user-fixture';

const test = mergeTests(
  authenticateUserFixture,
  // ... other fixtures
)

export { test };
Enter fullscreen mode Exit fullscreen mode

This approach offers several advantages:

  • Test titles clearly indicate which user or role is being tested.
  • You can easily test different user roles or specific users without modifying the test code.
  • The fixture reuses authentication tokens when possible, speeding up test execution.
  • User credentials and authentication logic are centralized, making updates easier.

By implementing this system, you can significantly reduce the complexity of managing different user roles in your Playwright tests.

Remember, the specific implementation may vary based on your authentication mechanism (JWT, cookies, etc.), but the core concept remains the same. Adapt this approach to fit your project's needs, and enjoy! ๐Ÿš€

Bonus

I encountered a specific test scenario where I needed to change role in the middle of the test. To address this, I created the 'useUser' fixture.
This fixture differs slightly from the previous one โ€” you don't need to store authentication data in a file, as it operates only within the scope of the test.

export const authenticateUserInContextFixture = base.extend<UseUser, { storageState: string }>({
  useUser: [
    async ({ browser, storageState }, use, workerInfo) => {
      await use(async (userData, options) => {
        const user = defineTestUser(userData);
        if (!user)
          throw Error('๐Ÿ”ด Wrong credentials. Check role, email or password');

        const newContext = await browser.newContext({
          storageState: undefined,
          baseURL: workerInfo.project.use.baseURL,
        });
        const page = await newContext.newPage();
        await authenticateUser({ page, user });
        console.info('[browser-context] ๐Ÿ‘ค ๐Ÿ“ง', user?.email);

        return page;
      });
    },
    { scope: 'test' },
  ],
});
Enter fullscreen mode Exit fullscreen mode

Don't forget to add this fixture to your tests:

const test = mergeTests(
  authenticateUserFixture,
  authenticateUserInContextFixture,
  // ... other fixtures
)
Enter fullscreen mode Exit fullscreen mode

Now you can use it in your tests.
In this example, I have two contexts within one test: one for a user with the 'reader' role and another for a user with the 'admin' role. The reader can only create comments and cannot delete them, while the admin can delete comments.

import { test } from 'tests/index.ts';

test('useUser fixture in the middle of the test. Start with {role: reader}', async ({
  useUser,
  page,
}) => {
  await page.goto('/articles/1');
  // ... add comment to the article. 
  // Use page.click, page.fill, etc.

  const adminPage = await useUser({ role: 'admin' });
  await adminPage.goto('/articles/1');
  // ... delete comment. 
  // Use adminPage.click, adminPage.fill, etc.

  // You can close the admin user's page if you don't need it anymore
  await adminPage.close();

  // get updated data with reader
  await page.reload({ waitUntil: 'domcontentloaded' });
  // ... check that comment isn't here
});
Enter fullscreen mode Exit fullscreen mode

If you run Playwright with the option to open the browser, you'll see two browser windows: one for the reader and another for the admin. This is particularly useful when you need to test different roles within a single test.

Top comments (0)