This document describes the test infrastructure, conventions, and patterns used across the Huella Latam monorepo: the apps/api integration suite (Vitest + Testcontainers) and the apps/web unit suite (Vitest + jsdom + React Testing Library — see Web unit tests).
The API uses Vitest with Testcontainers for integration testing. Tests run against real PostgreSQL and object-storage containers (Azurite for the storage-azure project, MinIO for storage-minio) — there are no mocks for the database layer. All API tests live under apps/api/test/; the web app's unit tests are co-located under apps/web/src/ (see Web unit tests).
| Aspect | Detail |
|---|---|
| Framework | Vitest 4.x |
| Test type | Integration (HTTP layer + real DB) |
| Database | Testcontainers — postgres:18-alpine |
| Storage | Testcontainers — Azurite (storage-azure project) or MinIO (storage-minio project) |
| Authentication | AUTH_PROVIDER=forced-user (hardcoded for all tests) |
| Execution | Parallel — files run across workers, each file gets its own database |
| Coverage | v8 provider; 90% gate (all four metrics) declared in the config and applied to any full/merged run; the single-project test:leg leg overrides it to 0 — see the coverage note below |
apps/api/test/
├── setup/
│ ├── globalSetup.ts # Starts/stops containers; migrates + seeds the template DB
│ ├── testDatabase.ts # PostgreSQL container + migration/seed helpers
│ ├── testStorage.ts # Storage container (Azurite or MinIO, per project name)
│ ├── perFileDatabase.ts # Clones a private DB per test file from the template
│ ├── storageTestManifest.ts # Manifest of storage-dependent tests (both storage CI legs)
│ └── assertStorageTestManifest.ts # Static verifier (pnpm test:api:verify-storage-manifest)
├── factories/ # Test data helpers
│ ├── appFactory.ts # Creates a ready Fastify test instance
│ ├── userFactory.ts
│ ├── organizationFactory.ts
│ ├── submissionFactory.ts
│ ├── carbonInventorySeeder.ts
│ ├── storageHelper.ts # Seeds fixture objects via the storage adapter
│ └── ... # One factory per domain entity
└── features/ # Integration tests
├── carbonInventories/
├── files/
├── organizations/
├── submissions/
├── users/
└── ...
Test files follow the naming pattern:
test/features/<feature>/<action>/integration.test.ts
test/features/<feature>/<action>/service.test.ts # service-level unit tests
test/setup/globalSetup.ts runs once per Vitest project before that project's tests:
- PostgreSQL container —
postgres:18-alpine, credentialstestuser:testpass, databasetestdb. Startup timeout: 180 s (accounts for first image pull in CI). - Storage container — chosen from the project name (not an env var): the
storage-azureproject boots Azurite (mcr.microsoft.com/azure-storage/azurite, in-memory),storage-minioboots MinIO (minio/minio), andbaseboots none (its tests never touch storage). Seetest/setup/testStorage.ts. Thetest-filescontainer/bucket is not pre-created — the test adapters in@repo/storage/testingcreate it lazily and idempotently. Startup timeout: 120 s. If a storage container fails to start, that project fails fast (every file in it needs the container);baseis unaffected, as it boots none. - Migrations —
prisma migrate deployis executed once against the template database (testdb). - Seeding — the
@repo/seedrunner (pnpm run seedintools/seed) withSEEDS_DATASET=testingpopulates all lookup tables (countries, job positions, methodologies, etc.) in the template. - Context injection — the template database URL (
databaseUrl) and a storage descriptor (storageDescriptor, provider + connection details, ornullforbase) are passed to that project's workers via Vitest'sproject.provide()interface. The dynamic MinIO endpoint is applied from that descriptor increateTestAppbeforeapp.ready().
The teardown function stops the project's containers after its tests complete.
Local Docker footprint. Because
globalSetupruns once per project, a full localpnpm test:api(onevitest run --coverageover all three projects) can stand up three PostgreSQL containers plus Azurite and MinIO at peak — heavier than running the legs one at a time. On a constrained machine or a tight Docker Desktop limit, run a single project for a lighter inner loop:pnpm --filter=api exec vitest run --project=base --coverage=false(baseboots only PostgreSQL). CI is unaffected — each runner runs one--project.
test/setup/perFileDatabase.ts (registered in setupFiles, so it runs once per
test file) gives every file its own database, cloned from the seeded template:
CREATE DATABASE "t_<sha256(testFilePath)[:16]>" TEMPLATE "testdb"TEMPLATE testdbis a fast, file-level Postgres copy of the already-migrated and seeded database — no per-file migrate/seed cost.createTestApp(...)automatically connects to the current file's database, so the standardcreateTestApp(inject("databaseUrl"))call needs no changes.- Consequence: each file starts from pristine seeded state. You do not need to clean up rows so the next file stays clean — files can no longer contaminate each other, which is what lets them run in parallel.
- The per-file databases are not dropped: the Postgres container is ephemeral and is discarded when the suite ends. On the rare name clash (hash collision or a re-run against a live container) the setup appends a numeric suffix so the file still gets its own fresh database.
Note: the storage container (Azurite/MinIO) and its
test-filesbucket are still shared across files. Storage-touching tests should use unique keys.
Requirements:
- Docker must be running before executing tests.
- On Linux:
sudo systemctl start docker
# Run every test suite (API storage legs + web + seed)
pnpm test
# Run all API tests (all three Vitest projects), merge coverage, apply the gate
pnpm test:api
# Lighter inner loop: run a single project (base boots only PostgreSQL — no
# storage container). Skip the gate, which a partial run can't clear.
pnpm --filter=api exec vitest run --project=base --coverage=false
# Run the apps/web suite (Vitest + jsdom; builds @repo/* deps first).
# Runs with coverage and enforces a global coverage floor (see the note below).
pnpm test:web
# Run the tools/seed unit tests
pnpm test:seed
# Run a single API test file
pnpm test --filter=api -- /createUser/integration.test.ts --coverage=false
# Run all tests for a domain
pnpm test --filter=api -- /organizations --coverage=false
# Open the Vitest UI dashboard
pnpm --filter=api test:uiWeb coverage floor.
pnpm test:webruns with--coverageand enforces a low global coverage floor (thresholds inapps/web/vitest.config.ts). Unlike the API — which enforces a merged 90% gate on all four metrics — the web floor is deliberately low:coverage.allcounts every file undersrc/**, and the app is ~98% render-heavy, untestedscreens//components/, so a high global gate is impractical. The floor is a regression guard set just below the current level to prevent backsliding, and is meant to be ratcheted up over time as the logic layers (utils/,hooks/,stores/,components/Chatbot/) gain tests — raise it by adding coverage, not by bumping the number, since adding untested UI lowers the global percentage. The coverage-free fast dev loop ispnpm --filter=web test:watch(no threshold checks).
The web app is tested with Vitest + jsdom + React Testing Library. Tests are co-located next to the source as *.test.ts(x) (unlike the API's separate test/ tree) and picked up by include: ["src/**/*.{test,spec}.{ts,tsx}"] in apps/web/vitest.config.ts.
Run them with pnpm test:web (Turbo builds the @repo/* deps first, then runs Vitest with coverage). Use pnpm --filter=web test:watch for the coverage-free fast loop. Config: apps/web/vitest.config.ts + vitest.setup.ts.
Coverage targets the logic layers, each at ~100%:
| Layer | Examples |
|---|---|
utils/ |
getApiErrorMessage, formatting, the Excel exporters, files, validateLineFileOriginalName |
hooks/ |
useCarbonInventoryAccess, useReductionProjectAccess, useFuzzySearch, useChatbotSize |
stores/ |
userStore, sidebarStore (Zustand) |
labels/chips/ |
the enum → label/color chip mappers |
components/Chatbot/ |
useChatStream, MessageBubble, ChatbotIcon |
| route guards | requireRole |
Render-heavy screens//components/ and the api/** query hooks are deliberately deferred — they need render/MSW scaffolding and a dedicated effort; browser E2E (Playwright) is not set up. As a result the global coverage figure is low (~8% lines) even though the covered files are near-100%.
pnpm test:web enforces a low global coverage floor (thresholds in vitest.config.ts). Because Vitest's coverage.all counts every file under src/** (~9,900 lines) and the app is ~98% untested UI, the floor is a regression guard, not a target. It is set just under the current level and ratcheted up as tests are added — raise it by adding coverage, never by just bumping the number. Adding untested UI lowers the global percentage, so the floor keeps a little headroom below current.
- Assert exact Spanish strings — mirror private/user-facing copy as local constants so a copy change trips a test deliberately.
- Determinism — no
Date.now()/ arglessnew Date(); usevi.useFakeTimers()for debounce/timeout logic and pass explicit dates to formatters. - Query-backed hooks (
useCarbonInventoryAccess, …) —vi.mockthe underlying TanStack Query hook (viavi.hoisted) andrenderHook; noQueryClientProviderneeded because the realuseQuerynever runs. - Zustand stores — drive via
store.getState()/store.setState(); no render. - Components — React Testing Library
render+screen; MUI'suseTheme()falls back to the default theme, so noThemeProviderwrapper is needed for content/role assertions. - Excel exporters — round-trip the written buffer back through a fresh
ExcelJS.Workbook().xlsx.load(...)and assert on cells. - jsdom gaps handled in
vitest.setup.ts— an in-memorylocalStoragepolyfill (jsdom here providessessionStoragebut notlocalStorage); jest-dom matchers are registered there, with an ambientsrc/vitest.d.ts(import "@testing-library/jest-dom/vitest") sotscseestoBeInTheDocumentetc.
CI resolution gotcha: always run via
pnpm test:web(turbo run test --filter=web), which builds the@repo/*deps todistfirst. A barepnpm --filter=web testskips that build and fails to resolve@repo/*on a clean checkout.
Place the file at:
apps/api/test/features/<feature>/<action>/integration.test.ts
import { describe, it, expect, beforeAll, afterAll, afterEach, inject } from "vitest";
import { createTestApp } from "@test/factories/appFactory.js";
import type { FastifyInstance } from "fastify";
import type { PrismaClient } from "@repo/database";
describe("POST /api/<feature> - Integration Tests", () => {
let app: FastifyInstance;
let prisma: PrismaClient;
beforeAll(async () => {
const databaseUrl = inject("databaseUrl"); // injected by globalSetup
app = await createTestApp(databaseUrl);
prisma = app.prisma;
});
afterAll(async () => {
await prisma.$disconnect();
await app.close();
});
// Optional: this file has its own database, so cleanup is NOT required for
// isolation from other files. Add an afterEach only if tests within THIS file
// need a clean slate from each other.
afterEach(async () => {
await prisma.<model>.deleteMany({ where: { ... } });
});
it("should <expected behavior>", async () => {
const response = await app.inject({
method: "POST",
url: "/api/<feature>",
payload: { ... },
});
expect(response.statusCode).toBe(201);
// Verify database state
const record = await prisma.<model>.findFirst({ where: { ... } });
expect(record).toBeDefined();
});
});| Convention | Detail |
|---|---|
| Get the DB URL | const databaseUrl = inject("databaseUrl") |
| Create the app | createTestApp(databaseUrl) |
| Storage tests | Pass { storageDescriptor: inject("storageDescriptor") } to createTestApp and list the file in the storage test manifest; without a descriptor, app.storage is a throwing adapter |
| Make HTTP calls | app.inject({ method, url, payload }) |
| Seed lookup data | Already present from global seed; query with prisma.<model>.findFirst() |
| Create test entities | Use the factories in test/factories/ |
| Clean up | Not needed for isolation — each file has its own database. Use afterEach/beforeEach only if a test needs a clean slate from other tests in the same file |
| Auth | All requests are automatically authenticated as the seeded test user; no auth headers needed |
Factories create test-specific entities and return them for use in assertions. They are not fixtures — they write to the database.
| Factory | Purpose |
|---|---|
appFactory.ts |
createTestApp(databaseUrl, options?) — Fastify instance with Prisma; a real storage adapter when storageDescriptor is passed, a throwing adapter otherwise |
userFactory.ts |
getTestLoggedUser(), createTestUser(), cleanupTestUsers() |
organizationFactory.ts |
createTestOrganization(), cleanupTestOrganization() |
organizationDataFactory.ts |
Creates OrganizationData linked to an organization |
submissionFactory.ts |
buildOrganizationDataSubmission() — creates org → org data → submission chain |
carbonInventorySeeder.ts |
cleanupCarbonInventoryTestData() |
methodologyFactory.ts |
getTestMethodologyVersionId(), getTestCountryId() |
fileFactory.ts |
createTestFile(), createTestFileForSubmission(), createTestFileForBadge() |
storageHelper.ts |
uploadFixture() — seeds a fixture object via the storage adapter (provider-agnostic) |
For features that span multiple HTTP calls (e.g., request-upload → upload → confirm), write a single it block that executes the full sequence. These tests touch real storage, so create the app with createTestApp(databaseUrl, { storageDescriptor: inject("storageDescriptor") }) and list the file in the storage test manifest:
it("should complete the full file upload lifecycle", async () => {
// Step 1 — Request an upload URL
const requestResponse = await app.inject({
method: "POST",
url: "/api/files/badge/CARBON_INVENTORY_CALCULATION/request-upload",
payload: { originalName: "badge.png" },
});
expect(requestResponse.statusCode).toBe(200);
const { uuid, uploadUrl } = JSON.parse(requestResponse.body);
// Step 2 — Simulate client uploading the file directly to storage
const blobPath = `BADGE/CARBON_INVENTORY_CALCULATION/${uuid}-badge.png`;
await uploadFixture(app.storage, blobPath, { contentType: "image/png" });
// Step 3 — Confirm the upload
const confirmResponse = await app.inject({
method: "POST",
url: "/api/files/badge/CARBON_INVENTORY_CALCULATION/confirm-upload",
payload: { uuid, originalName: "badge.png" },
});
expect(confirmResponse.statusCode).toBe(201);
});For complex service functions, a service-level test (without HTTP) can pass a stubbed
storage adapter straight into the service. Use createMockStorageAdapter() — it returns a
StorageAdapter whose every method is a vi.fn() with a canned default, so you assert on
calls or override individual methods per test:
import { createMockStorageAdapter } from "@test/factories/mockStorageAdapter.js";
const mockStorage = createMockStorageAdapter();
// Override a single method for this test:
mockStorage.createReadUrlSigner.mockResolvedValue(signerSpy);
// Inject it into the service under test (no HTTP, no container):
const result = await getOrganizationHistory({ storage: mockStorage /* ... */ });
expect(mockStorage.createReadUrlSigner).toHaveBeenCalledTimes(1);When you already have an app from createTestApp, spy on the real adapter instead of
mocking a module — e.g. vi.spyOn(app.storage, "copyObject") to make a copy/delete inert
or to assert it ran.
Use this pattern sparingly. Prefer integration tests with real Testcontainers where the overhead is acceptable.
Every new endpoint should have tests covering:
| Case | Assertion |
|---|---|
| Happy path | HTTP 2xx, response shape, database state |
| Validation error | HTTP 400, error message |
| Not found | HTTP 404 for unknown IDs |
| Authorization | HTTP 403 when user lacks required role |
| Constraint violation | HTTP 4xx for FK violations, unique conflicts |
Some behaviour is not covered by either suite — most notably the calculator's client-side arithmetic, factor auto-resolution and display formatting, which all live in the browser. For those there is a pinned, reproducible acceptance case:
| Document | Scope |
|---|---|
| Manual Testing — Emission Capture | Calculator step 3: fixture inventory, exact inputs to type, expected line/subcategory/category totals, SQL cross-check, and known display artifacts |
Run it after changing factor resolution, unit conversion, emission aggregation, or number formatting.
Everything lives in apps/api/vitest.config.ts: a local defineApiVitestProject helper builds one project, and the config assembles three projects (test.projects) — base (the full suite minus the storage manifest), storage-azure, and storage-minio (only the storage manifest, one per provider). Coverage and the other root-only options sit once on the root test. One config drives both vitest run --coverage locally and the --project=<leg> legs in CI:
| Setting | Value | Reason |
|---|---|---|
maxWorkers |
4 | Run files in parallel; safe because each file has its own database |
fileParallelism |
true | Files run concurrently across workers |
testTimeout |
30 000 ms | Allows for slower container I/O |
hookTimeout |
30 000 ms | Allows beforeAll/afterAll to complete |
teardownTimeout |
10 000 ms | Container shutdown grace period |
globalSetup |
./test/setup/globalSetup.ts |
Container lifecycle; migrate + seed the template DB |
setupFiles |
./test/setup/perFileDatabase.ts |
Clones a private database per test file |
coverage.thresholds |
90% for all four metrics (branches, functions, lines, statements), declared in the config | The gate lives where it is read; test:leg (a single-project, partial run) overrides it to 0 on the CLI — see the coverage note below |
Note on coverage: the 90% gate (all four metrics — lines, statements, functions, and branches) is declared once in
vitest.config.ts(test.coverage.thresholds) and applies to any full run. The suite is partitioned into three projects (base,storage-azure,storage-minio), so a single project never exercises the whole codebase — a per-project threshold would fail on the files it never runs. The one run that opts out istest:leg, which runs a single--projectand overrides the thresholds to 0 on the CLI; the gate is then applied once the projects' coverage is merged. v8 merges hit-counts, so a line covered by any project counts as covered.
- Locally:
pnpm test:apiruns all three projects in onevitest run --coverage, merges their coverage, and applies the gate.- In CI: each leg emits a blob report (
--reporter=blob); thecoveragejob merges them (vitest run --merge-reports --coverage) and applies the gate. No external merge script.See the
coveragejob in the CI/CD guide.