Skip to content

E2E Testing Basics with Playwright

End-to-end (E2E) testing is a crucial piece of the application testing puzzle. E2E testing generally refers to testing your application as an entire system as opposed to testing it in small isolated pieces like unit tests. This post introduces Playwright, a powerful E2E testing framework that offers APIs in several different programming languages. Before we dive in, let's take a high-level look at some of its most noteworthy features.

  • Component testing - This post is specifically about E2E tests but being able to test your components in isolation with the same framework is a really nice feature.
  • Incredible flexibility
    • Headless and headed test modes
    • Multiple browsers (Chromium, Firefox, WebKit), and mobile device testing environments
    • API is available in several different languages
    • Single-page and multi-page app testing
    • Project config supports running particular sets of tests against specific browsers and environments
  • Multiple browser contexts - Useful for testing multiple sessions or user accounts simultaneously
  • Parallel test execution - Runs tests in parallel by default
  • Test Generation - You can interact with your web app UI in the headed Playwright browser and have it generate selectors and test code for you. We won’t touch on it in this article, but you can learn more about it here
  • Debugging - Has built-in support for debugging

Playwright in Practice

In this post, we will reference a fictional web application with authentication that includes user login and profile setup flows to illustrate how you can use Playwright to test an application.

Here is the directory structure for our tests:

playwright.config.ts
test/ - This directory contains all of our Playwright test files
test/artifacts/ - This directory is used to store any artifacts generated during the testing process, such as screenshots or videos
test/fixtures/ - This directory contains any fixtures that we use in our tests, such as images or data files
test/models/ - This directory contains page object models that we use to interact with the different pages of our application
test/specs/ - This directory contains all of our tests

Playwright configuration

playwright.config.ts is where you can specify various options and features for your testing setup. The projects option is where things get really interesting. You can use projects to configure different sets of tests to run on different browsers and devices. We can add tags to our test description to target particular tests for specific projects. For example, we can tag particular tests with @firefox and configure a project that only runs it against the Firefox browser. This might be useful for testing against regressions for a browser specific bug. We are also able to match against file names. In the project config below, we are ignoring tests with .mobile in the filename in all of our projects except the one that is mobile-specific.

Here are some reference pages for some of the features, and config options covered here:

import type { PlaywrightTestConfig } from "@playwright/test";
import type { TestOptions } from "./test/test";
import { devices } from "@playwright/test";

const BASE_URL = "<http://localhost:8811>";

const config: PlaywrightTestConfig<TestOptions> = {
  testDir: "test",
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: BASE_URL,
    trace: "on-first-retry",
  },
  projects: [
    {
      name: "default",
      testIgnore: /.*mobile.spec.ts/,
    },
    {
      name: "firefox",
      grep: /(@critical|@smoke|@firefox)/,
      testIgnore: /.*mobile.spec.ts/,
      use: {
        ...devices["Desktop Firefox"],
      },
    },
    {
      name: "edge",
      grep: /(@critical|@smoke|@edge)/,
      testIgnore: /.*mobile.spec.ts/,
      use: {
        ...devices["Desktop Edge"],
      },
    },
    {
      name: "safari",
      grep: /(@critical|@smoke|@safari)/,
      testIgnore: /.*mobile.spec.ts/,
      use: {
        ...devices["Desktop Safari"],
      },
    },
    {
      name: "mobile",
      testMatch: /.*mobile.spec.ts/,
      use: {
        ...devices["iPhone 11"],
      },
    },
  ],
  webServer: {
    env: {
      NODE_ENV: "test",
      MSW_ENABLED: "1",
      TEST_ROUTES_ENABLED: "1",
      WEBSITE_URL: BASE_URL,
      PORT: "8811",
    },
    command: "yarn start",

The webServer option allows specifying a web server for server-rendered and API servers. You can provide a command to start the server, and Playwright will wait for it to be ready before running your tests.

Artifacts and Fixtures

In addition to the models and specs directories, we also have an artifacts directory and a fixtures directory.

The artifacts directory is used to store any artifacts generated during tests, such as screenshots or videos. Playwright can automatically take screenshots or videos of test runs, and these will be saved in the artifacts directory.

The fixtures directory, on the other hand, contains any static data that might be needed during tests, such as images or data files. For example, in our auth-flow tests, we use an avatar image stored in the fixtures directory to simulate a user uploading an avatar image during the profile setup process.

Selectors and Basic Test Writing

In Playwright, we use selectors to target and interact with specific elements on a page. Playwright supports a wide range of selector engines, including CSS, XPath, and text-based selectors. You can also create custom selectors to suit your specific testing needs.

When writing tests, we typically use the page.locator() method to target a specific element using a selector. For instance, you can use CSS selectors to target elements by their class, ID, or attributes. Here's an example that demonstrates how to use selectors to interact with a simple login form:

test("User can log in", async ({ page }) => {
  // Navigate to the login page
  await page.goto("/login");

  // Locate the email input field using a CSS selector and fill it with a value
  const emailInput = page.locator('input[type="email"]');
  await emailInput.fill("user@example.com");

  // Locate the password input field using a CSS selector and fill it with a value
  const passwordInput = page.locator('input[type="password"]');
  await passwordInput.fill("password123");

  // Locate the submit button using a text-based selector and click it
  const submitButton = page.locator("text=Log In");
  await submitButton.click();

  // Check if the user is redirected to the dashboard after logging in
  expect(page.url()).toContain("/dashboard");
});

In this example, we used CSS selectors to target the email and password input fields and a text-based selector to locate the login button. We can call methods, such as fill() and click(), that simulate user interactions and verify that the application behaves as expected. If you want to learn more on this topic, check out the writing tests page of the documentation.

Page Object Models

One pattern that Playwright recommends in its documentation is called Page Object Models. A page object model is a design pattern for organizing tests in a way that makes them more maintainable and encapsulates reusable logic. The basic idea is to define a set of classes that represent the pages and components of your web application, and then use these classes in your tests to interact with the application.

For testing our applications, auth flows we might add a auth-flow.ts file. We can define an AuthFlow class that represents the authentication flow of our web application. This class encapsulates all the logic for requesting magic links, creating user accounts, and clicking links in the email sent to the user. By defining this class, we can write more readable and maintainable tests that use the AuthFlow class to perform the necessary actions.

We accept a page argument in our constructor. This is a special argument that is always available in the browser context of our tests. It allows us to interact with the page via selectors and other easy to use APIs. There is a dedicated page in the documentation for this special object.

export class AuthFlow {
  readonly page: Page;
  email?: string;
  _baseURL: string;
  _signInLink?: string;

  constructor({ page, baseURL }: { page: Page; baseURL?: string }) {
    this.page = page;
    this._baseURL = baseURL ?? "<http://localhost:8811>";
  }

  async requestMagicLink(email: string, redirectTo: string) {
    this.email = email;

    const signInLink = await api.generateSignInWithEmailLink(email, {
      url: redirectTo,
      handleCodeInApp: true,
    });

    this._signInLink = encodeURL(signInLink);
  }

  async clickMagicLink() {
    if (!this.email) {
      throw new Error("Magic link must be requested before calling clickMagicLink");
    }
    const user = await api.user.findByEmail(this.email);
    await this.page.goto(this._getMagicLink(Boolean(user)));
  }

	_getMagicLink(hasAccount: boolean = false) {
    return `${this._baseURL}/${hasAccount ? "welcome-back" : "create-account"}?signin=${this._signInLink}`;
  }
}

Specs

Finally, let's take a look at the specs directory. This directory contains the actual test files that use the AuthFlow class to perform the authentication and profile setup flows. new-user.spec.ts

For example, the new-user.spec.ts file contains a test that verifies that a new user can complete the authentication flow, and be successfully redirected. In this example, we use the test.describe.serial test annotation to run the tests one after the other, since the second (sign-out) test depends on the user being logged in. (By default, Playwright runs as many tests as it can in parallel). We use test.beforeAll to run setup code (creating a magic link and user account) and test.afterAll to run any cleanup at the end of the tests.

One of the first things you’ll notice is the callback functions that we pass to the Playwright test methods like beforeAll get called with an object argument that includes things like browser and baseURL. The browser object provides an API to the actual browser that our tests are running in, and we can use it to create and open new pages like we do in the first couple of lines of the beforeAll below.

// new-user.spec.ts

test.describe.serial("New user auth flow @critical", () => {
  let page: Page;
  let authFlow: AuthFlow;

  test.beforeAll(async ({ browser, baseURL }, { project, title }) => {
    const context = await browser.newContext();
    page = await context.newPage();

    authFlow = new AuthFlow({ page, baseURL });
    await authFlow.requestMagicLink('e2e+user@fake.com', `${baseURL}/home`);
    await authFlow.clickMagicLink();
  });

  test.afterAll(async () => {
    await authFlow.cleanup();
    await page.close();
  });

  test("Can create an account and be redirected to /profile-setup", async ({ baseURL }) => {
    await authFlow.submitCreateAccountForm({ displayName: "Dr. Bob" });
    expect(page.url()).toContain(`${baseURL}/profile-setup`);
  });

	test('Can sign out from the user dropdown menu', async () => {
    await page.locator('button:has-text("Dr. Bob")').click();
    await Promise.all([
      page.waitForNavigation(),
      page.locator('a[role="menuitem"]:has-text("Sign out")').click(),
    ]);
    await expect(page.locator('button:has-text("Sign In")')).toBeVisible();
  });
});

profile-setup.spec.ts

Next, we have a profile-setup.spec.ts file that contains a test that verifies that the profile setup form can be filled out and changes can be successfully saved. Tests similar to this will be pretty common in most web applications. Luckily Playwright’s API and selectors make it pretty easy. We are able to test form accessibility here by trying both clicking and tabbing to the next fields.

test("Can fill out the profile setup form and have changes saved", async ({
  page,
  baseURL,.
  existingUser: _,
}) => {
  await page.goto(`${baseURL}/profile-setup`);

  const $unsavedText = page.locator("text=You have unsaved changes");
  const $savedText = page.locator("text=Changes saved!");
  const $saveBtn = page.locator("text=Save Profile");

  await page.setInputFiles("#avatar-file", "./test/fixtures/images/avatar.avif");
  await expect.soft($savedText).toBeVisible();
  const avatarSrc = await page.locator('img[alt="Your Profile Photo"]').getAttribute("src");
  expect(avatarSrc).not.toBeFalsy();

  const $locationInput = page.locator('[name="location"]');
  await $locationInput.fill("Akron, OH");
  await expect($saveBtn).not.toBeDisabled();
  expect($unsavedText).toBeVisible();
  await $locationInput.press("Tab");

  await $saveBtn.click({ force: true });
  await expect($savedText).toBeVisible();
});

sign-in.spec.ts

And finally, we have a sign-in.spec.ts file that contains a test that verifies that the sign-in modal can be opened and a magic link can be sent.

test("Can open the sign in modal and have a magic link sent @smoke", async ({ page, baseURL }) => {
  await page.goto(baseURL ?? "<http://localhost:8811>");
  await page.locator('button:has-text("Sign In")').click();
  await page.locator('[placeholder="Email Address"]').fill("e2e+user@fake.com");
  await page.locator("text=Send magic sign in link").click();
  await expect(page.locator("text=Magic link sent!")).toBeVisible();
});

Once you get familiar with the API, and the tools that Playwright makes available to you, writing tests for your application becomes pretty easy… and dare I say… fun?

Summary

There is so much more that we can cover when it comes to Playwright because it’s such a fantastic and powerful tool. Hopefully, this served as a good introduction to help you get started writing some basic E2E tests for your application. We covered some good ground after the intro by looking at some of the configuration options Playwright provides, and then writing some example tests for a fictional authentication flow. Our page object model implementation provided a nice abstraction for working with our app’s authentication mechanisms.

Please keep in mind that this is not a prescription for how you need to write or structure your tests. The specific setup in this post is more to serve as a guide and a way to introduce some of the features and ways to start writing some tests. The most important part is that you can meet your application testing goals, and enjoy your time building out your tests!