Testing a Next.js Form Component with Playwright

Ozan Tekin
lamalab
Published in
5 min readSep 8, 2024
Lama Lab

Table of Contents

  1. Introduction
  2. Form Component Explanation
  3. Understanding data-testid Usage in Tests
  4. Test Code Explanation
  5. Playwright Setup and Execution
  6. Conclusion

Introduction

Testing user interactions in modern web applications is a crucial part of ensuring quality and user satisfaction. In this article, we will break down a Form component created in a Next.js project and how to thoroughly test it using Playwright. We will also explore the significance of data-testid attributes in testing and guide you through setting up Playwright for testing and running your tests with various methods.

Form Component Explanation

Below is the Form component we will be testing. It allows users to input items, submit them, and dynamically update a list on the page.

"use client";
import { useState } from "react";

export default function Form() {
const [items, setItems] = useState<string[]>([]);
const [inputValue, setInputValue] = useState("");

return (
<>
<form
onSubmit={(e) => {
e.preventDefault();
setItems([...items, inputValue]);
setInputValue("");
}}
className="space-x-2"
>
<label htmlFor="item">Item:</label>
<input
type="text"
name="item"
id="item"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Enter item"
className="text-black h-8 px-4 rounded-md outline-none"
/>

<button
className="bg-white h-8 px-4 rounded-md text-black"
type="submit"
>
Add
</button>
</form>
<ul data-testid="items-list">
{items.map((item, index) => (
<li key={index} data-testid="item">
{item}
</li>
))}
</ul>
</>
);
}

In this component:

  • Users can type into an input field and submit items.
  • The submitted items are dynamically rendered in an unordered list (<ul>).
  • We use the data-testid attribute to help Playwright target and interact with specific elements in the test.

Home page:

import type { Metadata } from "next";
import Link from "next/link";

export const metadata: Metadata = {
title: "Ozan's Next.js Starter",
};

export default function Home() {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Home page</h1>
<Link
href="/form"
className="after:content-['_↗'] text-xl bg-white px-4 py-2 rounded-md text-blue-500 hover:bg-blue-500 hover:text-white transition-colors duration-300"
>
Go to form page
</Link>
</div>
);
}

Form Page:

import type { Metadata } from "next";
import Form from "@/test/app/components/Form";

export const metadata: Metadata = {
title: "Form page",
};

export default function FormPage() {
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">Form page</h1>
<Form />
</div>
);
}

Understanding data-testid Usage in Tests

The data-testid attribute is often used as a stable hook to select DOM elements in tests. It allows us to reference specific elements within the component, regardless of how the component's internal structure or class names change over time.

In our case, we use data-testid in two places:

  1. data-testid="items-list": This identifier helps us select the unordered list of items.
  2. data-testid="item": This identifier is applied to each individual list item in the form.

These attributes are crucial for making our tests resilient to changes in UI layout or class names.

Test Code Explanation

Let’s now walk through the Playwright tests for the Form component. Here's the full test file, followed by an explanation of each part.

import { test, expect } from "@playwright/test";

test.describe("Home page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("http://localhost:3000");
});

test("should have correct metadata and elements", async ({ page }) => {
await expect(page).toHaveTitle("Ozan's Next.js Starter");

await expect(
page.getByRole("heading", { name: "Home page" })
).toBeVisible();

await expect(
page.getByRole("link", { name: "Go to form page" })
).toBeVisible();
});

test("should navigate to form page", async ({ page }) => {
await page.getByRole("link", { name: "Go to form page" }).click();

await expect(page).toHaveTitle("Form page");
});
});

test.describe("Form page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("http://localhost:3000/form");
});

test("should have correct metadata and elements", async ({ page }) => {
await expect(page).toHaveTitle("Form page");

await expect(
page.getByRole("heading", { name: "Form page" })
).toBeVisible();

await expect(page.getByRole("textbox", { name: "Item:" })).toBeVisible();

await expect(page.getByLabel("Item:")).toBeVisible();

await expect(page.getByPlaceholder("Enter item")).toBeVisible();

await expect(page.getByRole("button", { name: "Add" })).toBeVisible();

await expect(page.getByTestId("items-list")).toHaveText("");
});

test("should have empty items list", async ({ page }) => {
const itemsList = page.getByTestId("items-list");

await expect(itemsList).toBeEmpty();
});

test("should add item to the list", async ({ page }) => {
const input = page.getByPlaceholder("Enter item");

await input.fill("Test item 1");

await page.getByRole("button", { name: "Add" }).click();

const item = page.getByTestId("item").nth(0);

await expect(item).toHaveText("Test item 1");

await expect(input).toBeEmpty();

await expect(page.getByTestId("items-list")).not.toBeEmpty();
});
});

1. beforeEach Hook

In each test, we use the beforeEach hook to navigate to the form page before any assertions are made. This ensures that every test starts on a fresh page.

test.beforeEach(async ({ page }) => {
await page.goto("http://localhost:3000/form");
});

2. Metadata and Element Visibility Test

This test checks that the correct title and elements exist on the form page. We ensure that the page title, input field, label, placeholder text, and button are visible.

test("should have correct metadata and elements", async ({ page }) => {
await expect(page).toHaveTitle("Form page");
await expect(page.getByRole("heading", { name: "Form page" })).toBeVisible();
await expect(page.getByTestId("items-list")).toHaveText("");
});

3. Empty Items List Test

We verify that the items list is empty when the form is first loaded.

test("should have empty items list", async ({ page }) => {
const itemsList = page.getByTestId("items-list");
await expect(itemsList).toBeEmpty();
});

4. Add Item to the List Test

This test simulates filling in the input field, clicking the “Add” button, and checking that the new item is added to the list. It also ensures that the input field is cleared afterward.

test("should add item to the list", async ({ page }) => {
const input = page.getByPlaceholder("Enter item");
await input.fill("Test item 1");
await page.getByRole("button", { name: "Add" }).click();

const item = page.getByTestId("item").nth(0);
await expect(item).toHaveText("Test item 1");

await expect(input).toBeEmpty();
await expect(page.getByTestId("items-list")).not.toBeEmpty();
});

This test leverages data-testid="item" to check that the new item appears in the list.

Playwright Setup and Execution

To set up Playwright in your Next.js project, follow these steps:

  • Install Playwright: Use the following command to install Playwright:
pnpm create playwright
  • Run Tests: To run the tests and view the results in the terminal, use:
pnpm exec playwright test
  • Show Reports: To view test results in the browser, run:
pnpm exec playwright show-report
  • UI Mode: To run tests in interactive UI mode, use:
pnpm exec playwright test --ui

Additionally, you can streamline the testing process using the Playwright VS Code extension: VS Code Playwright Extension.

Conclusion

In this guide, we explored how to test a form component using Playwright, utilizing data-testid attributes to ensure robust and stable tests. You learned how to set up Playwright in your Next.js project, write effective tests, and execute them via the terminal or UI. With these tools and techniques, you can confidently test your form and any other components in your application.

--

--