End-to-end (E2E) testing is a crucial part of modern web development, it ensures that your application works as expected from the user’s perspective.
What is E2E Testing?
In short, E2E testing simulates real user operations on your application, verifying that all interfaces and components work together seamlessly.
Unlike unit tests that focus on individual components, E2E tests cover the entire application flow, from the frontend to the backend.
Why Use Playwright for E2E Testing?
Playwright is a powerful E2E testing framework that supports multiple browsers and provides a rich API for simulating user interactions.
Besides, it offers features like automatic waiting, network interception, and parallel test execution, making it an excellent choice for testing Nuxt applications.
It also has a VSCode extension that enhances the development experience. For example, you can record tests directly from the browser, so the test cases are generated from your interactions instead of writing them manually.
How to Set Up Playwright with Nuxt
Setting up Playwright with a Nuxt application is almost the same as with any other Node.js project. The offcial documentation suggests using npm init playwright / pnpm create playwright to set up Playwright in your project.
But in our project, this command can not be used directly, it throws an ERR_PNPM_PATCH_NOT_APPLIED error.
In case you have interests, we guess the reason is like this:
- We are using a monorepo structure with pnpm workspaces.
- We patched some dependencies using
pnpm patchcommand.- We only use playwright in one of the packages, not the whole monorepo. So we run the command in that package folder.
- But our patches are applied to the whole monorepo, so when we run the command in that package folder, it detects that some patches are not applied, then throws the error.
So the step by step init would be like this:
Install Playwright as a dev dependency
pnpm add -D @playwright/test
Add Playwright configuration
// playwright.config.ts
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:3000',
},
globalSetup: './e2e/global-setup.ts',
globalTeardown: './e2e/global-teardown.ts',
projects: [
{
name: 'edge',
use: { ...devices['Desktop Edge'], channel: 'msedge' },
},
{
name: 'chrome',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'safari',
use: { ...devices['Desktop Safari'] },
},
],
});
In the above configuration, we did something:
- Set the test directory to
./e2e, all test cases are stored in this folder. - Specify the base URL for our Nuxt application, so we can use relative URLs in our tests.
- Define multiple browser projects for testing. Please note for
edge, we specify thechannel, which is required for Playwright to launch the Edge browser.
You may also have noticed we specified globalSetup and globalTeardown scripts. These scripts are used to start and stop the Nuxt server before and after the tests run. An example implementation would be like this:
// e2e/global-setup.ts
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
import { FullConfig } from '@playwright/test';
let serverProcess: any;
export default async function globalSetup(config: FullConfig) {
serverProcess = execAsync('pnpm dev'); // Adjust the command as needed
// Wait for the server to be ready
await new Promise((resolve) => setTimeout(resolve, 5000));
}
You can adjust it according to your project structure and requirements.
Setup tsconfig for E2E tests
// tsconfig.json
{
// ...,
"compilerOptions": {
// ...,
"types": [
// ...,
"playwright",
],
"include": [
// ...,
"e2e/**/*.ts"
],
},
}
This configuration ensures that TypeScript recognizes Playwright types in our E2E test files.
Writing Your First E2E Test
Now that we have Playwright set up, let’s write our first E2E test case. Create a new file e2e/example.spec.ts:
import { test, expect } from '@playwright/test';
test('homepage has title and links to intro page', async ({ page }) => {
await page.goto('/');
// Check the title
await expect(page).toHaveTitle(/Nuxt Application/);
// Click on the intro link
await page.click('text=Introduction');
// Verify the URL
await expect(page).toHaveURL(/.*intro/);
// Check for specific content on the intro page
await expect(page.locator('h1')).toHaveText('Introduction to Nuxt');
});
In this test case, we navigate to the homepage, verify the title, click on a link to the introduction page, and check for specific content on that page.
If everything is set up correctly, you can run your tests using the following command:
pnpm playwright test --headed --project=edge
Please pay attention to the flags used here:
--headed: This flag runs the tests in a visible browser window, which is useful for debugging.--project=edge: This flag specifies which browser project to run the tests against. It’s corresponding to theprojectswe defined in the Playwright configuration.
You can also use --debug flag to run the tests in debug mode, which allows you to step through the test execution and watch the browser interactions.
Wait, Something is Missing!
In fact, the above test case will likely to fail if you run it directly. In our case, we tested on a sign up form, and the logs show that our form was not property handled by our Vue component, instead, the form was submitted directly by the browser.
[edge] > e2e/sign-up.test.ts:2:2538 > Sign-Up Flow > should surface errors
TimeoutError: page.waitForURL: Timeout 20000ms exceeded.
========================== logs ==========================
waiting for navigation to "/dashboard" until "load"
navigated to "http://localhost:3000/sign-up"
navigated to "http://localhost:3000/sign-up?account=playwright-98655%40gmail.com&name=Existing+Admin"
==========================================================
83 |
84 | await Promise.all([
> 85 | page.waitForURL('/dashboard',{ timeout:20000 }),
Because the Nuxt application may take some time to start up, and the test case may try to access the application before it’s ready.
Let me analyze for you:
The page.goto('/') is shortcut for page.goto('/', { waitUntil: 'load' }), which means it will wait until the load event is fired.
However, this event only represents that the initial HTML document (and it’s resources) has been loaded, it does not guarantee that the Vue application is working.
In case you don’t know, Nuxt applications are built on top of Vue.js. When a Nuxt application is loaded, the initial HTML is rendered on the server side, and then the Vue.js application is hydrated on the client side. Our application can only be fully interactive after the hydration is complete.
If you search for this problem, you may find some solutions suggesting to use @nuxt/test-utils package, then use page.goto('/', { waitUntil: 'hydration'}). But in our case, it has no effect.
In fact, the Nuxt app will expose a global function useNuxtApp() which returns the Nuxt application instance. And there is a property isHydrating on this instance that indicates whether the hydration is still in progress.
So the real solution would be like this:
await page.goto('/');
await page.waitForFunction(() => {
// @ts-ignore
return window.useNuxtApp && !window.useNuxtApp().isHydrating;
});
We can encapsulate this logic into a helper function for better reusability:
// e2e/utils.ts
import { Page } from '@playwright/test';
export async function gotoNuxtPage(url: string, page: Page) {
await page.goto(url);
await page.waitForFunction(() => {
// @ts-ignore
return window.useNuxtApp && !window.useNuxtApp().isHydrating;
});
}
Then we can use this helper function in our test case:
import { test, expect } from '@playwright/test';
import { gotoNuxtPage } from './utils';
test('homepage has title and links to intro page', async ({ page }) => {
await gotoNuxtPage('/', page);
// Check the title
await expect(page).toHaveTitle(/Nuxt Application/);
// Click on the intro link
await page.click('text=Introduction');
await gotoNuxtPage('/intro', page);
// Check for specific content on the intro page
await expect(page.locator('h1')).toHaveText('Introduction to Nuxt');
});
With this change, our test case should work as expected.
Another Tips with TypeScript
According to the Playwright documentation, we can use TypeScript out of the box. However, in practice, we may encounter some TypeScript related issues.
For example, we have encountered several errors:
TypeScript 'declare' fields must first be transformed by @babel/plugin-transform-typescript.Error [ERR_REQUIRE_ESM]: Must use import to load ES Module
These errors are usually caused by misconfigurations in the TypeScript transpilation process. But we don’t really have ability to dig into the root cause as a user of Playwright.
Fortunately, we have many transpilation options in Node ecosystem. In our case, we use tsx to run TypeScript files directly without pre-compilation. And it can also be imported in a TypeScript file to register itself programmatically.
So our final solution is quite simple, we insert an import statement at the top of common files (all test cases will import it):
// e2e/utils.ts
import 'tsx';
// ... other code
This import ensures that tsx handles the TypeScript files correctly, resolving the issues we encountered.
Conclusion
In this blog, we explored how to create Playwright E2E test cases for Nuxt applications. We covered the setup process, wrote our first test case, and addressed some Nuxt-specific challenges.
Wish you a smooth testing experience with Playwright and Nuxt!
References: