In the previous article, I covered unit testing my application using the @testing-library
library. Here, I will describe why and how I apply end-to-end tests. I am using the Playwright
library.
Why
The Playwright
framework emulates the behavior of browsers using their own engines. While testing-library
prepares a simplified version of the HTML document, Playwright
renders the document exactly as it will be displayed to site visitors (and can do so for different devices).
If with @testing-library
, we could only check a number of functions tied to CSS through indirect signs, then now we can test everything directly.
Configuration
To configure, just follow the standard instructions posted on the framework website. After that, made some adjustments to fit my needs.
Firstly, change the locations of the standard folders in the playwright.config.ts
.
// /playwright.config.ts
const config: PlaywrightTestConfig = {
testDir: './src/__e2e__',
...
reporter: [ ['html', { outputFolder: './src/__e2e__/report' }] ],
...
outputDir: './src/__e2e__/test-results/',
...
}
Thus, the tests were moved to the src
folder. In the same file, remove comments for several mobile devices so that the website could be checked on them: Pixel 5
and iPhone 12
.
Secondly, we add scripts to package.json
.
....
"scripts":{
...
"e2e": "npx playwright test",
"e2e-update": "npx playwright test --update-snapshots",
...
}
...
The first command runs the test. The second runs the test with updates to the saved screenshots.
Thirdly, in case the project grows and new pages appear in it, in advance added an object to store the page addresses. This will simplify the readability and maintenance of the code. Also add a file with constants.
// /src/__e2e__/helpers/pages.ts
const baseUrl = 'http://localhost:3000/'
export const pages = {
home: baseUrl
}
Now, we can use page.goto(pages.home)
instead of the more abstract page.goto('http://localhost:3000/')
.
Add a file with constants.
// /src/__e2e__/helpers/variables.ts
export const changeThemeDuatationMs = 500
Checking the side menu
The visibility and animation of the side menu are fully implemented through CSS: the menu component itself has the attribute data-state-popupmenu-status={ props.menuStatus }
set on it, which toggles the side menu to either "pop up" or "hide".
The CSS code looks like this:
/* /src/components/Layout/Popupmenu/popupmenu.module.css
.popupmenu {
position: fixed;
top: 0;
right: 0;
margin-right: -350px;
width: 300px;
height: 100%;
border-left: 1px solid var(--color-grey);
background-color: var(--color-background);
transition: margin var(--basic-duration), opacity var(--basic-duration), background-color var(--basic-duration);
}
.popupmenu[data-state-popupmenu-status="open"] {
margin-right: 0px;
transition: margin var(--basic-duration), background-color var(--basic-duration);
}
When testing this component with @testing-library
, all we could do is to check the value of the data-state-popupmenu-status
attribute and rely on CSS to handle the value change as required.
Thanks to Playwright
, we can now check whether the menu is visible or not. To do this, we will create a helper function:
// /src/__e2e__/utils/isInScreenAxisX.ts
import { Locator, Page } from "@playwright/test"
type Props = {
element: Locator,
page: Page
}
export const isInScreenAxisX = async (props: Props): Promise<boolean> => {
let isIn = false
const rect = await props.element.boundingBox()
const viewportSizes = props.page.viewportSize()
if (rect === null) throw new Error("element's boundingBox is null")
if (viewportSizes === null) throw new Error("page's viewportSize is null")
if (rect.x < viewportSizes.width && (rect.x + rect.width) > 0) {
isIn = true
}
return isIn
}
The function takes the screen size values props.page.viewportSize()
and the object's coordinates pops.element.boundingBox()
, and then simply checks whether the element fits within the screen boundaries or not.
Here I am only checking the horizontal axis.
The test file for this function looks like this. First, we load the page and check that all the required elements have been found.
// /src/__e2e__/PopupMenu.spec.ts
test.describe('Button open / close menu', () => {
let page: Page
let burgerIcon: Locator
let popupMenu: Locator
let closeMenuIcon: Locator
test.beforeEach(async ({ browser }) => {
page = await browser.newPage()
await page.goto(pages.home)
burgerIcon = page.getByRole('button', { name: /open menu/i })
popupMenu = page.getByRole('navigation', { name: /popup menu/i })
closeMenuIcon = page.getByRole('button', { name: /close menu/i })
})
test("All elements on the page", async () => {
await expect(burgerIcon).toBeVisible()
await expect(popupMenu).toBeVisible()
await expect(closeMenuIcon).toBeVisible()
})
...
})
After that, we add the actual check for the menu position: off-screen or on-screen. Because the appearance and disappearance of the menu are not instantaneous, I added small delays: await page.waitForTimeout(changeThemeDuatationMs)
.
// /src/__e2e__/PopupMenu.spec.ts
....
test("Popup menu is located outside the screen boundaries", async () => {
const isIn = await isInScreenAxisX({ element: popupMenu, page })
expect(isIn).toBe(false)
})
test("Popup menu is located on the screen after clicking on the Burger button", async () => {
await burgerIcon.click()
await page.waitForTimeout(changeThemeDuatationMs)
const isIn = await isInScreenAxisX({ element: popupMenu, page })
expect(isIn).toBe(true)
})
test("Menu closes after clicking the CloseButton", async () => {
await burgerIcon.click()
await page.waitForTimeout(changeThemeDuatationMs)
await closeMenuIcon.click()
await page.waitForTimeout(changeThemeDuatationMs * 2)
const isIn = await isInScreenAxisX({ element: popupMenu, page })
expect(isIn).toBe(false)
})
....
Checking the switch of the theme and snapshot tests
In the previous article, when writing unit tests, we only checked the function call that changes the theme and made sure that the correct arguments were passed to it. But actually, we could not check the change of the required attribute in the tag and, moreover, the change of the color scheme on the site.
How theme switching works
Colors on the site are set using CSS variables and they look like this:
/* /src/assets/styles/variables.css */
:root {
--color-main: #e6e5e5;
--color-grey: #868687;
...
}
[data-theme="light"] {
--color-main: #000000;
--color-background: #FFFFFF;
}
The theme change is done through a helper function:
// /src/assets/utls/setTheme.ts
import { ThemeColorSchema } from "../types/ui.type"
const setUiTheme = (theme: ThemeColorSchema) => {
document.documentElement.setAttribute("data-theme", theme)
}
export { setUiTheme }
How we perform the test
The first step, as usual, is to load the page and ensure that everything that should be on it is there:
// /src/__e2e___/Theme.spec.ts
import { test, expect, type Page, Locator } from '@playwright/test'
import { ThemeColorSchema } from '../assets/types/ui.type'
import { pages } from './utils/pages'
import { changeThemeDuatationMs } from './utils/variables'
test.describe('Theme swicthing', () => {
let page: Page
let themeSwitcher: Locator
let burgerIcon: Locator
let htmlTag: Locator
test.beforeEach(async ({ browser }) => {
page = await browser.newPage()
await page.goto(pages.home)
themeSwitcher = page.getByRole('switch', { name: /switch theme/i })
burgerIcon = page.getByRole('button', { name: /open menu/i })
htmlTag = page.locator('html')
})
test("All elements on the page", async () => {
await expect(themeSwitcher).toBeVisible()
await expect(burgerIcon).toBeVisible()
await expect(htmlTag).toBeVisible()
})
...
})
Next, we have the actual tests. The first group of tests verifies that the correct attribute is added to the tag.
...
test("When the page is loaded, the <html> tag does not have the value "light" for the data-theme attribute.", async () => {
const theme = await htmlTag.getAttribute('data-theme')
expect(theme).not.toBe(ThemeColorSchema.light)
})
test("Checking the screenshot of the dark theme during the initial loading", async () => {
await htmlTag.getAttribute('data-theme')
await expect(page).toHaveScreenshot('dark_theme.png')
})
test("Clicking on the ThemeSwitcher toggles the value of the data-theme attribute for the <html> tag to "light".", async () => {
await burgerIcon.click()
await page.waitForTimeout(changeThemeDuatationMs)
await themeSwitcher.click()
const theme = await htmlTag.getAttribute('data-theme')
expect(theme).toBe(ThemeColorSchema.light)
})
test("Clicking on ThemeSwitcher for the second time will switch back the theme to DARK", async () => {
await burgerIcon.click()
await page.waitForTimeout(changeThemeDuatationMs)
await themeSwitcher.click()
await themeSwitcher.click()
const theme = await htmlTag.getAttribute('data-theme')
expect(theme).toBe(ThemeColorSchema.dark)
})
...
The second group of tests takes screenshots and, on the next test run, checks whether any changes have occurred. If yes, it generates separate images highlighting the changes. The tests look like this:
test("Testing screenshot of dark theme on initial page load", async () => {
await htmlTag.getAttribute('data-theme')
await expect(page).toHaveScreenshot('dark_theme.png')
})
test("Checking the light_theme screenshot after switching the theme", async () => {
await burgerIcon.click()
await page.waitForTimeout(changeThemeDuatationMs)
await themeSwitcher.click()
await htmlTag.getAttribute('data-theme')
await expect(page).toHaveScreenshot('light_theme.png')
})
test("Checking screenshot daek_theme_2 after theme switching back to DARK for the second time", async () => {
await burgerIcon.click()
await page.waitForTimeout(changeThemeDuatationMs)
await themeSwitcher.click()
await themeSwitcher.click()
await expect(page).toHaveScreenshot('dark_theme_2.png')
})
Here, I directly specified the names of the images to make it easier to navigate. Playwright
generates images and includes the name in its names:
Now we can simply go through them and see if the website looks as expected. If everything is in order, then you can rely on this test in the future to ensure that everything works correctly.
An additional benefit of this test is that it also checks the functionality of the side menu.
Having a snapshot test in this case also raises questions about the necessity of the previous test that checked the tag: it verified the internal implementation of the code without providing any information about the end result.
However, since it has already been written and there is no plan to change the mechanism of theme switching, it was decided to leave it. Perhaps in the future, it will prove to be useful.
Conclusion
In this article, I have explored working with the Playwright
framework. Writing tests on it is only slightly more difficult than with @testing-library
. However, the tests themselves are more reliable, as the checks are carried out under conditions that are as close as possible to reality.
Snapshot tests
are set up very simply but, at the same time, allow you to quickly assess the site's performance "as a whole": whether the theme has changed, whether we messed up with CSS, whether the logic worked correctly, etc. In the next article, I will describe site testing using Storybook
, but the capabilities of Playwright
in this area seemed more suitable to me.
At the same time, it should be noted that e2e tests
work noticeably slower than simple unit tests, and writing code in the TDD style seemed difficult to me on them. Their scope of application is to check the site's operation at a general level and, as in the case of <PopupMenu />
, additional testing where checking unit tests may raise doubts about reliability.
Top comments (0)