Add unit, integration and e2e test suites

tests/{unit,integration,e2e}/, at the repo root (not under frontend/,
since e2e exercises the frontend and PocketBase together) — all
against real code rather than mocks:

- unit/: Vitest (nuxt environment) — pure functions in server/utils
  and shared/utils, plus components/composables via
  @nuxt/test-utils/runtime's mountSuspended.
- integration/: Vitest (node environment) — @nuxt/test-utils/e2e's
  setup() builds and runs the real Nitro server against a real
  ephemeral PocketBase instance, exercising the actual /content/* and
  /admin routes end to end.
- e2e/: Playwright, a real browser against the built app and another
  ephemeral PocketBase, covering the user-facing flows (home, blog
  archive, article detail incl. JSON-LD, category filter, 404s, the
  /admin redirect).

The Vitest/Playwright tooling (and node_modules) stays in frontend/,
the repo's only npm project; its configs just point at ../tests/.
Playwright resolves packages from the node_modules nearest each spec
file, so `npm run test:e2e` first symlinks tests/node_modules to
frontend/node_modules (pretest:e2e, idempotent, gitignored).

tests/support/pocketbase.ts is the shared piece behind integration
and e2e: it downloads the same pinned PocketBase binary
pocketbase/Dockerfile uses, starts it against the real
pocketbase/pb_migrations/ (not a copy), and seeds it from
tests/support/fixtures.ts — so these tests run against the exact
schema and API rules that ship to production, catching the class of
bug that only shows up when PocketBase actually enforces them (e.g. a
wrong filter/fields query string silently returning nothing, or too
much).

CLAUDE.md and docs/frontend.md document the new npm scripts and the
single-test commands for each layer.
This commit is contained in:
2026-09-11 11:48:43 +02:00
parent 6609e3ae28
commit 91181f2170
23 changed files with 1767 additions and 75 deletions
+11
View File
@@ -0,0 +1,11 @@
import { expect, test } from '@playwright/test'
test('/admin redirects to the real PocketBase dashboard', async ({ page }) => {
await page.goto('/admin')
await expect(page).toHaveURL(/\/_\/(#.*)?$/)
// The static <title> is "PocketBase"; the dashboard SPA then renders its
// login view and retitles the page — asserting the live title also proves
// the dashboard's own JS bundle loaded and ran correctly through the proxy.
await expect(page).toHaveTitle('Superuser login')
})
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from '@playwright/test'
test('article detail renders body, breadcrumb and structured data', async ({ page }) => {
await page.goto('/blog/articolo-pubblicato')
await expect(page.getByRole('heading', { level: 1, name: 'Articolo pubblicato' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Economia' })).toHaveAttribute(
'href',
'/category/economia'
)
await expect(page.locator('.prose strong')).toHaveText('markdown')
const jsonLd = await page.locator('script[type="application/ld+json"]').textContent()
const data = JSON.parse(jsonLd ?? '{}')
expect(data['@type']).toBe('BlogPosting')
expect(data.headline).toBe('Articolo pubblicato')
})
test('unknown article slug renders the 404 page', async ({ page }) => {
const response = await page.goto('/blog/non-esiste')
expect(response?.status()).toBe(404)
await expect(page.getByRole('heading', { name: 'Pagina non trovata' })).toBeVisible()
})
+9
View File
@@ -0,0 +1,9 @@
import { expect, test } from '@playwright/test'
test('blog archive lists only published articles', async ({ page }) => {
await page.goto('/blog')
await expect(page.getByRole('link', { name: 'Secondo articolo pubblicato' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Articolo pubblicato', exact: true })).toBeVisible()
await expect(page.getByText('Articolo in bozza')).toHaveCount(0)
})
+14
View File
@@ -0,0 +1,14 @@
import { expect, test } from '@playwright/test'
test('category page filters articles to that category only', async ({ page }) => {
await page.goto('/category/attualita')
await expect(page.getByRole('link', { name: 'Secondo articolo pubblicato' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Articolo pubblicato', exact: true })).toHaveCount(0)
})
test('unknown category slug 404s', async ({ page }) => {
const response = await page.goto('/category/non-esiste')
expect(response?.status()).toBe(404)
})
+18
View File
@@ -0,0 +1,18 @@
import { startTestPocketbase } from '../support/pocketbase'
import { E2E_POCKETBASE_PORT } from '../../frontend/playwright.config'
/**
* Starts one PocketBase instance, shared by the whole e2e run, on the fixed
* port `playwright.config.ts` also bakes into the Nuxt server's env — so both
* sides can reference it without needing an async hand-off between them.
*
* Playwright keeps this module's closure alive and calls the returned
* function as teardown once all tests finish (no separate globalTeardown
* file needed).
*/
export default async function globalSetup() {
const pb = await startTestPocketbase({ port: E2E_POCKETBASE_PORT })
return async () => {
await pb.stop()
}
}
+11
View File
@@ -0,0 +1,11 @@
import { expect, test } from '@playwright/test'
test('home page shows the latest published article as featured', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('heading', { name: 'Secondo articolo pubblicato' })).toBeVisible()
await expect(page.getByRole('link', { name: "Leggi l'ultimo articolo" })).toHaveAttribute(
'href',
'/blog/secondo-articolo'
)
})