Testing front-end code
While building the new blog, I started thinking about testing front-end code again.
In this post I will revisit Playwright Test class, and look at the types of testing that we can do to ensure that the site works as intended.
Why test front-end code? #
I get caught in a deceptively simple question: Why do we need to test the front-end?
Because testing will help you catch errors that you don't see. I've had multiple instances where I thought I typed one thing and I had typed something completely different.
Testing can also help you identify potential performance bottlenecks before you deploy your application.
Using Playwright Test #
The @playwright/test
package is the testing portion of Playwright. In addition to the core automation features available to Playwright, it provides testing-specific features like test
and expect
.
Rather than installing the package, we use npm init to create a package.json
file if one is not present or update the project's package.json
with Playwright-related information.
npm init playwright@latest
Playwright configuration #
One of the things that Playwright adds to an existing project is a configuration file. I've modified the default configuration file.
We first load the required functions from @playwright/test
and optionally require dotenv
to store sensitive information in an .env
file.
const { defineConfig, devices } = require('@playwright/test');
// require('dotenv').config();
We use Common.js' modules.exports
to export the the full configuration defined with defineConfig
. The first part sets the parameters for the testing.
Option | Description |
---|---|
testDir | Directory with the test files. |
fullyParallel | have all tests in all files to run in parallel. See Parallelism and sharding for more details. |
retries | The maximum number of retry attempts per test. See Test Retries to learn more about retries. |
forbidOnly | Whether to exit with an error if any tests are marked as test.only. Useful on CI. In the example we use a ternary operator to only set value to true if we're running on a CI environment |
reporter | The reporter to use. See Test Reporters to learn more about available reporters. |
workers | The maximum number of concurrent worker processes to use for parallelizing tests. Can also be set as percentage of logical CPU cores, e.g. '50%'. In this example we use a ternary operator to set the workers to 2 when working in CI environments and to 0 or the undefined value otherwise. See Parallelism and sharding for more information. |
use | Options for the use{} global configuration test. |
module.exports = defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://127.0.0.1:8080',
trace: 'on-first-retry',
},
The projects
section, as specified below, tells Playwright what browsers to use when running the tests. The example includes desktop and mobile browsers.
projects: [
/* Test against branded browsers. */
{
name: 'Microsoft Edge',
use: { ...devices['Desktop Edge'], channel: 'msedge' },
},
{
name: 'Google Chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
},
{
name: 'Safari',
use: { ...devices['Desktop Safari'] },
},
{
name: "Firefox",
use: {...devices['Desktop Firefox']}
},
/* Test against mobile viewports. */
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
Playwright configuration also allows us to configure a local web server that will run before starting the tests. This allows Playwright to work against the local copy of the application.
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://127.0.0.1:8080',
// reuseExistingServer: !process.env.CI,
// },
});
Writing the tests #
To start we require the parts of @playwright/test
that we will need. For basic tests these parts are test
and expect
.
The @ts-check
declaration in the comment at the very top of the file will make it easier for VS Code to typechek the Javascript file.
// @ts-check
const { test, expect } = require('@playwright/test');
Playwright tests do two things:
- Perform actions
- Assert the state against expectations
Navigation #
Before we can work on an objectin our target page we need to navigate to that page using the goto
method of the page
object.
test('has title', async ({ page }) => {
await page.goto('https://publishing-project.rivendellweb.net');
// Once the page.goto promises return we
// can do something with the results
});
We can also use the page fixture to set the viewport size for the tests that we want to run.
const page = await browser.newPage();
await page.setViewportSize({
width: 640,
height: 480,
});
await page.goto('https://publishing-project.rivendellweb.net');
This will run all subsequent tests in the smaller vieport size.
For more information on the page fixture, see the page documentation.
Locators #
Once we've pointed Playwright to a page, we need to tell it the specific element of the page we want to work with using locators.
There are several typesof locators, but I will look at the two that have been the most valuable to me: CSS selectors and simple locators.
Selectors are tricky because they can be a fixture like the page object or in more specific selectors.
The first example uses the toHaveTitle
locator to test if the page title contains the string "Publishing Project".
test('has title', async ({ page }) => {
await page.goto('https://publishing-project.rivendellweb.net');
await expect(page).toHaveTitle(/Publishing Project/);
});
The second example uses a simple locator with the :has-text
pseudo class.
test('has title', async ({ page }) => {
await page.goto('https://publishing-project.rivendellweb.net');
await page.locator('article:has-text("Playwright")').click();
});
The locators guide has basic information about locators: How to use them and what are the recommended locators to use.
There are additional locators built into Playwright. Look at Other locators
Assertions #
So far we've told Playwright the URL of the page and the specific element within the page we want to test. Now we need to tell Playwright what the actual test it. We do this with a combination of the expect
functions and assertion matchers.
The assertions in the list below will continue retrying until they succeed or timeout.
Note
You must use await
with these assertions
Assertion | Description |
---|---|
await expect(locator).toBeAttached() | Element is attached |
await expect(locator).toBeChecked() | Checkbox is checked |
await expect(locator).toBeDisabled() | Element is disabled |
await expect(locator).toBeEditable() | Element is editable |
await expect(locator).toBeEmpty() | Container is empty |
await expect(locator).toBeEnabled() | Element is enabled |
await expect(locator).toBeFocused() | Element is focused |
await expect(locator).toBeHidden() | Element is not visible |
await expect(locator).toBeInViewport() | Element intersects viewport |
await expect(locator).toBeVisible() | Element is visible |
await expect(locator).toContainText() | Element contains text |
await expect(locator).toHaveAttribute() | Element has a DOM attribute |
await expect(locator).toHaveClass() | Element has a class property |
await expect(locator).toHaveCount() | List has exact number of children |
await expect(locator).toHaveCSS() | Element has CSS property |
await expect(locator).toHaveId() | Element has an ID |
await expect(locator).toHaveJSPropert() | Element has a JavaScript property |
await expect(locator).toHaveScreenshot() | Element has a screenshot |
await expect(locator).toHaveText() | Element matches text |
await expect(locator).toHaveValue() | Input has a value |
await expect(locator).toHaveValues() | Select has options selected |
await expect(page).toHaveScreenshot() | Page has a screenshot |
await expect(page).toHaveTitle() | Page has a title |
await expect(page).toHaveURL() | Page has a URL |
await expect(response).toBeOK() | Response has an OK status |
Hooks #
Most of the time there are tasks that we will want to run before or after each test or before or after we run all our tests.
Some of these tasks may include:
- Set/tear down a test database
- Prepare navigation or locators that will be shared among tests
In the example below, we use the beforeEach
hook to go to the site we want to test.
test.beforeEach(async ({ page }, testInfo) => {
console.log(`Running ${testInfo.title}`);
await page.goto('https://publishing-project.rivendellweb.net');
});
test('my test', async ({ page }) => {
expect(page.url()).toBe('https://publishing-project.rivendellweb.net');
});
The hooks that I use more often are listed in the table below.
BeforeAll
and afterAll
will run before all the tests execute.
beforeEach
and afterEach
will run before each test.
Hook | Description |
---|---|
beforeEach | Runs before each test |
afterEach | Runs after each test |
beforeAll | Runs once per worker before all tests |
afterAll | Runs once per worker after all tests |
Running the tests #
To run the tests you've created, run the following command if you want to run the tests in the command line.
npx playwright test
The following command will run a UI so you can choose what tests to run and in what order.
npx playwright test --ui
If you run the CLI command, you can get a GUI with the results with the following command:
npx playwright show-report
Finally, you can run the following command to debug your playwright tests:
npx playwright test --debug
Example tests #
I've created examples of Playwright tests to illustrate the topics that we've covered in this post.
The tests cover a basic set of tasks that you can accomplish with Playwright. They are offered as a starting point.
Example 1: Find and click a button containing "Submit".
test('has title', async ({ page }) => {
await page.goto('https://example.com');
await page.getByText('Submit').click();
})
Example 2: Find a link with text starting with "Learn More" and navigate to it.
test('Find link with "Learn more text"', async ({ page }) => {
await page.goto('https://example.com');
await page.getByText(/Learn More/).click();
})
Example 3: Find and fill the second element containing the text "Product Name".
test('Find and fill the second product name item', async ({ page }) => {
await page.goto('https://example.com/products');
await page.locator('.product-name')
.nth(1) // 0 based
.fill('My Awesome Product');
})
Example 4: Find all buttons with the class "primary-button" and click the first one.
test('Find primary-button buttons', async ({ page }) => {
await page.goto('https://example.com');
await page.locator('button.primary-button')
.first()
.click();
})
Example 5: Find and clear the input element with the id "username".
test('Clear user name input', async ({ page }) => {
await page.goto('https://example.com/login');
await page.locator('#username').clear();
})
Example 6: Find the element with specific attributes and click it.
test('Click about link', async ({ page }) => {
await page.goto('https://example.com');
await page.locator('a[href="/about"]').click();
})
Example 8: Find the checkbox element labeled "Remember Me" and check it.
test('check remember me checkbox', async({ page }) => {
await page.goto('https://example.com/login');
const rememberMeCheckbox = page.getByLabel('Remember Me');
await rememberMeCheckbox.check();
})
Headless versus headed #
By default Playwright will run the tests in headless mode. There may be times when you want to see how Playwright interacts with the page. To do so, run playwright with the --headed
flag.
npx playwright test --headed