Průvodce testováním
Komplexní průvodce testováním pokrývající unit testy s Vitest a end-to-end testování s Playwright pro CatCMS.[object Object]
Přehled
Filozofie testování
CatCMS se řídí komplexním přístupem k testování:
- Unit testy - Rychlé, izolované testy pro jednotlivé funkce a služby
- End-to-End testy - Testy v prohlížeči pro kritické cesty uživatele a pracovní postupy
Cíle pokrytí testy
- 90 % minimální pokrytí kódu pro klíčovou business logiku
- Všechny API endpointy mají pokrytí E2E testy
- Klíčové uživatelské pracovní postupy mají komplexní pokrytí testy
- Funkčnost pluginů je důkladně testována
Aktuální stav pokrytí
Podle posledního spuštění testů:
📊
Overall Coverage
90.86% code coverage
✅
Total Tests
684 passing tests
📁
Test Files
26 test files
🎯
Statements
90.86% coverage
🔀
Branches
90.34% coverage
⚙️
Functions
96.23% coverage
Sada nástrojů pro testování
Základní nástroje pro testování
⚡
Vitest
Fast, Vite-native test runner with excellent TypeScript support
🎭
Playwright
Reliable cross-browser testing with powerful debugging
📈
@vitest/coverage-v8
Fast, accurate code coverage using V8's built-in coverage
Proč tyto nástroje?
- Vitest - Rychlý, nativní test runner pro Vite s vynikající podporou TypeScriptu
- Playwright - Spolehlivé testování napříč prohlížeči s výkonnými možnostmi ladění
- Coverage-v8 - Rychlé a přesné pokrytí kódu pomocí vestavěného pokrytí V8
Nastavení a instalace
Předpoklady
# Install dependencies
npm install
# Install Playwright browsers (first time only)
npx playwright install
Install Dependencies
Konfigurační soubory
Projekt obsahuje předkonfigurovaná nastavení pro testování:
vitest.config.ts- Konfigurace Vitestplaywright.config.ts- Konfigurace Playwrighttests/e2e/utils/test-helpers.ts- Sdílené utility pro testy
Unit testování s Vitest
Konfigurace Vitest
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
exclude: ['node_modules', 'dist', '.next'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{js,ts}'],
exclude: [
'src/**/*.{test,spec}.{js,ts}',
'src/**/*.d.ts',
'src/scripts/**',
'src/templates/**'
],
thresholds: {
global: {
branches: 90,
functions: 90,
lines: 90,
statements: 90
}
}
}
},
})Vitest Config
Příklad unit testu z reálného světa
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
CacheService,
createCacheService,
getCacheService,
clearAllCaches,
getAllCacheStats
} from '../services/cache.js'
import {
CACHE_CONFIGS,
getCacheConfig,
generateCacheKey,
parseCacheKey,
hashQueryParams,
createCachePattern
} from '../services/cache-config.js'
describe('CacheConfig', () => {
it('should have predefined cache configurations', () => {
expect(CACHE_CONFIGS.content).toBeDefined()
expect(CACHE_CONFIGS.user).toBeDefined()
expect(CACHE_CONFIGS.config).toBeDefined()
expect(CACHE_CONFIGS.media).toBeDefined()
})
it('should generate cache key with correct format', () => {
const key = generateCacheKey('content', 'post', '123', 'v1')
expect(key).toBe('content:post:123:v1')
})
it('should parse cache key correctly', () => {
const key = 'content:post:123:v1'
const parsed = parseCacheKey(key)
expect(parsed).toBeDefined()
expect(parsed?.namespace).toBe('content')
expect(parsed?.type).toBe('post')
expect(parsed?.identifier).toBe('123')
expect(parsed?.version).toBe('v1')
})
it('should hash query parameters consistently', () => {
const params1 = { limit: 10, offset: 0, sort: 'asc' }
const params2 = { offset: 0, limit: 10, sort: 'asc' }
const hash1 = hashQueryParams(params1)
const hash2 = hashQueryParams(params2)
expect(hash1).toBe(hash2) // Order shouldn't matter
})
})
describe('CacheService - Basic Operations', () => {
let cache: CacheService
beforeEach(() => {
const config = {
ttl: 60,
kvEnabled: false,
memoryEnabled: true,
namespace: 'test',
invalidateOn: [],
version: 'v1'
}
cache = createCacheService(config)
})
it('should set and get value from cache', async () => {
await cache.set('test:key', 'value')
const result = await cache.get('test:key')
expect(result).toBe('value')
})
it('should return null for non-existent key', async () => {
const result = await cache.get('non-existent')
expect(result).toBeNull()
})
it('should delete value from cache', async () => {
await cache.set('test:key', 'value')
await cache.delete('test:key')
const result = await cache.get('test:key')
expect(result).toBeNull()
})
})
describe('CacheService - TTL and Expiration', () => {
let cache: CacheService
beforeEach(() => {
const config = {
ttl: 1, // 1 second TTL for testing
kvEnabled: false,
memoryEnabled: true,
namespace: 'test',
invalidateOn: [],
version: 'v1'
}
cache = createCacheService(config)
})
it('should expire entries after TTL', async () => {
await cache.set('test:key', 'value')
// Wait for expiration
await new Promise(resolve => setTimeout(resolve, 1100))
const result = await cache.get('test:key')
expect(result).toBeNull()
})
it('should allow custom TTL per entry', async () => {
await cache.set('test:key', 'value', { ttl: 10 }) // 10 second TTL
// Entry should still be there after 1 second
await new Promise(resolve => setTimeout(resolve, 1100))
const result = await cache.get('test:key')
expect(result).toBe('value')
})
})
Cache Plugin Tests
Vzory pro unit testování
import { vi } from 'vitest'
describe('Service with Dependencies', () => {
it('should not call fetcher when value is cached', async () => {
await cache.set('test:key', 'cached-value')
const fetcher = vi.fn(async () => 'fetched-value')
const result = await cache.getOrSet('test:key', fetcher)
expect(result).toBe('cached-value')
expect(fetcher).not.toHaveBeenCalled()
})
})Testing with Mocks
End-to-end testování s Playwright
Konfigurace Playwright
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
globalSetup: require.resolve('./tests/e2e/global-setup.ts'),
globalTeardown: require.resolve('./tests/e2e/global-teardown.ts'),
use: {
baseURL: 'http://localhost:8787',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:8787',
reuseExistingServer: !process.env.CI,
timeout: 120 \* 1000,
},
})
Playwright Config
Testy kontroly stavu (Health Check)
import { test, expect } from '@playwright/test'
import { checkAPIHealth } from './utils/test-helpers'
test.describe('Health Checks', () => {
test('API health endpoint should return running status', async ({ page }) => {
const health = await checkAPIHealth(page)
expect(health).toHaveProperty('name', 'CatCMS AI')
expect(health).toHaveProperty('version', '0.1.0')
expect(health).toHaveProperty('status', 'running')
expect(health).toHaveProperty('timestamp')
})
test('Home page should redirect to login', async ({ page }) => {
const response = await page.goto('/')
expect(response?.status()).toBe(200)
// Should redirect to login page
await page.waitForURL(/\/auth\/login/)
// Verify we're on the login page
expect(page.url()).toContain('/auth/login')
await expect(page.locator('h2')).toContainText('Welcome Back')
})
test('Admin routes should require authentication', async ({ page }) => {
// Try to access admin without auth
await page.goto('/admin')
// Should redirect to login
await page.waitForURL(/\/auth\/login/)
// Verify error message is shown
await expect(page.locator('.bg-error\\/10')).toContainText(
'Please login to access the admin area'
)
})
})Health Check Tests
Testy autentizace
import { test, expect } from '@playwright/test'
import { loginAsAdmin, logout, isAuthenticated, ADMIN_CREDENTIALS } from './utils/test-helpers'
test.describe('Authentication', () => {
test.beforeEach(async ({ page }) => {
await logout(page)
})
test('should display login form', async ({ page }) => {
await page.goto('/auth/login')
await expect(page.locator('h2')).toContainText('Welcome Back')
await expect(page.locator('[name="email"]')).toBeVisible()
await expect(page.locator('[name="password"]')).toBeVisible()
await expect(page.locator('button[type="submit"]')).toBeVisible()
})
test('should login successfully with valid credentials', async ({ page }) => {
await loginAsAdmin(page)
// Should be on admin dashboard
await expect(page).toHaveURL('/admin')
await expect(page.locator('nav').first()).toBeVisible()
})
test('should show error with invalid credentials', async ({ page }) => {
await page.goto('/auth/login')
await page.fill('[name="email"]', 'invalid@email.com')
await page.fill('[name="password"]', 'wrongpassword')
await page.click('button[type="submit"]')
// Should show error message
await expect(page.locator('.error, .bg-red-100')).toBeVisible()
})
test('should maintain session across page reloads', async ({ page }) => {
await loginAsAdmin(page)
await page.reload()
// Should still be authenticated
await expect(page).toHaveURL('/admin')
await expect(await isAuthenticated(page)).toBe(true)
})
})
Authentication Tests
Testy správy obsahu
import { test, expect } from '@playwright/test'
import {
loginAsAdmin,
navigateToAdminSection,
waitForHTMX,
ensureTestContentExists
} from './utils/test-helpers'
test.describe('Content Management', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page)
await ensureTestContentExists(page)
await navigateToAdminSection(page, 'content')
})
test('should display content list', async ({ page }) => {
await expect(page.locator('h1').first()).toContainText('Content Management')
// Should have filter dropdowns
await expect(page.locator('select[name="model"]')).toBeVisible()
await expect(page.locator('select[name="status"]')).toBeVisible()
})
test('should filter content by status', async ({ page }) => {
// Filter by published status
await page.selectOption('select[name="status"]', 'published')
// Wait for HTMX to update the content
await waitForHTMX(page)
const table = page.locator('table')
const hasTable = await table.count() > 0
if (hasTable) {
const publishedRows = page.locator('tr').filter({ hasText: 'published' })
const rowCount = await publishedRows.count()
expect(rowCount).toBeGreaterThanOrEqual(0)
}
})
test('should navigate to new content form', async ({ page }) => {
await page.click('a[href="/admin/content/new"]')
await page.waitForURL('/admin/content/new', { timeout: 10000 })
// Should show collection selection page
await expect(page.locator('h1')).toContainText('Create New Content')
await expect(page.locator('text=Select a collection to create content in:')).toBeVisible()
// Should have at least one collection to select
const collectionLinks = page.locator('a[href^="/admin/content/new?collection="]')
const count = await collectionLinks.count()
expect(count).toBeGreaterThan(0)
})
})Content Tests
Testování API s Playwright
import { test, expect } from '@playwright/test'
test.describe('API Endpoints', () => {
test('should return health check', async ({ request }) => {
const response = await request.get('/health')
expect(response.ok()).toBeTruthy()
const health = await response.json()
expect(health).toHaveProperty('name', 'CatCMS AI')
expect(health).toHaveProperty('version', '0.1.0')
expect(health).toHaveProperty('status', 'running')
})
test('should return OpenAPI spec', async ({ request }) => {
const response = await request.get('/api')
expect(response.ok()).toBeTruthy()
const spec = await response.json()
expect(spec).toHaveProperty('openapi')
expect(spec).toHaveProperty('info')
expect(spec).toHaveProperty('paths')
})
test('should handle SQL injection attempts safely', async ({ request }) => {
const sqlInjectionAttempts = [
"'; DROP TABLE collections; --",
"' OR '1'='1",
"'; SELECT * FROM users; --",
]
for (const injection of sqlInjectionAttempts) {
const response = await request.get(
`/api/collections/${encodeURIComponent(injection)}/content`
)
// Should safely return 404, not expose database errors
expect(response.status()).toBe(404)
const data = await response.json()
expect(data.error).toBe('Collection not found')
// Should not expose SQL error messages
expect(data.error).not.toContain('SQL')
expect(data.error).not.toContain('database')
}
})
})
API Tests
Spouštění testů
Unit testy
# Run all unit tests
npm test
# Run tests in watch mode
npm run test:watch
# Run with coverage
npm run test:cov
# Run with coverage in watch mode
npm run test:cov:watch
# Run with coverage and UI
npm run test:cov:uiUnit Test Commands
E2E testy
# Run all E2E tests
npm run test:e2e
# Run E2E tests with UI mode
npm run test:e2e:ui
# Run specific test file
npx playwright test tests/e2e/02-authentication.spec.ts
# Run tests in headed mode (see browser)
npx playwright test --headed
# Run tests in debug mode
npx playwright test --debug
E2E Test Commands
Reportování pokrytí
Zobrazení reportů o pokrytí
# Generate coverage report
npm run test:cov
# Coverage files are generated in:
# - coverage/index.html (HTML report)
# - coverage/coverage-final.json (JSON report)Coverage Commands
Prahové hodnoty pokrytí
Projekt vynucuje minimální prahové hodnoty pokrytí:
thresholds: {
global: {
branches: 90,
functions: 90,
lines: 90,
statements: 90
}
}
Nedávná zlepšení pokrytí:
Projekt nedávno zvýšil pokrytí z 87 % na více než 90 % přidáním komplexních testů pro:
- Operace s úložištěm médií - 92,96 %
- Optimalizace obrázků - 91,74 %
- Funkčnost cache pluginu - rozsáhlé pokrytí
- Základní služby (CDN, notifikace, plánovač, workflow) - všechny >93 %
Testování pluginů
Struktura testů pluginu
Pluginy obsahují své vlastní testovací soubory:
src/plugins/cache/
├── services/
│ ├── cache.ts
│ └── cache-config.ts
└── tests/
└── cache.test.ts
Příklad testu pluginu
describe('CacheService - Batch Operations', () => {
let cache: CacheService
beforeEach(() => {
cache = createCacheService(CACHE_CONFIGS.content!)
})
it('should get multiple values at once', async () => {
await cache.set('key1', 'value1')
await cache.set('key2', 'value2')
await cache.set('key3', 'value3')
const results = await cache.getMany(['key1', 'key2', 'key3', 'key4'])
expect(results.size).toBe(3)
expect(results.get('key1')).toBe('value1')
expect(results.get('key2')).toBe('value2')
expect(results.has('key4')).toBe(false)
})
it('should set multiple values at once', async () => {
await cache.setMany([
{ key: 'key1', value: 'value1' },
{ key: 'key2', value: 'value2' },
{ key: 'key3', value: 'value3' }
])
const value1 = await cache.get('key1')
const value2 = await cache.get('key2')
expect(value1).toBe('value1')
expect(value2).toBe('value2')
})
})
Plugin Tests
Pomocné funkce a utility pro testy
Běžné pomocné funkce pro testy
// Authentication
export const ADMIN_CREDENTIALS = {
email: 'admin@catcms.app',
password: 'admin123'
}
export async function loginAsAdmin(page: Page) {
await ensureAdminUserExists(page)
await page.goto('/auth/login')
await page.fill('[name="email"]', ADMIN_CREDENTIALS.email)
await page.fill('[name="password"]', ADMIN_CREDENTIALS.password)
await page.click('button[type="submit"]')
await expect(page.locator('#form-response .bg-green-100')).toBeVisible()
await page.waitForURL('/admin', { timeout: 15000 })
}
// Navigation
export async function navigateToAdminSection(
page: Page,
section: 'collections' | 'content' | 'media' | 'users'
) {
await page.click(`a[href="/admin/${section}"]`)
await page.waitForURL(`/admin/${section}`)
}
// HTMX Support
export async function waitForHTMX(page: Page) {
try {
await page.waitForLoadState('networkidle', { timeout: 5000 })
} catch {
await page.waitForTimeout(1000)
}
}
// API Health Check
export async function checkAPIHealth(page: Page) {
const response = await page.request.get('/health')
expect(response.ok()).toBeTruthy()
const health = await response.json()
expect(health.status).toBe('running')
return health
}Test Helpers
Osvědčené postupy
1. Organizace testů
- Udržujte testy blízko kódu - Unit testy jsou umístěny vedle kódu, který testují
- Logické seskupování - Používejte
describebloky k organizaci souvisejících testů - Jasné pojmenování - Názvy testů by měly popisovat, co se testuje a jaký je očekávaný výsledek
describe('CacheService - Pattern Invalidation', () => {
it('should invalidate entries matching pattern', async () => {
// Test implementation
})
it('should not invalidate entries that do not match pattern', async () => {
// Test implementation
})
})
Test Organization
2. Nezávislost testů
- Každý test by měl být nezávislý a neměl by se spoléhat na ostatní testy
- Používejte
beforeEachk nastavení čerstvého stavu - V případě potřeby po testech ukliďte
describe('My Feature', () => {
beforeEach(() => {
// Set up fresh state for each test
cache = createCacheService(config)
})
afterEach(async () => {
// Clean up if needed
await cache.clear()
})
})Test Independence
3. Asynchronní testování
- Vždy používejte
async/awaitpro asynchronní operace - Nezapomeňte na
awaitpromise v testech
// Good
it('should fetch data', async () => {
const result = await fetchData()
expect(result).toBeDefined()
})
// Bad - missing await
it('should fetch data', async () => {
const result = fetchData() // Missing await!
expect(result).toBeDefined() // Will fail
})
Async Testing
4. Osvědčené postupy pro Playwright
- Používejte pomocné funkce pro testy - Vytvářejte znovupoužitelné funkce pro běžné operace
- Čekejte na prvky - Používejte vestavěné mechanismy čekání Playwrightu
- Vyhněte se pevným časovým limitům - Preferujte
waitForSelectorpředwaitForTimeout - Zpracování HTMX - Používejte
waitForHTMXpomocnou funkci pro dynamické aktualizace
Testing Best Practices
- Vždy definujte TypeScript rozhraní pro testovací data - Rozdělujte složité testy na menší, cílené testy - Používejte fixtures a factories pro konzistentní testovací data - Testujte úspěšné i neúspěšné případy - Ověřujte chybové zprávy a stavové kódy - Udržujte rychlost testů mockováním externích závislostí - Spouštějte testy v CI/CD pipeline před nasazením
Další kroky