feat: добавил тесты с использованием Playwright
Frontend CI / build-and-check (push) Failing after 19s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 8s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 8s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 20s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s

This commit is contained in:
2026-05-28 19:17:11 +03:00
parent cb80b35ba6
commit 88146f22b6
10 changed files with 419 additions and 2 deletions
+40
View File
@@ -0,0 +1,40 @@
name: Frontend Playwright
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
jobs:
e2e:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Build app
run: pnpm build-only
- name: Install Playwright browser
run: pnpm exec playwright install --with-deps chromium
- name: Run e2e
run: pnpm test:e2e
+111
View File
@@ -0,0 +1,111 @@
# Playwright E2E тесты frontend
## Назначение
Playwright-тесты проверяют ключевые браузерные сценарии frontend-приложения UniVerse:
- редирект неавторизованного пользователя на страницу входа;
- отображение каталога открытых лекций;
- запись на доступную лекцию.
Тесты работают поверх production preview сборки frontend и используют mock API, поэтому для базового запуска не нужен поднятый backend.
## Где лежат файлы
- [playwright.config.ts](../frontend/playwright.config.ts) - конфигурация Playwright.
- [auth.spec.ts](../frontend/tests/e2e/auth.spec.ts) - сценарии аутентификации.
- [catalog.spec.ts](../frontend/tests/e2e/catalog.spec.ts) - сценарии каталога лекций.
- [mockApi.ts](../frontend/tests/e2e/support/mockApi.ts) - перехват и mock ответов API.
- [fixtures.ts](../frontend/tests/mocks/fixtures.ts) - тестовые данные.
## Как устроен запуск
Конфигурация находится во `frontend/playwright.config.ts`.
Основные параметры:
- `testDir: ./tests/e2e` - Playwright ищет тесты в папке `frontend/tests/e2e`.
- `baseURL: http://127.0.0.1:4173` - базовый адрес приложения в тестах.
- `webServer` запускает `pnpm preview --host 127.0.0.1 --port 4173`.
- В CI включены `2` retry и GitHub reporter.
- Локально используется list reporter.
- По умолчанию проект запускается в Chromium.
## Команды
Запуск всех E2E-тестов из корня репозитория:
```bash
pnpm -C frontend test:e2e
```
Интерактивный UI Playwright:
```bash
pnpm -C frontend test:e2e:ui
```
Если нужно вручную поднять preview-сервер:
```bash
pnpm -C frontend build-only
pnpm -C frontend test:e2e:preview
```
После этого можно запускать Playwright с переменной `PW_SKIP_WEB_SERVER=1`, чтобы он не стартовал свой `webServer`.
## Mock API
Тесты не обращаются к реальному backend. Вместо этого helper `mockApi(page, options)` перехватывает запросы к `/api/v1` через `page.route`.
Сейчас замоканы:
- `POST/GET /api/v1/auth/refresh` - refresh авторизации;
- `/api/v1/auth/me` - текущий пользователь;
- `/api/v1/users/me/stats` - статистика студента;
- `/api/v1/lectures` - список лекций;
- `/api/v1/lectures/{id}/enroll` - запись на лекцию.
Для авторизованного сценария используйте:
```ts
await mockApi(page, { authenticated: true })
```
Для проверки гостевого сценария:
```ts
await mockApi(page, { authenticated: false })
```
## Как добавлять новые тесты
1. Создавайте spec-файлы в `frontend/tests/e2e`.
2. Для страниц, которым нужен backend, сначала добавляйте нужные ответы в `mockApi.ts` и данные в `fixtures.ts`.
3. Проверяйте пользовательский результат через role/text/label locators: `getByRole`, `getByText`, `getByLabel`.
4. Не завязывайтесь на CSS-классы, если сценарий можно проверить через доступные пользователю элементы.
5. Для маршрутов под авторизацией вызывайте `mockApi(page, { authenticated: true })` до `page.goto(...)`.
## CI
Workflow находится в [.gitea/workflows/frontend-playwright.yml](../.gitea/workflows/frontend-playwright.yml).
Пайплайн:
1. Устанавливает зависимости через `pnpm install --frozen-lockfile`.
2. Собирает frontend командой `pnpm build-only`.
3. Устанавливает браузер Playwright Chromium.
4. Запускает `pnpm test:e2e`.
## Артефакты и отладка
Playwright сохраняет trace на первом retry: `trace: on-first-retry`.
Локально полезные команды:
```bash
pnpm -C frontend exec playwright show-report
pnpm -C frontend exec playwright show-trace ./test-results/<папка>/trace.zip
```
Папки `frontend/test-results` и `frontend/playwright-report` считаются временными артефактами тестовых прогонов.
+5
View File
@@ -35,5 +35,10 @@ coverage
# Vitest # Vitest
__screenshots__/ __screenshots__/
# Playwright
/test-results/
/playwright-report/
/blob-report/
# Vite # Vite
*.timestamp-*-*.mjs *.timestamp-*-*.mjs
+7 -2
View File
@@ -13,7 +13,11 @@
"lint": "run-s lint:*", "lint": "run-s lint:*",
"lint:oxlint": "oxlint . --fix", "lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache", "lint:eslint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/" "format": "prettier --write --experimental-cli src/",
"test:e2e:preview": "vite preview --host 127.0.0.1 --port 4173",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:ui:edge": "PW_USE_SYSTEM_EDGE=1 PW_SKIP_WEB_SERVER=1 playwright test --ui --timeout=0 --workers=1"
}, },
"dependencies": { "dependencies": {
"pinia": "^3.0.4", "pinia": "^3.0.4",
@@ -38,7 +42,8 @@
"typescript": "~6.0.0", "typescript": "~6.0.0",
"vite": "^8.0.8", "vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1", "vite-plugin-vue-devtools": "^8.1.1",
"vue-tsc": "^3.2.6" "vue-tsc": "^3.2.6",
"@playwright/test": "^1.54.2"
}, },
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig, devices } from '@playwright/test'
const useSystemEdge = process.env.PW_USE_SYSTEM_EDGE === '1'
const skipWebServer = process.env.PW_SKIP_WEB_SERVER === '1'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'on-first-retry',
},
webServer: skipWebServer
? undefined
: {
command: 'pnpm preview --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
cwd: '.',
},
projects: [
{
name: useSystemEdge ? 'msedge' : 'chromium',
use: {
...devices['Desktop Chrome'],
...(useSystemEdge ? { channel: 'msedge' } : {}),
},
},
],
})
+38
View File
@@ -18,6 +18,9 @@ importers:
specifier: ^5.0.6 specifier: ^5.0.6
version: 5.0.6(@vue/compiler-sfc@3.5.34)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)) version: 5.0.6(@vue/compiler-sfc@3.5.34)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))
devDependencies: devDependencies:
'@playwright/test':
specifier: ^1.54.2
version: 1.54.2
'@tsconfig/node24': '@tsconfig/node24':
specifier: ^24.0.4 specifier: ^24.0.4
version: 24.0.4 version: 24.0.4
@@ -433,6 +436,11 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@playwright/test@1.54.2':
resolution: {integrity: sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==}
engines: {node: '>=18'}
hasBin: true
'@polka/url@1.0.0-next.29': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -1008,6 +1016,11 @@ packages:
flatted@3.4.2: flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1347,6 +1360,16 @@ packages:
pkg-types@2.3.1: pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
playwright-core@1.54.2:
resolution: {integrity: sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==}
engines: {node: '>=18'}
hasBin: true
playwright@1.54.2:
resolution: {integrity: sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==}
engines: {node: '>=18'}
hasBin: true
postcss-safe-parser@7.0.1: postcss-safe-parser@7.0.1:
resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==}
engines: {node: '>=18.0'} engines: {node: '>=18.0'}
@@ -2011,6 +2034,10 @@ snapshots:
'@oxlint/binding-win32-x64-msvc@1.60.0': '@oxlint/binding-win32-x64-msvc@1.60.0':
optional: true optional: true
'@playwright/test@1.54.2':
dependencies:
playwright: 1.54.2
'@polka/url@1.0.0-next.29': {} '@polka/url@1.0.0-next.29': {}
'@rolldown/binding-android-arm64@1.0.0-rc.17': '@rolldown/binding-android-arm64@1.0.0-rc.17':
@@ -2606,6 +2633,9 @@ snapshots:
flatted@3.4.2: {} flatted@3.4.2: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -2889,6 +2919,14 @@ snapshots:
exsolve: 1.0.8 exsolve: 1.0.8
pathe: 2.0.3 pathe: 2.0.3
playwright-core@1.54.2: {}
playwright@1.54.2:
dependencies:
playwright-core: 1.54.2
optionalDependencies:
fsevents: 2.3.2
postcss-safe-parser@7.0.1(postcss@8.5.14): postcss-safe-parser@7.0.1(postcss@8.5.14):
dependencies: dependencies:
postcss: 8.5.14 postcss: 8.5.14
+9
View File
@@ -0,0 +1,9 @@
import { test, expect } from '@playwright/test'
import { mockApi } from './support/mockApi'
test('redirects unauthenticated user to login', async ({ page }) => {
await mockApi(page, { authenticated: false })
await page.goto('/catalog')
await expect(page).toHaveURL(/\/login/)
await expect(page.getByText('Войти через ЮФУ')).toBeVisible()
})
+23
View File
@@ -0,0 +1,23 @@
import { expect, test } from '@playwright/test'
import { mockApi } from './support/mockApi'
test.beforeEach(async ({ page }) => {
await mockApi(page, { authenticated: true })
})
test('renders catalog items from mock api', async ({ page }) => {
await page.goto('/catalog')
await expect(page.getByRole('heading', { name: 'Каталог открытых лекций' })).toBeVisible()
await expect(page.getByText('Введение в ML')).toBeVisible()
await expect(page.getByText('Квантовые вычисления')).toBeVisible()
})
test('register button works for available lecture', async ({ page }) => {
await page.goto('/catalog')
const firstRegisterButton = page.getByRole('button', { name: 'Записаться' }).first()
await firstRegisterButton.click()
await expect(page.getByText('Вы записаны на лекцию.')).toBeVisible()
})
+74
View File
@@ -0,0 +1,74 @@
import { Page } from '@playwright/test'
import { mockAuthResponse, mockCurrentUser, mockLectures, mockUserStats } from '../../mocks/fixtures'
export async function mockApi(page: Page, options?: { authenticated?: boolean }) {
const authenticated = options?.authenticated ?? false
await page.route('**/api/v1/auth/refresh', async (route) => {
if (!authenticated) {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ message: 'Unauthorized' }),
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockAuthResponse),
})
})
await page.route('**/api/v1/auth/me', async (route) => {
if (!authenticated) {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ message: 'Unauthorized' }),
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockCurrentUser),
})
})
await page.route('**/api/v1/users/me/stats', async (route) => {
if (!authenticated) {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ message: 'Unauthorized' }),
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockUserStats),
})
})
await page.route('**/api/v1/lectures/*/enroll', async (route) => {
await route.fulfill({ status: 204 })
})
await page.route(/\/api\/v1\/lectures(\?.*)?$/, async (route) => {
if (route.request().method() !== 'GET') {
await route.continue()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: mockLectures }),
})
})
}
+80
View File
@@ -0,0 +1,80 @@
export const mockAuthResponse = {
accessToken: 'fake-token',
expiresAt: '2099-01-01T00:00:00.000Z',
user: {
id: 1,
email: 'student@example.com',
displayName: 'Test User',
roles: ['Student'],
},
}
export const mockCurrentUser = {
id: 1,
email: 'student@example.com',
displayName: 'Test User',
roles: ['Student'],
avatarUrl: null,
xp: 120,
coins: 10,
level: 2,
createdAt: '2026-01-01T00:00:00.000Z',
}
export const mockUserStats = {
totalLectures: 0,
attendedLectures: 0,
totalReviews: 0,
xp: 120,
coins: 10,
level: 2,
achievementsCount: 0,
currentLevelXp: 20,
nextLevelXp: 100,
activeEnrollments: 0,
enrollmentSlotLimit: 3,
enrollmentSlotRules: [],
}
export const mockLectures = [
{
id: 1,
courseId: 101,
courseName: 'ML',
teacherId: 201,
teacherName: 'Иванов И.И.',
locationId: 301,
locationName: 'B-1 / 101',
title: 'Введение в ML',
description: 'База машинного обучения',
format: 'Offline',
startsAt: '2026-06-12T10:00:00.000Z',
endsAt: '2026-06-12T11:30:00.000Z',
isOpen: true,
maxEnrollments: 30,
enrollmentsCount: 10,
onlineUrl: null,
createdAt: '2026-01-01T00:00:00.000Z',
isEnrolled: false,
},
{
id: 2,
courseId: 102,
courseName: 'квантовые-вычисления',
teacherId: 202,
teacherName: 'Петров П.П.',
locationId: null,
locationName: null,
title: 'Квантовые вычисления',
description: 'Кубиты и алгоритмы',
format: 'Online',
startsAt: '2026-06-13T10:00:00.000Z',
endsAt: '2026-06-13T11:00:00.000Z',
isOpen: false,
maxEnrollments: 50,
enrollmentsCount: 50,
onlineUrl: 'https://example.com/meet',
createdAt: '2026-01-01T00:00:00.000Z',
isEnrolled: false,
},
]