Skip to content

E2E Testing Optional

playwrighttestcontainersdocker

Supporting Multiple dummy accounts Best Practice

At startup, auth.setup.ts is executed first to handle the login process for all accounts used in parallel. The authentication data for each account is saved in the .auth directory via storageState. During actual tests, you can specify the account name using test.use, skipping the login process and speeding up execution.

What if you want to add a new user?

  • Add the user information to dummyUsers.ts
  • Add a setup to create a session for the new user in setup/auth.ts
  • Run useUser and registerUserToDB before executing the tests
ts
import type { User } from "next-auth";

type RemoveNullish<T> = {
  [K in keyof T]-?: NonNullable<T[K]>;
};

type NonNullableUser = RemoveNullish<User>;

export const user1: NonNullableUser = {
  id: "id1",
  name: "user1",
  email: "user1@a.com",
  image:
    "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAMElEQVR42u3OMQEAAAQAMDrpp4Zuyojh2RIsa7bjUQoICAgICAgICAgICAgICHwHDhv0ROEuXMGUAAAAAElFTkSuQmCC",
  role: "USER",
};

export const admin1: NonNullableUser = {
  id: "id2",
  name: "admin1",
  email: "admin1@a.com",
  image:
    "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN8qS5UDwAExgGj/3sspQAAAABJRU5ErkJggg==",
  role: "ADMIN",
};
ts
import { test as setup } from "@playwright/test";
import { admin1, user1 } from "../dummyUsers";
import { createUserAuthState } from "../helpers/users";

setup("Create user1 auth", async ({ context }) => {
  await createUserAuthState(context, {
    user: user1,
  });
});

setup("Create admin1 auth", async ({ context }) => {
  await createUserAuthState(context, {
    user: admin1,
  });
});
ts
import { registerUserToDB, useUser } from "../helpers/users";

test.describe("user1", () => {
  useUser(test, user1);

  test.beforeEach(async ({ page }) => {
    await registerUserToDB(user1);
  });
});

Mocking and working around next-auth's JWT strategy

JWE is highly secure, making it very difficult to use dummy accounts in tests. To address this, the main code is configured to read a test-specific environment variable(NEXTAUTH_TEST_MODE). When this variable is present, an alternative encoding/decoding method is provided to bypass this limitation.

ts
import type { NextAuthConfig } from "next-auth";

export const configForTest = {
  jwt: {
    encode: async ({ token }) => {
      return btoa(JSON.stringify(token));
    },
    decode: async ({ token }) => {
      if (!token) {
        return {};
      }

      return JSON.parse(atob(token));
    },
  },
} satisfies Omit<NextAuthConfig, "providers">;

Introducing Page Object Models Best Practice

Page Object Models are that large test suites can be structured to optimize ease of authoring and maintenance. They are one such approach provided by Playwright to structure your test suite.

Each page is modeled and inherits from the Base class. One key feature is that all Locators used in tests are defined as members of the class upon instantiation, and these members are referenced thereafter. This approach allows for writing robust code that can handle changes to elements effectively.

ts
import { type Locator, type Page, expect } from "@playwright/test";
import type { User } from "next-auth";

export class Base {
  page: Page;
  headerLocator: Locator;
  headerButtonSignInLocator: Locator;
  headerButtonSignOutLocator: Locator;
  headerLinkMyPageLocator: Locator;
  headerImageMyAvatorLocator: Locator;
  footerLocator: Locator;
  footerLinkRepositoryLocator: Locator;

  constructor(page: Page) {
    this.page = page;
    this.headerLocator = this.page.locator("header");
    this.headerButtonSignInLocator = this.headerLocator.getByRole("button", {
      name: "Sign in",
    });
    this.headerButtonSignOutLocator = this.headerLocator.getByRole("button", {
      name: "Sign out",
    });
    this.headerLinkMyPageLocator = this.headerLocator.locator("a[href='/me']");
    this.headerImageMyAvatorLocator = this.headerLocator.getByRole("img", {
      name: "profile",
    });
    this.footerLocator = this.page.locator("footer");
    this.footerLinkRepositoryLocator = this.footerLocator.getByRole("link", {
      name: "Repository",
    });
  }

  async goToMePage() {
    await this.headerLinkMyPageLocator.click();
  }

  async expectHeaderUI(state: "signIn" | "signOut", user?: User) {
    if (state === "signIn") {
      await expect(this.headerButtonSignInLocator).not.toBeVisible();
      await expect(this.headerButtonSignOutLocator).toBeVisible();
      expect(await this.headerImageMyAvatorLocator.getAttribute("src")).toBe(
        user?.image,
      );
    }

    if (state === "signOut") {
      await expect(this.headerButtonSignInLocator).toBeVisible();
      await expect(this.headerButtonSignOutLocator).not.toBeVisible();
      await expect(this.headerButtonSignInLocator).toBeVisible();
    }
  }

  async expectFooterUI() {
    await expect(this.footerLinkRepositoryLocator).toBeVisible();
    await expect(this.footerLinkRepositoryLocator).toHaveAttribute(
      "href",
      "https://github.com/hiroppy/web-app-template",
    );
  }
}
ts
import { type Locator, type Page, expect } from "@playwright/test";
import { Base } from "./Base";

export class MePage extends Base {
  inputNameLocator: Locator;
  inputNameErrorLocator: Locator;
  buttonSubmitLocator: Locator;

  constructor(page: Page) {
    super(page);

    this.inputNameLocator = this.page.locator('input[name="name"]');
    this.inputNameErrorLocator = this.page.locator("#input-name-error");
    this.buttonSubmitLocator = this.page.locator('button[type="submit"]');
  }

  async goTo() {
    return await this.page.goto("/me");
  }

  async changeName(name: string) {
    await this.inputNameLocator.fill(name);
  }

  async submit() {
    await this.buttonSubmitLocator.click();
  }

  async expectUI(name: string) {
    await expect(this.inputNameLocator).toBeVisible();
    await expect(this.buttonSubmitLocator).toBeVisible();
    await expect(this.inputNameLocator).toHaveValue(name);
  }

  async expectInputNameErrorUI() {
    await expect(this.inputNameErrorLocator).toBeVisible();
    await expect(this.inputNameErrorLocator).toHaveText("name is too short");
  }
}