Skip to content

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

bash
pnpm exec playwright install
pnpm add -D dotenv

Update Playwright configuration to handle API and UI tests separately.

./apps/playwright.config.ts
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
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

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:

bash
pnpm test:e2e

This executes all tests in e2e/ui/ directory.

TIP

Add data-cy attributes to elements you want to test. e.g.

vue
<button data-cy="theme-toggle" @click="toggleTheme()">Toggle Theme</button>

Then select them in tests:

typescript
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:

bash
pnpm test:api

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