Ensure Blog Quality with Playwright Automated Testing
While we have built a blog using VitePress and Vue.js, it is vital to confirm that it performs as expected. Testing is an essential component of software development, ensuring our blog operates correctly and efficiently.
Testing each feature manually would be a labor-intensive and error-prone process. As we continue to refine our blog to meet ever-evolving requirements, automated testing becomes indispensable. It allows us to verify quickly that our blog functions as intended, even when modifications are made. This practice helps maintain quality, prevent regressions, and accelerate the development workflow.
The straightforward nature of this blog makes it an ideal candidate for beginners to implement end-to-end testing. This type of testing validates software functionality from a user's perspective, ensuring all integrated components work harmoniously.
Testing is not Just About Writing TestsIn this article, we will explore automated testing utilizing Playwright.
Installing Playwright
Playwright is a Node.js library designed to automate browsers, enabling us to script interactions with web pages such as clicking buttons, completing forms, and navigating between pages. Playwright is compatible with multiple browsers, including Chromium, Firefox, and WebKit.
To install Playwright, execute the following command:
pnpm create playwright
This command prompts the creation of a tests folder in the root directory. We will accept the default folder name tests
. It further queries whether we require a GitHub Action workflow file. We will select Yes
. Lastly, it inquires if we wish to install the Playwright browsers. We will opt for Yes
.
Configuring Playwright
Upon installing Playwright, several files are generated, including playwright.config.ts
, where we must make necessary adjustments to suit our needs.
Initially, we will modify the timeout
property to 4_000
milliseconds. Given the simplicity of our tests, a shorter timeout suffices, potentially accelerating test execution should they fail. The workers
property will be set to 100%
, allowing Playwright to utilize all available CPU cores for parallel test execution. This configuration works because our tests are independent; we are creating a static site. Within the use
property, define the baseURL
as http://localhost:4173
. This is VitePress's default port for the preview server, omitting the need for redundant URL specifications in each test. In the projects
property, we will select only the Google Chrome browser, although additional browsers can be included at your discretion.
Finally, adjust the webServer
property to automatically build and preview our blog before test execution, ensuring it is updated. As VitePress behaves differently in development versus production, testing against the production build is preferable.
Ultimately, our playwright.config.ts
file should resemble the following:
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
timeout: 4_000,
workers: '100%',
reporter: 'html',
use: {
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
},
projects: [
{
name: 'Google Chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
},
],
webServer: {
command: 'npm run build && npm run preview',
url: 'http://localhost:4173',
reuseExistingServer: false,
timeout: 60_000,
},
})
In our package.json
file, include the following scripts:
{
"scripts": {
"test": "npm run test:e2e",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
}
The first script executes all tests, both unit and end-to-end. The second script specifically runs end-to-end tests. The third script allows running end-to-end tests with a graphical interface, useful during test development as it visualizes browser activity.
Writing Tests
With Playwright now configured, we can proceed to write our tests. Let's commence with a simple test before progressing to more intricate ones.
Header Test
Begin by creating a header.test.ts
file within the tests
folder. These tests will confirm the correct display of our blog header with the anticipated links, ensuring header consistency post-refactoring.
import { expect, test } from '@playwright/test'
test('should have a link to the homepage', async ({ page }) => {
// Arrange
// Act
await page.goto('/')
// Assert
const headerHomeLink = page
.getByRole('banner')
.getByRole('link', { name: 'Garabit', exact: true })
await expect(headerHomeLink).toBeVisible()
await expect(headerHomeLink).toHaveAttribute('href', '/')
})
Let me elucidate this initial test. We utilize Playwright's test
function to craft a test. The first argument describes the test, while the second is an asynchronous function receiving an object containing the page
property. This object represents the Playwright page object, which interacts with the browser.
In our test, we firstly navigate to the homepage. We can use a relative URL due to the baseURL
configuration in playwright.config.ts
. Subsequently, we obtain the header element by its role, verifying its visibility and correct link. Employing role-based element selection ensures tests remain resilient amidst HTML structure changes and guarantees element accessibility.
We can conduct two additional tests to verify the presence of links to the blog and GitHub projects, similar to the first, but targeting different elements.
test('should have a link to the blog', async ({ page }) => {
// Arrange
// Act
await page.goto('/')
// Assert
const headerBlogLink = page
.getByRole('banner')
.getByRole('link', { name: 'Blog', exact: true })
await expect(headerBlogLink).toBeVisible()
await expect(headerBlogLink).toHaveAttribute('href', '/blog')
})
test('should have a link to the projects', async ({ page }) => {
// Arrange
// Act
await page.goto('/')
// Assert
const headerProjectsLink = page
.getByRole('banner')
.getByRole('link', { name: 'Projects', exact: true })
await expect(headerProjectsLink).toBeVisible()
await expect(headerProjectsLink).toHaveAttribute('href', '/projects')
})
Execute the tests using the command:
pnpm run test:e2e:ui # or pnpm run test:e2e
This command will open a browser and execute the tests. A passing test will display a green checkmark beside it, whereas a failing test will show a red cross. Clicking on a test provides the error message details.
Projects
Next, we can validate that users can navigate from the projects page back to the homepage using the "Back to Home" button. Create a projects.test.ts
file within the tests
folder with the following content:
import { expect, test } from '@playwright/test'
test('should have a link to the homepage', async ({ page }) => {
// Arrange
// Act
await page.goto('/projects')
// Assert
const headerHomeLink = page.getByRole('link', {
name: 'Back to Home',
exact: true,
})
await expect(headerHomeLink).toBeVisible()
await expect(headerHomeLink).toHaveAttribute('href', '/')
})
Utilizing the pnpm run test:e2e:ui
command will allow you to observe tests as they are created. As you develop them, you can re-run tests using the green play button to confirm they pass.
In this file, you may also consider evaluating the page title and ensuring the list of projects reflects the projects intended for display. Initially, prioritize testing non-visible elements like links as done previously, and incorporate tests when issues arise or code refactoring occurs.
Blog
Following the procedure for the projects page, create a blog.test.ts
file within the tests
folder. Let's test the "Back to Home" button.
import { expect, test } from '@playwright/test'
test.describe('index', () => {
test('should have a link to the homepage', async ({ page }) => {
// Arrange
// Act
await page.goto('/blog')
// Assert
const headerHomeLink = page.getByRole('link', {
name: 'Back to Home',
exact: true,
})
await expect(headerHomeLink).toBeVisible()
await expect(headerHomeLink).toHaveAttribute('href', '/')
})
})
We use a test.describe
block to group similar tests, which is advantageous when handling multiple tests on the same page. In this file, we will write tests for both the blog index page and individual blog posts.
Regarding the blog post page, we could examine whether each post contains a "Back to Blog" button. Should we attempt testing a single post, or should we try for all? How can we navigate to the blog post?
One approach is crafting a test that navigates to /blog and clicks on the first post, subsequently verifying the visibility and link accuracy of the "Back to Blog" button. This test effectively validates inter-page navigation but is not our current focus. Alternatively, we could select all posts and iterate through them, confirming consistent functionality.
However, since VitePress generates the URL based on filename, we can exploit this aspect by using a glob pattern to retrieve all post filenames and iterate through them to check functionality. This efficient method accelerates testing.
import { expect, test } from '@playwright/test'
import { glob } from 'tinyglobby'
const posts = await glob('src/blog/*.md')
test.describe('index', () => {
// ...
})
test.describe('show', () => {
posts.forEach((post) => {
const link = post.replace('src', '').replace('.md', '').replace('.md', '')
test(`should have a link to the blog index (${post})`, async ({ page }) => {
// Arrange
// Act
await page.goto(link)
// Assert
const headerBlogLink = page.getByRole('link', {
name: 'Back to Blog',
exact: true,
})
await expect(headerBlogLink).toBeVisible()
await expect(headerBlogLink).toHaveAttribute('href', '/blog')
})
})
})
SEO
Using end-to-end testing for a static blog proves highly beneficial for verifying SEO metadata. We can check the title, description, and image accuracy of each page, ensuring optimal SEO-friendliness.
Manually checking each page's DOM for the correct tags is labor-intensive, but Playwright simplifies DOM manipulation, including hidden elements.
By applying the same techniques as on the blog post page, we can use a glob pattern to iterate through all pages, verifying the SEO metadata.
import test, { expect } from '@playwright/test'
import { glob } from 'tinyglobby'
import { joinURL, withoutTrailingSlash } from 'ufo'
const pages = await glob('src/**/*.md')
pages.forEach((page) => {
const link = page.replace('src', '').replace('.md', '').replace('index', '')
test(`should have metadata (${page})`, async ({ page, request }) => {
// Arrange
// Act
await page.goto(link)
// Assert
const title = await page.title()
expect(title).toBeTruthy()
expect(title.endsWith(' | Garabit')).toBeTruthy()
const ogTitle = await page
.locator('meta[property="og:title"]')
.getAttribute('content')
expect(ogTitle).toBeTruthy()
const twitterTitle = await page
.locator('meta[name="twitter:title"]')
.getAttribute('content')
expect(twitterTitle).toBeTruthy()
expect(title.startsWith(ogTitle!)).toBeTruthy()
expect(ogTitle).toBe(twitterTitle)
const description = await page
.locator('meta[name="description"]')
.getAttribute('content')
expect(description).toBeTruthy()
const ogDescription = await page
.locator('meta[property="og:description"]')
.getAttribute('content')
expect(ogDescription).toBeTruthy()
const twitterDescription = await page
.locator('meta[name="twitter:description"]')
.getAttribute('content')
expect(twitterDescription).toBeTruthy()
expect(description).toBe(ogDescription)
expect(description).toBe(twitterDescription)
const twitterSite = await page
.locator('meta[name="twitter:site"]')
.getAttribute('content')
expect(twitterSite).toBe('@soubiran_')
const twitterCard = await page
.locator('meta[name="twitter:card"]')
.getAttribute('content')
expect(twitterCard).toBe('summary_large_image')
const canonical = await page
.locator('link[rel="canonical"]')
.getAttribute('href')
expect(canonical).toBe(
withoutTrailingSlash(joinURL('https://garabit.barbapapazes.dev', link)),
)
const ogMetaTags = [
{ name: 'og:image:width', value: '1200' },
{ name: 'og:image:height', value: '630' },
{ name: 'og:image:type', value: 'image/png' },
{ name: 'og:site_name', value: 'Garabit' },
{ name: 'og:type', value: 'website' },
{ name: 'og:url', value: 'https://garabit.barbapapazes.dev' },
]
for (const { name, value } of ogMetaTags) {
const metaTag = await page
.locator(`meta[property="${name}"]`)
.getAttribute('content')
expect(metaTag).toBe(value)
}
const url = await page
.locator('meta[property="og:image"]')
.getAttribute('content')
expect(url).toBeTruthy()
const image = await request.get(
url!.replace('https://garabit.barbapapazes.dev', 'http://localhost:4173'),
)
expect(image.ok()).toBeTruthy()
expect(image.headers()['content-type']).toBe('image/png')
})
})
This test even confirms the correct operation of automatic Open Graph image generation.
Continuous Integration
To automate test execution upon code pushes, we employ GitHub Actions. Playwright provides a GitHub Action workflow file, which we can modify to our specifications.
name: Playwright Tests
on:
push:
branches:
- main
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
- run: corepack enable
- uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Install **Playwright Browsers**
run: pnpm exec **playwright install** --with-deps chrome
- name: Run **Playwright tests**
run: pnpm run **test:e2e**
- uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
We solely install the Chrome browser as it is the only one used in our tests, expediting the workflow. The command pnpm run test:e2e
initiates the tests, and failing results are logged as artifacts for error analysis.
Conclusion
In this article, we've implemented automated testing for our blog using Playwright. We've configured Playwright for parallel test execution, set up browsers, and run tests against production builds. We've crafted tests for headers, projects, blogs, and SEO metadata while establishing continuous integration with GitHub Actions for automatic test execution. This approach maintains blog quality, prevents regressions, and streamlines the developmental process.
Thanks for reading! My name is Estéban, and I love to write about web development.
I've been coding for several years now, and I'm still learning new things every day. I enjoy sharing my knowledge with others, as I would have appreciated having access to such clear and complete resources when I first started learning programming.
If you have any questions or want to chat, feel free to comment below or reach out to me on Bluesky, X, and LinkedIn.
I hope you enjoyed this article and learned something new. Please consider sharing it with your friends or on social media, and feel free to leave a comment or a reaction below—it would mean a lot to me! If you'd like to support my work, you can sponsor me on GitHub!