This document provides comprehensive instructions for running and writing tests in the vatix-backend project.
The vatix-backend uses Vitest as the testing framework, which provides fast unit testing with excellent TypeScript support and built-in coverage reporting.
tests/
├── setup.ts # Global test setup and utilities
├── helpers/
│ └── test-database.ts # Database testing utilities
├── integration/
│ ├── helpers/
│ │ └── build-test-app.ts # Shared Fastify harness (sets API_KEY/ADMIN_TOKEN, clearRateLimitStores)
│ ├── health.test.ts # Real GET /v1/health against live test DB + degraded path
│ ├── markets.test.ts # Markets endpoint integration tests
│ ├── orders.test.ts # Order creation, validation, persistence, listing, matching
│ ├── admin.test.ts # Auth guard matrix + admin market mutations
│ └── positions.test.ts # Positions endpoint integration tests
└── sample.test.ts # Sample test demonstrating setup
| Test file | Route prefix | What it tests |
|---|---|---|
health.test.ts |
GET /v1/health |
Real DB ok path; degraded path (mocked Prisma failure) |
markets.test.ts |
GET /v1/markets |
Pagination, status filter, response envelope |
orders.test.ts |
POST /v1/orders, GET /v1/orders/user/:address |
Creation (201), DB persistence, decimal serialization, all 400 validation paths, status filter, CLOB matching |
admin.test.ts |
GET /v1/admin/markets, PATCH /v1/admin/markets/:id/status |
Five auth guard combinations (401/403), list includes CANCELLED, status mutation, invalid enum (400), unknown ID |
positions.test.ts |
GET /v1/wallets/:wallet/positions |
Position listing and PnL |
requireApiKey checks the x-api-key header against API_KEY env var.
requireAdmin checks Authorization: Bearer <token> against ADMIN_TOKEN env var.
Admin routes require both. Tests cover: no headers → 401, API key only → 401, Bearer only → 401, wrong key → 401, wrong token → 403.
tests/integration/helpers/build-test-app.ts exports buildTestApp({ plugins }) — builds a minimal Fastify instance with the real error handler, registers each plugin under /v1, sets API_KEY/ADMIN_TOKEN defaults.
See error-handler.md for the error envelope shape, custom error classes, and NODE_ENV behaviour.
Call resetRateLimits() from the same module in beforeEach to prevent rate-limit state bleeding between tests.
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/vatix
REDIS_URL=redis://localhost:6379
NODE_ENV=test
API_KEY=test-api-key # set automatically by buildTestApp if absent
ADMIN_TOKEN=test-admin-token # set automatically by buildTestApp if absent
- Test individual functions and components in isolation
- Use mocks for external dependencies
- Fast execution, suitable for TDD
- Test API endpoints with real database
- Use test database with deterministic fixtures
- Slower but more comprehensive testing
# Run all tests
npm test
# or
pnpm test
# Run tests in watch mode
npm run dev
# or
pnpm dev
# Run tests once (no watch)
npm run test:run
# or
pnpm test:run
# Run tests with coverage
npm run test:coverage
# or
pnpm test:coverage
# Run tests with UI
npm run test:ui
# or
pnpm test:ui# Run specific test file
npm test markets.test.ts
# Run tests matching pattern
npm test -- --grep "markets"
# Run tests in specific directory
npm test tests/integration/The test configuration is in vitest.config.ts:
- Environment: Node.js
- Pool: Forks (for proper process isolation)
- Coverage: V8 provider with 80% thresholds
- Setup: Global setup file for test utilities
- Timeouts: 30s test timeout, 10s hook timeout
Tests use a dedicated test database with automatic cleanup:
import { testUtils } from "../setup.js";
// Create test data
const market = await testUtils.createTestMarket();
const position = await testUtils.createTestPosition(market.id, wallet);testUtils.createTestMarket()- Create test markettestUtils.createTestPosition()- Create test positiontestUtils.createTestOrder()- Create test ordertestUtils.generateStellarAddress()- Generate valid addresstestUtils.assertDecimalEqual()- Fixed-precision assertions
- Database is cleaned before each test
- Advisory locks serialize database tests
- Each test gets fresh data
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { testUtils } from "../setup.js";
describe("Feature Name", () => {
beforeEach(async () => {
// Setup before each test
});
afterEach(async () => {
// Cleanup after each test
});
it("should do something", async () => {
// Arrange
const testData = await testUtils.createTestMarket();
// Act
const result = await someFunction(testData.id);
// Assert
expect(result).toBeDefined();
expect(result.status).toBe("ACTIVE");
});
});- Use descriptive test names - "should return 400 for invalid input"
- Follow AAA pattern - Arrange, Act, Assert
- Test one thing per test - Single assertion per test when possible
- Use helpers for setup - Leverage
testUtilsfor common operations - Mock external services - Use mocks for third-party APIs
- Test edge cases - Empty data, invalid inputs, error conditions
For API endpoint testing:
import Fastify from "fastify";
import { describe, it, expect } from "vitest";
describe("API Endpoint", () => {
let app: FastifyInstance;
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(routes);
});
afterAll(async () => {
await app.close();
});
it("should return correct response", async () => {
const response = await app.inject({
method: "GET",
url: "/endpoint",
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body).toHaveProperty("data");
});
});Coverage is configured with 80% thresholds for:
- Branches
- Functions
- Lines
- Statements
# Generate coverage report
npm run test:coverage
# View HTML report (opens in browser)
open coverage/index.htmlThe following are excluded from coverage:
- Test files (
**/*.test.ts,**/*.spec.ts) - Test directories (
tests/) - Scripts (
scripts/) - Coverage reports (
coverage/)
Tests use deterministic fixtures for stable outcomes:
// Generate consistent test data
const testWallet = testUtils.generateStellarAddress("GTEST");
const testMarket = await testUtils.createTestMarket({
question: "Predictable test question",
endTime: new Date("2026-12-31T23:59:59Z"),
});Database is automatically cleaned between tests:
// Automatic cleanup in beforeEach
beforeEach(async () => {
await cleanDatabase();
});import { vi } from "vitest";
// Mock entire module
vi.mock("../../services/prisma.js", () => ({
getPrismaClient: () => mockPrismaClient,
}));
// Mock specific function
const mockFunction = vi.fn();
vi.mock("../../module", () => ({
functionName: mockFunction,
}));// Verify mock was called
expect(mockFunction).toHaveBeenCalled();
expect(mockFunction).toHaveBeenCalledWith(expectedArgs);
// Clear mocks between tests
beforeEach(() => {
vi.clearAllMocks();
});Vitest provides built-in performance tracking:
import { bench } from "vitest";
bench("function performance", () => {
// Function to benchmark
expensiveFunction();
});Tests run in CI with:
# From .github/workflows/ci.yml
- name: Run tests
run: pnpm test
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/vatix
REDIS_URL: redis://localhost:6379
NODE_ENV: test
- name: Run tests with coverage
run: pnpm test:coverage
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/vatix
REDIS_URL: redis://localhost:6379
NODE_ENV: testCoverage reports are automatically uploaded to Codecov.
-
Database connection errors
- Check
DATABASE_URLenvironment variable - Ensure PostgreSQL is running
- Verify test database exists
- Check
-
Test timeouts
- Increase timeout in
vitest.config.ts - Check for infinite loops or hanging promises
- Increase timeout in
-
Mock issues
- Clear mocks in
beforeEach - Verify mock configuration
- Check module path resolution
- Clear mocks in
-
Coverage issues
- Check exclusion patterns
- Verify thresholds are realistic
- Ensure all code paths are tested
# Run tests with debugger
node --inspect-brk node_modules/.bin/vitest
# Run specific test with logging
DEBUG=* npm test -- specific-test.test.tsThe project enforces minimum code coverage thresholds (configured in vitest.config.ts):
- Lines: 80%
- Functions: 80%
- Branches: 80%
- Statements: 80%
# Generate coverage report
pnpm test:coverage
# Coverage reports available in ./coverage/
# Open ./coverage/index.html in a browser for detailed reportThe CI workflow runs pnpm exec vitest run --coverage and enforces the thresholds.
If coverage drops below the floor, the CI job fails. To adjust the floor:
- Update
vitest.config.tsthresholds in thecoverage.thresholdssection - Ensure the change is intentional (increasing thresholds is preferred)
- Submit a PR explaining the rationale
The matching engine uses a Redis-backed leader lease to enforce single-writer behavior: only one API process may match orders at a time. This prevents double-fills and book inconsistency under horizontal scaling.
By default, tests run with MATCHING_LEASE_ENFORCED=false (lease is bypassed), allowing all instances to match orders. To test with production-like behavior (lease actually enforced):
# Run integration tests with lease enforced
MATCHING_LEASE_ENFORCED=true pnpm test:integration
# Or run the matching-engine tests specifically
pnpm test:matchingThe CI workflow runs two integration test passes:
- Default (lease disabled):
MATCHING_LEASE_ENFORCED=false— tests the baseline API behavior - Lease enforced:
MATCHING_LEASE_ENFORCED=true— validates single-writer behavior (rejects concurrent matching from non-leaders with 503 MatchingUnavailable)
The lease-enforced job catches regressions where matching logic inadvertently violates the single-writer invariant.
Lease timing is configurable via environment variables:
MATCHING_LEASE_TTL_MS— Lease expiry in Redis (default: 15000 ms)MATCHING_LEASE_RENEW_INTERVAL_MS— Heartbeat renewal interval (default: 5000 ms)MATCHING_LEASE_ENFORCED— Enable/disable enforcement (default:truein production,falsein tests)
buildTestApp (the integration route harness) bypasses the lease by default.
Pass enableLease: true to run a route test through the real production
single-writer gate:
const app = await buildTestApp({ plugins: [ordersRoutes], enableLease: true });
// MATCHING_LEASE_ENFORCED is forced to "true" and the Redis-backed lease is
// acquired before the app is ready; matchingService.placeOrder now runs the
// same leaderLease.isLeader() check it runs in production.
await app.close(); // releases the lease and restores the previous env valueEnablement is fail-fast: if the lease cannot be acquired (Redis
unreachable, or another holder owns it) buildTestApp throws rather than
silently returning a lease-disabled app.
Each test only needs the backing services it actually exercises. In particular:
- Prisma seed / schema tests (
prisma/seed.test.ts,prisma/schema.test.ts) and the seed script itself (prisma/seed.ts) require only Postgres — never Redis.prisma/seed-no-redis.test.tsis a regression guard that fails if a Redis import creeps into that path. The seed script also refuses to run whenNODE_ENV=production. - Fills SSE resume after trim
(
tests/integration/fills-stream-xtrim.test.ts) covers the RedisXTRIMcase: when a client's resume cursor has been trimmed from the audit stream, missed fills are backfilled from Postgres and the request only returns410 stream_gapwhen Postgres also cannot serve the cursor.
- Write tests first (TDD when possible)
- Keep tests fast - Use mocks for external dependencies
- Test edge cases - Don't just test happy paths
- Use descriptive names - Test should document behavior
- Maintain test independence - Tests shouldn't depend on each other
- Review coverage reports - Aim for meaningful coverage, not just metrics
- Test with lease enforced - Run
MATCHING_LEASE_ENFORCED=truelocally to catch single-writer violations - Monitor coverage trends - Coverage floors prevent silent regressions in test quality
- Update tests with code - Keep tests in sync with implementation