chore: document and stabilize vitest browser setup
This commit is contained in:
parent
42942350dc
commit
eee0f432e7
12 changed files with 1238 additions and 325 deletions
|
|
@ -46,6 +46,7 @@ Aplicação Next.js 15 com Convex e Better Auth para gestão de tickets da Rever
|
||||||
- Índice de docs: `docs/README.md`
|
- Índice de docs: `docs/README.md`
|
||||||
- Operações (produção): `docs/operations.md`
|
- Operações (produção): `docs/operations.md`
|
||||||
- Guia de DEV: `docs/DEV.md`
|
- Guia de DEV: `docs/DEV.md`
|
||||||
|
- Testes automatizados (Vitest/Playwright): `docs/testes-vitest.md`
|
||||||
- Stack Swarm: `stack.yml` (roteado por Traefik, rede `traefik_public`).
|
- Stack Swarm: `stack.yml` (roteado por Traefik, rede `traefik_public`).
|
||||||
|
|
||||||
### Variáveis de ambiente
|
### Variáveis de ambiente
|
||||||
|
|
|
||||||
70
docs/testes-vitest.md
Normal file
70
docs/testes-vitest.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Guia de Testes com Vitest 4
|
||||||
|
|
||||||
|
Este documento resume a configuração atual de testes e como aproveitá-la para automatizar novas verificações.
|
||||||
|
|
||||||
|
## Comandos principais
|
||||||
|
|
||||||
|
- `pnpm test` → roda a suíte unitária em ambiente Node/JSdom.
|
||||||
|
- `pnpm test:browser` → executa os testes de navegador via Playwright (Chromium headless).
|
||||||
|
- `pnpm test:all` → executa as duas suítes de uma vez (requer Playwright instalado).
|
||||||
|
|
||||||
|
> Sempre que adicionar novos testes, priorize mantê-los compatíveis com esses dois ambientes.
|
||||||
|
|
||||||
|
## Pré-requisitos
|
||||||
|
|
||||||
|
1. Dependências JavaScript já estão listadas em `package.json` (`vitest`, `@vitest/browser-playwright`, `playwright`, `jsdom`, etc.).
|
||||||
|
2. Baixe os binários do Playwright uma vez:
|
||||||
|
```bash
|
||||||
|
pnpm exec playwright install chromium
|
||||||
|
```
|
||||||
|
3. Em ambientes Linux “puros”, instale as bibliotecas de sistema recomendadas:
|
||||||
|
```bash
|
||||||
|
sudo apt-get install libnspr4 libnss3 libasound2t64
|
||||||
|
# ou
|
||||||
|
sudo pnpm exec playwright install-deps
|
||||||
|
```
|
||||||
|
|
||||||
|
Se o Playwright avisar sobre dependências ausentes ao rodar `pnpm test:browser`, instale-as e repita o comando.
|
||||||
|
|
||||||
|
## Estrutura de setup
|
||||||
|
|
||||||
|
- `vitest.setup.node.ts` → executado apenas na suíte Node. Aqui é seguro acessar `process`, configurar variáveis de ambiente, carregar `tsconfig-paths/register`, etc.
|
||||||
|
- `tests/setup.browser.ts` → setup vazio para a suíte de navegador. Não use `process` ou APIs do Node aqui; adicione polyfills/mocks específicos do browser quando necessário.
|
||||||
|
|
||||||
|
O arquivo `vitest.config.mts` seleciona automaticamente o setup correto com base na env `VITEST_BROWSER`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
setupFiles: process.env.VITEST_BROWSER
|
||||||
|
? ["./tests/setup.browser.ts"]
|
||||||
|
: ["./vitest.setup.node.ts"],
|
||||||
|
```
|
||||||
|
|
||||||
|
## Boas práticas para novos testes
|
||||||
|
|
||||||
|
- **Aliases (`@/`)**: continuam funcionando em ambos os ambientes graças ao `vite-tsconfig-paths`.
|
||||||
|
- **Variáveis de ambiente no browser**: use `import.meta.env.VITE_*`. Evite `process.env` no código que será executado no navegador.
|
||||||
|
- **Mocks Playwright**: para testes de browser, use os helpers de `vitest/browser`. Exemplo:
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "vitest"
|
||||||
|
import { page } from "vitest/browser"
|
||||||
|
|
||||||
|
test("exemplo", async () => {
|
||||||
|
await page.goto("https://example.com")
|
||||||
|
await expect(page.getByRole("heading", { level: 1 })).toBeVisible()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
No nosso exemplo atual (`tests/browser/example.browser.test.ts`) manipulamos o DOM diretamente e geramos screenshots com `expect(...).toMatchScreenshot(...)`.
|
||||||
|
- **Snapshots visuais**: os arquivos de referência ficam em `tests/browser/__screenshots__/`. Ao criar ou atualizar um snapshot, revise e commite apenas se estiver correto.
|
||||||
|
- **Mocks que dependem de `vi.fn()`**: quando mockar classes/constructores (ex.: `ConvexHttpClient`), use funções nomeadas ou `class` ao definir a implementação para evitar os erros do Vitest 4 (“requires function or class”).
|
||||||
|
|
||||||
|
## Fluxo sugerido no dia a dia
|
||||||
|
|
||||||
|
1. Rode `pnpm test` localmente antes de abrir PRs.
|
||||||
|
2. Para alterações visuais/lógicas que afetem UI, adicione/atualize um teste em `tests/browser` e valide com `pnpm test:browser`.
|
||||||
|
3. Se novos snapshots forem criados ou alterados, confirme visualmente e inclua os arquivos em commit.
|
||||||
|
4. Para tarefas de automação futuras (por exemplo, smoke-tests que renderizam componentes críticos), utilize a mesma estrutura:
|
||||||
|
- Setup mínimo no `tests/setup.browser.ts`.
|
||||||
|
- Testes localizados em `tests/browser/**.browser.test.ts`.
|
||||||
|
- Utilização de Playwright para interagir com a UI e gerar screenshots/asserts.
|
||||||
|
|
||||||
|
Seguindo este guia, conseguimos manter a suíte rápida no ambiente Node e, ao mesmo tempo, aproveitar o modo browser do Vitest 4 para validações visuais e regressões de UI. Quilas regressões detectadas automaticamente economizam tempo de QA manual e agilizam o ciclo de entrega.***
|
||||||
10
package.json
10
package.json
|
|
@ -10,6 +10,8 @@
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"convex:dev": "convex dev",
|
"convex:dev": "convex dev",
|
||||||
"test": "vitest --run --passWithNoTests",
|
"test": "vitest --run --passWithNoTests",
|
||||||
|
"test:browser": "cross-env VITEST_BROWSER=true vitest --run --browser.headless tests/browser/example.browser.test.ts --passWithNoTests",
|
||||||
|
"test:all": "cross-env VITEST_BROWSER=true vitest --run --passWithNoTests",
|
||||||
"auth:seed": "node scripts/seed-auth.mjs",
|
"auth:seed": "node scripts/seed-auth.mjs",
|
||||||
"queues:ensure": "node scripts/ensure-default-queues.mjs",
|
"queues:ensure": "node scripts/ensure-default-queues.mjs",
|
||||||
"desktop:dev": "pnpm --filter appsdesktop tauri dev",
|
"desktop:dev": "pnpm --filter appsdesktop tauri dev",
|
||||||
|
|
@ -80,14 +82,20 @@
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"@types/three": "^0.180.0",
|
"@types/three": "^0.180.0",
|
||||||
|
"@vitest/browser-playwright": "^4.0.1",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "^16.0.0",
|
"eslint-config-next": "^16.0.0",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"jsdom": "^27.0.1",
|
||||||
|
"playwright": "^1.56.1",
|
||||||
"prisma": "^6.16.2",
|
"prisma": "^6.16.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
"tw-animate-css": "^1.3.8",
|
"tw-animate-css": "^1.3.8",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^2.1.4"
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^4.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1338
pnpm-lock.yaml
generated
1338
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,11 +4,17 @@ const mutationMock = vi.fn()
|
||||||
const deleteManyMock = vi.fn()
|
const deleteManyMock = vi.fn()
|
||||||
const assertAuthenticatedSession = vi.fn()
|
const assertAuthenticatedSession = vi.fn()
|
||||||
|
|
||||||
vi.mock("convex/browser", () => ({
|
vi.mock("convex/browser", () => {
|
||||||
ConvexHttpClient: vi.fn().mockImplementation(() => ({
|
const ConvexHttpClient = vi.fn(function ConvexHttpClientMock() {
|
||||||
|
return {
|
||||||
mutation: mutationMock,
|
mutation: mutationMock,
|
||||||
})),
|
}
|
||||||
}))
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
ConvexHttpClient,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({
|
vi.mock("@/lib/prisma", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
|
|
@ -32,6 +38,12 @@ describe("POST /api/admin/machines/delete", () => {
|
||||||
mutationMock.mockReset()
|
mutationMock.mockReset()
|
||||||
deleteManyMock.mockReset()
|
deleteManyMock.mockReset()
|
||||||
assertAuthenticatedSession.mockReset()
|
assertAuthenticatedSession.mockReset()
|
||||||
|
mutationMock.mockImplementation(async (_ctx, payload) => {
|
||||||
|
if (payload && typeof payload === "object" && "machineId" in payload) {
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
return { _id: "user_123" }
|
||||||
|
})
|
||||||
assertAuthenticatedSession.mockResolvedValue({
|
assertAuthenticatedSession.mockResolvedValue({
|
||||||
user: {
|
user: {
|
||||||
email: "admin@example.com",
|
email: "admin@example.com",
|
||||||
|
|
@ -41,8 +53,7 @@ describe("POST /api/admin/machines/delete", () => {
|
||||||
avatarUrl: null,
|
avatarUrl: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
mutationMock.mockResolvedValueOnce({ _id: "user_123" })
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(function noop() {})
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
|
|
||||||
restoreConsole = () => consoleSpy.mockRestore()
|
restoreConsole = () => consoleSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -52,7 +63,6 @@ describe("POST /api/admin/machines/delete", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("returns ok when the machine removal succeeds", async () => {
|
it("returns ok when the machine removal succeeds", async () => {
|
||||||
mutationMock.mockResolvedValueOnce({ ok: true })
|
|
||||||
const { POST } = await import("./route")
|
const { POST } = await import("./route")
|
||||||
const response = await POST(
|
const response = await POST(
|
||||||
new Request("http://localhost/api/admin/machines/delete", {
|
new Request("http://localhost/api/admin/machines/delete", {
|
||||||
|
|
@ -69,7 +79,12 @@ describe("POST /api/admin/machines/delete", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("still succeeds when the Convex machine is already missing", async () => {
|
it("still succeeds when the Convex machine is already missing", async () => {
|
||||||
mutationMock.mockRejectedValueOnce(new Error("Máquina não encontrada"))
|
mutationMock.mockImplementation(async (_ctx, payload) => {
|
||||||
|
if (payload && typeof payload === "object" && "machineId" in payload) {
|
||||||
|
throw new Error("Máquina não encontrada")
|
||||||
|
}
|
||||||
|
return { _id: "user_123" }
|
||||||
|
})
|
||||||
const { POST } = await import("./route")
|
const { POST } = await import("./route")
|
||||||
const response = await POST(
|
const response = await POST(
|
||||||
new Request("http://localhost/api/admin/machines/delete", {
|
new Request("http://localhost/api/admin/machines/delete", {
|
||||||
|
|
@ -84,7 +99,12 @@ describe("POST /api/admin/machines/delete", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("returns an error for other Convex failures", async () => {
|
it("returns an error for other Convex failures", async () => {
|
||||||
mutationMock.mockRejectedValueOnce(new Error("timeout error"))
|
mutationMock.mockImplementation(async (_ctx, payload) => {
|
||||||
|
if (payload && typeof payload === "object" && "machineId" in payload) {
|
||||||
|
throw new Error("timeout error")
|
||||||
|
}
|
||||||
|
return { _id: "user_123" }
|
||||||
|
})
|
||||||
const { POST } = await import("./route")
|
const { POST } = await import("./route")
|
||||||
const response = await POST(
|
const response = await POST(
|
||||||
new Request("http://localhost/api/admin/machines/delete", {
|
new Request("http://localhost/api/admin/machines/delete", {
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
40
tests/browser/example.browser.test.ts
Normal file
40
tests/browser/example.browser.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { expect, test } from "vitest"
|
||||||
|
|
||||||
|
test("CTA button snapshot", async () => {
|
||||||
|
const html = `
|
||||||
|
<main
|
||||||
|
style="
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
padding: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #111827, #1f2937);
|
||||||
|
min-height: 320px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
data-testid="cta"
|
||||||
|
style="
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
background: #2563eb;
|
||||||
|
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.35);
|
||||||
|
cursor: pointer;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Abrir chamado
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
`
|
||||||
|
|
||||||
|
document.body.innerHTML = html
|
||||||
|
|
||||||
|
const ctaButton = document.querySelector("[data-testid='cta']")
|
||||||
|
expect(ctaButton).toBeTruthy()
|
||||||
|
|
||||||
|
await expect(document.body).toMatchScreenshot("cta-button")
|
||||||
|
})
|
||||||
2
tests/setup.browser.ts
Normal file
2
tests/setup.browser.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Browser-specific Vitest bootstrap. Keep this file free of Node globals.
|
||||||
|
// Add global mocks or polyfills here when we start writing browser tests.
|
||||||
|
|
@ -1,22 +1,61 @@
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { defineConfig } from "vitest/config"
|
import { defineConfig } from "vitest/config"
|
||||||
|
import { playwright } from "@vitest/browser-playwright"
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths"
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const srcDir = path.resolve(__dirname, "./src")
|
||||||
|
const convexDir = path.resolve(__dirname, "./convex")
|
||||||
|
const isCI = process.env.CI === "true"
|
||||||
|
const isBrowserRun = process.env.VITEST_BROWSER === "true"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
root: __dirname,
|
||||||
|
plugins: [tsconfigPaths()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": srcDir,
|
||||||
"@/convex": path.resolve(__dirname, "./convex"),
|
"@/convex": convexDir,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
pool: (process.env.VITEST_POOL as "threads" | "forks" | "vmThreads" | undefined) ?? "threads",
|
|
||||||
environment: "node",
|
|
||||||
globals: true,
|
globals: true,
|
||||||
|
setupFiles: isBrowserRun ? ["./tests/setup.browser.ts"] : ["./vitest.setup.node.ts"],
|
||||||
|
pool: (process.env.VITEST_POOL as "threads" | "forks" | "vmThreads" | undefined) ?? "threads",
|
||||||
|
testTimeout: isBrowserRun ? 30000 : 15000,
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
include: ["src/**/*.{ts,tsx}", "convex/**/*.ts"],
|
||||||
|
exclude: ["**/*.d.ts", "**/*.test.*", "tests/**"],
|
||||||
|
reportsDirectory: "./coverage",
|
||||||
|
},
|
||||||
|
deps: {
|
||||||
|
registerNodeLoader: true,
|
||||||
|
},
|
||||||
|
alias: {
|
||||||
|
"@": srcDir,
|
||||||
|
"@/convex": convexDir,
|
||||||
|
},
|
||||||
|
browser: {
|
||||||
|
enabled: isBrowserRun,
|
||||||
|
provider: playwright({
|
||||||
|
launchOptions: {
|
||||||
|
headless: isCI ? true : undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
instances: [
|
||||||
|
{
|
||||||
|
browser: "chromium",
|
||||||
|
launch: {
|
||||||
|
headless: isCI ? true : false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
trace: isCI ? "on-first-retry" : "off",
|
||||||
|
},
|
||||||
|
environment: "jsdom",
|
||||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||||
setupFiles: ["./vitest.setup.ts"],
|
exclude: isBrowserRun ? [] : ["tests/browser/**/*.browser.test.ts"],
|
||||||
testTimeout: 15000,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
8
vitest.setup.node.ts
Normal file
8
vitest.setup.node.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
// Node-only Vitest bootstrap: keep browser runs free from process dependencies.
|
||||||
|
if (typeof process !== "undefined" && process.versions?.node) {
|
||||||
|
await import("tsconfig-paths/register")
|
||||||
|
|
||||||
|
process.env.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? "test-secret"
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
|
||||||
|
process.env.BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_APP_URL
|
||||||
|
}
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
process.env.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? "test-secret"
|
|
||||||
process.env.NEXT_PUBLIC_APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
|
|
||||||
process.env.BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_APP_URL
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue