DEV Community

Cover image for Testing React. Part 2: Playwright
Petr Tcoi
Petr Tcoi

Posted on • Edited on

Testing React. Part 2: Playwright

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/',
  ...
}
Enter fullscreen mode Exit fullscreen mode

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",
    ...
}
...

Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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);
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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()
    })
...
})

Enter fullscreen mode Exit fullscreen mode

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)
    })
....

Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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()
    })
...
})

Enter fullscreen mode Exit fullscreen mode

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)
    })
...

Enter fullscreen mode Exit fullscreen mode

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')
    })

Enter fullscreen mode Exit fullscreen mode

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:

Image description

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.

Image description

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)