Testing a Next.js Form Component with Playwright
Table of Contents
- Introduction
- Form Component Explanation
- Understanding
data-testid
Usage in Tests - Test Code Explanation
- Playwright Setup and Execution
- 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:
data-testid="items-list"
: This identifier helps us select the unordered list of items.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.