Component Testing with React/Next.js, Playwright, and Serenity/JS
Continuing my series about Serenity/JS: Dive into the potent pairing of Serenity/JS and Playwright. Witness how this winning combination revolutionizes component testing. Ready to elevate your testing game?
Playwright introduces an experimental component testing approach. In this tutorial, we'll explore how to seamlessly integrate component testing into a React/Next.js application using Playwright in conjunction with Serenity/JS, a powerful testing framework.
This tutorial provides a step-by-step guide to set up and execute tests from scratch. However, if you prefer to skip these steps, you can access the complete example code in this GitHub repository.
Setting Up an Example Application
Before diving into testing, let's establish a baseline React application. Following the recommended guidelines, we'll leverage Next.js as our framework of choice.
We'll initialize a Next.js application in non-interactive mode, incorporating TypeScript and Tailwind CSS.
npx create-next-app@latest next-sjs-ct-tutorial --ts --tailwind --eslint --app --src-dir --import-alias "@/*"
cd next-sjs-ct-tutorial
npm run dev[def]
Once completed, your newly created Next.js application will be accessible at http://localhost:3000/.
Ensure that you've installed at least Node.js version 18.17.0 for Next.js, the minimum required version by the time of writing. You can manage Node.js versions using tools like Volta. For example, pinning the Node.js version to the latest available, currently 20.11.1, can be achieved with:
volta pin node@latest
git add .
git commit -m "Pinned Node.js version using Volta"
Now, let's enhance our basic page by creating a small application with the following user story:
As a user, I want to interact with a counter component that displays a count value and allows me to increase or decrease it within a specified range. The component should feature two buttons: "Increase" and "Decrease."
When I click the "Increase" button, the displayed count value should increment, providing immediate visual feedback. Conversely, clicking the "Decrease" button should decrement the count value. However, these actions should be constrained within a defined range, preventing the count from exceeding a maximum of 10 and a minimum value of 0.
To ensure intuitive interaction, the buttons should visually indicate when they are disabled, preventing further increment or decrement operations beyond the defined range. This clear visual feedback helps me understand the available actions at a glance, enhancing my overall user experience with the counter component.
Create a ./src/components/Counter.tsx
file within your project to implement this
functionality.
'use client'
import { useState } from "react"
export const Counter = () => {
// Define state variables for count and minimum/maximum values
const [count, setCount] = useState(0)
const minCount = 0
const maxCount = 10
// Function to handle click on the "Increase" button
function onIncreaseButtonClick(): void {
// Check if count is less than maximum allowed count
if (count < maxCount) {
// Increment count by 1
setCount(count + 1)
}
}
// Function to handle click on the "Decrease" button
function onDecreaseButtonClick(): void {
// Check if count is greater than minimum allowed count
if (count > minCount) {
// Decrement count by 1
setCount(count - 1)
}
}
// Define button styles for normal, hover, and disabled states
const buttonStyle = "bg-blue-500 text-white font-bold py-2 px-4 rounded"
const hoverStyle = "hover:bg-blue-700"
const disabledButtonStyle = "bg-gray-400 cursor-not-allowed"
return (
<>
{/* Display the current count */}
<div className="flex space-x-2">
<div>Count:</div>
<div
data-testid="count">
{count}
</div>
</div>
{/* Render buttons for incrementing and decrementing the count */}
<div className="flex space-x-4">
{/* Increase button */}
<button
className={`${buttonStyle} ${count >= maxCount ? disabledButtonStyle : hoverStyle}`}
onClick={onIncreaseButtonClick}
disabled={count === maxCount}
data-testid="button-increase">
Increase
</button>
{/* Decrease button */}
<button
className={`${buttonStyle} ${count === minCount ? disabledButtonStyle : hoverStyle}`}
onClick={onDecreaseButtonClick}
disabled={count === minCount }
data-testid="button-decrease">
Decrease
</button>
</div>
</>
)
}
Update ./src/app/page.tsx
to include the Counter component on the main page:
import { Counter } from "@/components/Counter";
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center p-24 space-y-4">
<Counter />
</main>
);
}
With these changes, you can now utilize the increase/decrease counter functionality on your Next.js page.
git add .
git commit -m "Implemented Counter"
Integrating Playwright and Serenity/JS
Now, let's integrate the necessary dependencies for testing, including Playwright and Serenity/JS, along with associated packages for assertions and reporting.
npm install --save-dev \
@serenity-js/assertions \
@serenity-js/console-reporter \
@serenity-js/core \
@serenity-js/playwright \
@serenity-js/playwright-test \
@serenity-js/serenity-bdd \
@serenity-js/web \
@playwright/experimental-ct-react \
rimraf \
npm-failsafe
Update the package.json
file with scripts for cleaning, executing tests,
and generating reports using Serenity/JS.
[...]
"scripts": {
[...]
"clean": "rimraf dist target",
"serenity-bdd:update": "serenity-bdd update",
"test:ct": "failsafe clean serenity-bdd:update test:execute test:report",
"test:execute": "playwright test -c playwright-ct.config.ts",
"test:report": "serenity-bdd run --features ./src/components"
}
[
...]
Define the Playwright configuration file playwright-ct.config.ts
, which includes
settings for test directories, reporters, browser configurations, and other options
for running the tests.
import { defineConfig, devices } from '@playwright/experimental-ct-react'
import { SerenityOptions } from '@serenity-js/playwright-test'
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig<SerenityOptions>({
testDir: './src/components',
/* The base directory, relative to the config file, for snapshot files created with toMatchSnapshot and toHaveScreenshot. */
snapshotDir: './__snapshots__',
/* Maximum time one test can run for. */
timeout: 10 * 1000,
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [
[ 'line' ],
[ 'html', { open: 'never' } ],
[ '@serenity-js/playwright-test', {
crew: [
[ '@serenity-js/serenity-bdd', { specDirectory: './src/components' } ],
'@serenity-js/console-reporter',
[ '@serenity-js/core:ArtifactArchiver', { outputDirectory: './target/site/serenity' } ],
// [ '@serenity-js/core:StreamReporter', { outputFile: './target/events.ndjson' }]
],
} ],
],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Port to use for Playwright component endpoint. */
ctPort: 3100,
/* Set headless: false to see the browser window */
headless: true,
crew: [
[ '@serenity-js/web:Photographer', {
strategy: 'TakePhotosOfInteractions'
} ]
],
defaultActorName: 'Tess',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
})
In this code block, tasks and page elements for Serenity/JS are defined to ensure the functionality of the counter component. Tasks include checking button states, verifying count values, and other assertions related to the counter component.
import { Ensure, contain, equals, not } from "@serenity-js/assertions";
import { Task } from "@serenity-js/core";
import { By, PageElement, isEnabled, Text, CssClasses } from "@serenity-js/web";
export class CounterSerenity {
static CounterLabel = PageElement
.located(By.css('[data-testid="count"]'))
.describedAs('label that shows the current count')
static IncreaseButton = PageElement
.located(By.css('[data-testid="button-increase"]'))
.describedAs('button to increase the count')
static DecreaseButton = PageElement
.located(By.css('[data-testid="button-decrease"]'))
.describedAs('button to decrease the count')
static EnsureIncreaseButtonIsEnabled = () =>
Task.where('#actor ensures that increase button is enabled',
Ensure.that(
CssClasses.of(this.IncreaseButton),
not(contain('cursor-not-allowed')))
)
static EnsureIncreaseButtonIsNotEnabled = () =>
Task.where('#actor ensures that increase button is not enabled',
Ensure.that(
CssClasses.of(this.IncreaseButton),
contain('cursor-not-allowed'))
)
static EnsureDecreaseButtonIsEnabled = () =>
Task.where('#actor ensures that decrease button is enabled',
Ensure.that(
CssClasses.of(this.DecreaseButton),
not(contain('cursor-not-allowed')))
)
static EnsureDecreaseButtonIsNotEnabled = () =>
Ensure.that(
CssClasses.of(this.DecreaseButton),
(contain('cursor-not-allowed'))
)
static EnsureCount = (count: string) =>
Task.where(`#actor ensures count label displays a value of ${count}`,
Ensure.that(Text.of(this.CounterLabel), equals(count)))
}
This section contains the test specifications for the Counter component using Playwright and Serenity/JS. Tests cover scenarios such as initial state verificatio and interactions to reach upper count limits while ensuring button states and count values are correctly maintained.
import { test as componentTest } from '@playwright/experimental-ct-react'
import { useBase } from '@serenity-js/playwright-test'
import { Click, PageElement } from '@serenity-js/web'
import React from 'react'
import { Counter } from './Counter'
import { CounterSerenity } from './CounterSerenity'
import { Duration, Wait } from '@serenity-js/core'
// eslint-disable-next-line react-hooks/rules-of-hooks
const { it, describe } = useBase(componentTest)
describe('Counter', () => {
it('ensures initial state', async ({ mount, actor }) => {
const x = PageElement.from(await mount(
<Counter />
)).describedAs('counter component')
await actor.attemptsTo(
CounterSerenity.EnsureIncreaseButtonIsEnabled(),
CounterSerenity.EnsureDecreaseButtonIsNotEnabled(),
CounterSerenity.EnsureCount('0')
)
})
it('ensures state after click to upper limit', async ({ mount, actor }) => {
const x = PageElement.from(await mount(
<Counter />
)).describedAs('counter component')
await actor.attemptsTo(
CounterSerenity.EnsureIncreaseButtonIsEnabled(),
CounterSerenity.EnsureDecreaseButtonIsNotEnabled(),
CounterSerenity.EnsureCount('0'),
Click.on(CounterSerenity.IncreaseButton),
CounterSerenity.EnsureIncreaseButtonIsEnabled(),
CounterSerenity.EnsureDecreaseButtonIsEnabled(),
CounterSerenity.EnsureCount('1'),
Click.on(CounterSerenity.IncreaseButton),
Click.on(CounterSerenity.IncreaseButton),
Click.on(CounterSerenity.IncreaseButton),
Click.on(CounterSerenity.IncreaseButton),
Click.on(CounterSerenity.IncreaseButton),
CounterSerenity.EnsureIncreaseButtonIsEnabled(),
CounterSerenity.EnsureDecreaseButtonIsEnabled(),
CounterSerenity.EnsureCount('6'),
Click.on(CounterSerenity.IncreaseButton),
Click.on(CounterSerenity.IncreaseButton),
Click.on(CounterSerenity.IncreaseButton),
Click.on(CounterSerenity.IncreaseButton),
CounterSerenity.EnsureCount('10'),
CounterSerenity.EnsureIncreaseButtonIsNotEnabled(),
CounterSerenity.EnsureDecreaseButtonIsEnabled(),
)
})
})
Finally, include a basic HTML template index.html
for the testing page,
and create an entry point (index.tsx) for rendering React components.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Testing Page</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="index.tsx"></script>
</body>
</html>
import '../src/app/globals.css'
With everything set up, you can run the tests using npm run test:ct
,
and find the Serenity-BDD report under ./target/site/serenity
.
Before committing the latest changes, update your .gitignore
file:
# serenity-js
/target/site
# playwright
/playwright-report
/playwright/.cache
This concludes our tutorial on simplifying component testing with React/Next.js, Playwright, and Serenity/JS. Happy testing!
You can find a set of templates at the Serenity/JS GitHub repositories page, including a template for component testing with React