End to End Tests
Overview
End-to-end (E2E) tests simulate real user interactions with your application, testing complete workflows across the entire system.
UI E2E Tests
Test user interface workflows by automating browser interactions:
- Navigate between pages, click buttons, fill out forms
- Verify visual outputs and page behavior
- Run against multiple browsers (Chromium, Firefox, WebKit)
- Catch integration issues that unit tests might miss
API Tests
Test backend API endpoints and integration:
- Validate authentication, authorization, and error handling
- Test API status codes and response data
- Ensure backend functionality works correctly
Why Use E2E Tests?
- Catch integration issues between frontend and backend
- Verify user workflows work end-to-end
- Test real browser behavior and API responses
- Regression detection - prevent breaking existing features
- Confidence in production - validate features before deployment
Playwright
This project uses Playwright, a modern E2E testing framework that supports multiple browsers, parallel test execution, and excellent debugging tools.
Setup
Before running tests, install Playwright browsers. And API tests will also need to read .env variables
pnpm exec playwright install
pnpm add -D dotenvUpdate Playwright configuration to handle API and UI tests separately.
./apps/playwright.config.ts
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
import { config } from 'dotenv'
config({ path: '.env.development.local', quiet: true })
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e/ui',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Only on CI systems run the tests headless */
headless: !!process.env.CI,
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
webServer: {
/**
* Use the dev server by default for faster feedback loop.
* Use the preview server on CI for more realistic testing.
* Playwright will re-use the local server if there is already a dev-server running.
*/
command: process.env.CI ? 'npm run preview' : 'npm run dev',
port: process.env.CI ? 4173 : 5173,
reuseExistingServer: !process.env.CI,
},
})./apps/playwright.config.api.ts
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
import { config } from 'dotenv'
config({ path: '.env.development.local', quiet: true })
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e/ui',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Only on CI systems run the tests headless */
headless: !!process.env.CI,
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
webServer: {
/**
* Use the dev server by default for faster feedback loop.
* Use the preview server on CI for more realistic testing.
* Playwright will re-use the local server if there is already a dev-server running.
*/
command: process.env.CI ? 'npm run preview' : 'npm run dev',
port: process.env.CI ? 4173 : 5173,
reuseExistingServer: !process.env.CI,
},
})And create script shortcuts in ./apps/package.json
//
"test:api": "playwright test --config=playwright.config.api.ts",
"test:e2e": "playwright test",
//Running Tests
UI E2E Tests
Run UI end-to-end tests against all configured browsers:
pnpm test:e2eThis executes all tests in e2e/ui/ directory.
TIP
Add data-cy attributes to elements you want to test. e.g.
<button data-cy="theme-toggle" @click="toggleTheme()">Toggle Theme</button>Then select them in tests:
const button = page.locator('[data-cy="theme-toggle"]');
await button.click();Using a dedicated attribute like data-cy is good practice because it provides stable, test-only selectors that don’t break when classes, styles, or DOM structure change. It also makes the testing intent explicit and decouples tests from implementation details, which is now a de-facto industry standard
API Tests
Run API integration tests:
pnpm test:apiThis executes all tests in e2e/api/ directory using a dedicated configuration.
Test Organization
Tests are organized by purpose:
e2e/ui/- User interface E2E tests- Test real browser interactions
- Run against multiple browsers (Chromium, Firefox, WebKit)
- Simulate user workflows
e2e/api/- API integration tests- Test backend API endpoints
- Validate authentication and authorization
- Test error handling and status codes
Tests
UI Tests
app.spec.ts
import { test, expect } from '@playwright/test'
test('visits the app root url', async ({ page }) => {
await page.goto('/')
await expect(page.locator('h1')).toHaveText('Home')
})
test('dark mode switch works', async ({ page }) => {
await page.goto('/')
await page.locator('h1').waitFor({ timeout: 10000 })
const themeToggleBtn = page.locator('[data-cy="theme-toggle"]')
await themeToggleBtn.first().click({ timeout: 10000 })
const darkThemeElement = page.locator('[class*="v-theme--dark"]').first()
await darkThemeElement.waitFor({ timeout: 5000 })
const classAttr = await darkThemeElement.getAttribute('class')
expect(classAttr).toContain('v-theme--dark')
await themeToggleBtn.first().click()
const lightThemeElement = page.locator('[class*="v-theme--light"]').first()
await lightThemeElement.waitFor({ timeout: 5000 })
const finalClassAttr = await lightThemeElement.getAttribute('class')
expect(finalClassAttr).toContain('v-theme--light')
})API Tests
api.spec.ts
import { test, expect } from '@playwright/test'
const apiUrl = process.env.VITE_API_URI
const baseRequestOptions = { ignoreHTTPSErrors: true }
let token: string
test.beforeAll(async ({ playwright }) => {
const context = await playwright.chromium.launchPersistentContext('', {})
const request = context.request
const response = await request.post(`${apiUrl}app/login/`, {
data: {
username: process.env.VITE_APP_USERNAME,
password: process.env.VITE_APP_PASSWORD,
},
...baseRequestOptions,
})
const loginData = await response.json()
token = loginData.access_token
await context.close()
})
function getRequestOptionsAnon() {
return baseRequestOptions
}
function getRequestOptionsAuth() {
return {
...baseRequestOptions,
headers: {
Authorization: `Bearer ${token}`,
},
}
}
test('app context API returns version with v prefix', async ({ request }) => {
const response = await request.get(`${apiUrl}app/context/`, getRequestOptionsAnon())
expect(response.ok()).toBeTruthy()
const data = await response.json()
expect(data.version).toBeDefined()
expect(data.version).toMatch(/^v/)
})
test('login with invalid credentials returns 401', async ({ request }) => {
const response = await request.post(`${apiUrl}app/login/`, {
data: {
username: 'foo',
password: 'bar',
},
...getRequestOptionsAnon(),
})
expect(response.status()).toBe(401)
})
test('login with valid credentials returns 200', async ({ request }) => {
const response = await request.post(`${apiUrl}app/login/`, {
data: {
username: process.env.VITE_APP_USERNAME,
password: process.env.VITE_APP_PASSWORD,
},
...getRequestOptionsAnon(),
})
expect(response.status()).toBe(200)
})
test('login with valid credentials and get context', async ({ request }) => {
const response = await request.get(`${apiUrl}app/context/`, getRequestOptionsAuth())
expect(response.ok()).toBeTruthy()
const data = await response.json()
expect(data.user[0].uuid).toBeDefined()
})
test('login with invalid password 7 times, should get 429', async ({ request }) => {
let status = 0
for (let i = 0; i < 7; i++) {
const response = await request.post(`${apiUrl}app/login/`, {
data: {
username: process.env.VITE_APP_USERNAME,
password: 'bar',
},
...getRequestOptionsAnon(),
})
status = response.status()
}
expect(status).toBe(429)
})