Testing Guide
Bilko —Drop Testing Guide
Status: Active but partially stale — implementation moved to Kotlin/Ktor API and current Playwright partitioning is in progress
Version: 2.1
Last Updated:updated: 2026-05-2103-03
Author:Source: ALAI Documentation Team
Canonical testing policy:seeTEST-STRATEGY.mdsrc/drop-app/vitest.config.ts,E2E-TEST-PLAN.mdplaywright.config.ts,andpackage.json,DEMO-TESTING-PLAN.md. Older sections in this guide may still mention Express/Prisma/API Vitest inventory and should not override the current Ktor + Playwright policy.tests/
Table of Contents
Testing PhilosophyTesting PyramidTech StackActualTestFilesRunning TestsTest ConfigurationTest Setup & MockingCI IntegrationWriting New TestsCoverage ReportingTesting Best PracticesDebugging Tests
1. Testing PhilosophyFrameworks
Financial software has a higher correctness bar than typical web apps. Bilko's testing strategy prioritizes:
Financial Logic Accuracy— VAT calculations, double-entry bookkeeping, currency conversionData Integrity— No lost transactions, no balance discrepanciesRegression Prevention— Once fixed, bugs stay fixedFast Feedback— Prefer fast unit tests and targeted integration/contract tests; use real PostgreSQL/Testcontainers where behavior depends on the database
2. Testing Pyramid
/\
/E2E\ ← 10% (Critical user flows only)
/------\
/ Integ \ ← 20% (API endpoints, mocked DB)
/----------\
/ Unit \ ← 70% (Business logic, financial engine)
/--------------\
Distribution:
70% Unit Tests— Fast, isolated, test business logic in@bilko/core20% Integration Tests— Test API endpoints with mocked Prisma client10% E2E Tests— Full API integration test (single file intests/e2e/)
Coverage Targets by Module
|
vitest.config.ts |
||
|
|||
| |||
3. Tech StackPrerequisites
| ||
|
Why Vitest (not Jest)
Native ESM support, Vite-based — faster than JestCompatible with Turborepo workspace structureWatch mode with HMRSame API as Jest (easy migration)
Why Supertest (not Postman)
Programmatic API testing within VitestWorks with Express app16 instancedirectly(port No5433).runningThereserver required
4. Actual Test Files
apps/api/tests/ — Mock Suite (11 test files)
Tests with mocked Prisma — fast,is no databasein-memory required.SQLite fallback.
| This: starts Docker, creates drop_test DB, enables pgcrypto, pushes Drizzle schema
The | ||
| ||
| ||
| ||
| ||
| ||
| ||
| ||
| ||
| ||
| ||
| ||
|
Total: ~120+ mock suite test cases:
apps/api/tests/unit/postgresql://drop:dev_only_not_a_secret@localhost:5433/drop_test
— Unit Suite (4 test files)
apps/api/tests/unit/postgresql://drop:dev_only_not_a_secret@localhost:5433/drop_test
Service-layer tests. Mocked Prisma. No HTTP layer.
| | |
| | |
| | |
|
apps/api/tests/e2e/ — E2E Suite (2 test files)
End-to-end workflow tests. Mocked services, no real DB required.
| ||
|
apps/api/tests/integration/ — Real DB Suite (5 test files)
Requires docker-compose.test.yml PostgreSQL. Run with npm run test:integration.
| ||
| ||
| ||
| ||
|
Grand Total: ~390 tests across 27 test files
packages/core/tests/ — Unit Tests (5 test files)
| | |
| ||
| | |
| ||
|
Total: 121 unit test cases
Grand Total: ~220 tests across 14 test files
5. Running Tests
RunUnit All+ Integration Tests (Turborepo)Vitest)
# FromRecommended: projectvia rootMakefile —(handles runsDocker all+ testsDATABASE_URL inautomatically)
all packages
npm runmake test
# Or with turbo directly
npx turboDirect run test(Docker must Runalready APIbe Mockup)
Suite
cd apps/apisrc/drop-app && DATABASE_URL=postgresql://drop:dev_only_not_a_secret@localhost:5433/drop_test npx vitest run
# Watch mode (development)
cd src/drop-app && DATABASE_URL=postgresql://drop:dev_only_not_a_secret@localhost:5433/drop_test npx vitest
# SpecificRun specific test file
npx vitest run tests/auth.test.ts
npx vitest run tests/chatbot.test.ts
# Specific test by name pattern
npx vitest run --reporter=verbose -t "POST /api/v1/auth/login"
Run API Unit Suite
cd apps/apisrc/drop-app npx&& vitest run tests/unit/
# Specific service test
npx vitest run tests/unit/two-factor.test.ts
npx vitest run tests/unit/vat-calculation.test.ts
Run API E2E Suite
cd apps/api
npx vitest run tests/e2e/
# Full billing flow
npx vitest run tests/e2e/billing-flow.e2e.test.ts
Run Real DB Integration Suite
Requires Docker. Uses docker-compose.test.yml (port 5433).
# Start test database
docker-compose -f docker-compose.test.yml up -d
# Run integration tests
cd apps/api
npm run test:integration
# Or directly:DATABASE_URL=postgresql://drop:dev_only_not_a_secret@localhost:5433/drop_test npx vitest run tests/integration/
# Stop test database
docker-compose -f docker-compose.test.yml down
Run Core Unit Tests
cd packages/core
npx vitest run
# Watch mode
npx vitest
# Specific file
npx vitest run tests/tax.api-endpoints.test.ts
# WithRun with coverage
cd src/drop-app && DATABASE_URL=postgresql://drop:dev_only_not_a_secret@localhost:5433/drop_test npx vitest run --coverage
Run in Verbose Mode
npx vitest run --reporter=verbose
Test Without Building
Tests resolve workspace packages from TypeScript sourceConfiguration (not dist/) via vitest.config.ts aliases. No build step required before running tests.):
- Environment:
node - Test
Configurationpattern:apps/api-express/vitest.config.tsimport { defineConfig } from 'vitest/config' import path from 'path' export default defineConfig({ resolve: { alias: { // Resolves workspace packages from source — no dist/ build needed '@bilko/core': path.resolve(__dirname, '../../packages/core/src/index.ts'), '@bilko/domain-rs': path.resolve(__dirname, '../../packages/domain-rs/src/index.ts'), '@bilko/domain-ba': path.resolve(__dirname, '../../packages/domain-ba/src/index.ts'), '@bilko/domain-hr': path.resolve(__dirname, '../../packages/domain-hr/src/index.ts'), '@bilko/database': path.resolve(__dirname, '../../packages/database/src/index.ts'), }, }, test: { globals: false, environment: 'node', setupFiles: [], // Setup is imported per test file via tests/setup.ts include: ['tests/**/*.test.ts']ts - Setup file:
tests/setup.ts(setsNODE_ENV=test,exclude:sets['node_modules',DATABASE_URL) - Path
},alias:})@->./src fileParallelism:packages/core/vitest.config.tsimport { defineConfig } from 'vitest/config' export default defineConfig({ test: { globals: true, root: '.', include: ['tests/**/*.test.ts'], }, })Key Configuration Differences
limit conflictsSettingapps/api-expresspackages/core raceglobals runfalse—importstestsexplicit PostgreSQLsequentiallytrue—todescribe/it/expectavoidglobalconditions setupFiles :NoneE2E Tests (
per-filePlaywright)import#ofRun all E2E tests npx playwright test # Run specific project npx playwright test --project=user-flows npx playwright test --project=full-flows npx playwright test --project=input-chaos # Run with UI mode npx playwright test --ui # View HTML report npx playwright show-reportConfiguration (
)./setupplaywright.config.ts:3-38None- Serial
ratealiasesWorkspace package path aliasesNot needed avoidenvironment 1execution (nodeexplicit) toDefaultworker)node- Serial
- Auto-starts dev server (
npm run dev) if not running - HTML reporter
- Trace on first retry (CI mode: 2 retries)
6.
7. Test Setup & MockingArchitecture
Setup
File: apps/api/tests/setup.ts
Sets NODE_ENV=test and DATABASE_URL=postgresql://drop:dev_only_not_a_secret@localhost:5433/drop_test for all Vitest tests.
Database Strategy (PostgreSQL — ADR-014)
The APIAll integration tests use a mockedshared PrismaPostgreSQL clienttest helper (tests/helpers/pg-test-db.ts):
- Connects to the real
drop_testdatabase on port 5433 - Runs Drizzle schema push once before each test suite
- Truncates all tables between tests for isolation
- No in-memory SQLite —
testsfullruntest/prodwithout a real PostgreSQL database. The setup file:Sets required environment variables (JWT secrets, rate limit config)parityMocks the entiresrc/lib/prismamodule withvi.mock()Provides test data factories and JWT token generators
// Import setup (must be first import inIntegration test file)pattern (post-migration)
import { createTestUser,getTestDb, generateTestAccessToken,
generateTestRefreshToken,
TEST_USER_EMAIL,
TEST_USER_ID,
TEST_ORG_ID,cleanupTestDb } from '"../setup'
Mock Prisma Pattern
// In test file — reference the mocked prismahelpers/pg-test-db";
import { prismadb } from '../src/"@/lib/prisma'db";
constbeforeAll(async mockPrisma = prisma as any
// Configure mock for a specific test
mockPrisma.user.findUnique.mockResolvedValue(testUser)
mockPrisma.user.findUnique.mockResolvedValue(null) // simulate not found
// Clear mocks between tests
beforeEach(() => { vi.clearAllMocks(await getTestDb(); });
afterEach(async () => { await cleanupTestDb(); });
Unit tests that test pure logic (validation, auth utilities) mock @/lib/db directly without a real DB connection.
Note: better-sqlite3 has been removed. There is no new Database(":memory:") pattern anymore.
AuthE2E TokenTest GenerationHelpers
File: tests/e2e/input-chaos.spec.ts:21-42
loginAsDemo(page) -- Mocks the /api/auth/me endpoint to bypass rate limiting in chaos tests. Returns a pre-authenticated user object.
CHAOS_STRINGS -- Dictionary of malicious/edge-case inputs used across E2E tests:
- Empty, spaces, XSS, SQL injection, HTML injection
- Unicode, RTL override, null bytes
- Very long strings (10K chars)
- Norwegian (aeoa), Bosnian (sdccz), Japanese characters
- Zalgo text, special characters
Writing Tests
Integration Test Pattern (PostgreSQL)
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { getTestDb, cleanupTestDb } from "..// Generate a valid JWT for test requests
const authToken = generateTestAccessToken()helpers/pg-test-db";
// GenerateDATABASE_URL tokenis withset specificby roletests/setup.ts const— adminTokenno =manual generateTestAccessToken({config role:needed
'admin' })
const viewerToken = generateTestAccessToken({ role: 'viewer' })
// Generate refresh token (used in cookies)
const refreshToken = generateTestRefreshToken(TEST_USER_ID)
Service Mocking Pattern (for route tests)
// Mock entire service module
vi.mock('../src/services/invoice.service'describe("Feature", () => {
const mockService = {
listInvoices: vi.fn(),
createInvoice: vi.fn(),
// ...
}
return { invoiceService: mockService }
})
import { invoiceService } from '../src/services/invoice.service'
const mockInvoiceService = invoiceService as any
// Configure per test
mockInvoiceService.createInvoice.mockResolvedValue(newInvoice)
// Verify calls
expect(mockInvoiceService.createInvoice).toHaveBeenCalledWith(
TEST_ORG_ID,
TEST_USER_ID,
expect.any(Object),
)
8. CI Integration
Tests run via GitHub Actions on every push and pull request. See .github/workflows/ci.yml.
CI Pipeline (4 parallel jobs)
# .github/workflows/ci.yml
on:
push:
branches: ['*']
pull_request:
branches: [main, staging]
jobs:
lint: # npx turbo run lint
type-check: # npx turbo run type-check (after prisma generate)
test: # npx turbo run test (unit + integration)
build: # npx turbo run build (needs lint, type-check, test)
Job Details
| | | |
| | | |
| | | |
| |
Prisma Client Generation (required in CI)
- name: Generate Prisma Client
run: npx prisma generate --schema=packages/database/prisma/schema.prisma
This is required before type-check and test because the API source references @prisma/client types.
Concurrency
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
Cancels in-flight CI runs on the same branch when new commits are pushed.
CI Gate Rules
9. Writing New Tests
API Integration Test — Pattern
// apps/api/tests/my-feature.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import request from 'supertest'
import { generateTestAccessToken, TEST_ORG_ID, TEST_USER_ID } from './setup'
import app from '../src/app'
// Mock the service
vi.mock('../src/services/my-feature.service', () => {
return {
myFeatureService: {
listItems: vi.fn(),
createItem: vi.fn(),
},
}
})
import { myFeatureService } from '../src/services/my-feature.service'
const mockService = myFeatureService as any
const authToken = generateTestAccessToken()
describe('GET /api/v1/my-feature', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 200 with paginated list', beforeAll(async () => {
mockService.listItems.mockResolvedValue({await data:getTestDb(); [],// total:Ensure 0,schema page:is 1,pushed pageSize:to 20drop_test
});
const res = await request(app)
.get('/api/v1/my-feature')
.set('Authorization', `Bearer ${authToken}`)
expect(res.status).toBe(200)
expect(res.body).toHaveProperty('data')
})
it('returns 401 without auth', afterEach(async () => {
const res = await request(app).get('/api/v1/my-feature'cleanupTestDb()
expect(res.status).toBe(401)
})
})
Core Unit Test — Pattern
// packages/core/tests/my-calculation.test.ts
import { describe, it, expect } from 'vitest'
import Decimal from 'decimal.js'
import { myCalculation } from '../src/my-module/index'
describe('myCalculation', () => {
it('returns correct result for standard input', () => {
const result = myCalculation('100', '20')
expect(result.eq(new Decimal('20'))).toBe(true)
})
it('handles zero input', () => {
const result = myCalculation('0', '20')
expect(result.eq(new Decimal('0'))).toBe(true)
})
it('throws for negative amounts', () => {
expect(() => myCalculation('-100', '20')).toThrow()
})
})
Naming Conventions
// Describe block: HTTP method + route OR function name
describe('POST /api/v1/invoices', () => { ... })
describe('calculateInvoiceTotals', () => { ... }); // ItTruncate block:all betables specificbetween abouttests
scenario and expected outcome});
it('returns"does 201something with created invoice when data is valid', ...)
it('returns 400 when customerId is missing', ...)
it('calculates Serbian VAT at 20% on 100 RSD as 20 RSD', ...)
it('throws for negative amounts', ...)
10. Coverage Reporting
Generate Coverage (core unit tests)
cd packages/core
npx vitest run --coverage
Coverage Output Format
File | % Stmts | % Branch | % Funcs | % Lines
-------------------------|---------|----------|---------|--------
All files | XX.X | XX.X | XX.X | XX.X
accounting/index.ts | 95.0 | 90.0 | 100.0 | 95.0
invoicing/index.ts | 88.2 | 80.5 | 85.0 | 88.2
tax/index.ts | 92.0 | 88.0 | 100.0 | 92.0
Coverage Targets
| ||
| ||
11. Testing Best Practices
1. Arrange-Act-Assert (AAA)
it('creates invoice with correct totals'DB", async () => {
// ARRANGEArrange, constAct, newInvoiceAssert =against mockInvoice({real status: 'draft'PostgreSQL
})
mockInvoiceService.createInvoice.mockResolvedValue(newInvoice)
// ACT
const res = await request(app)
.post('/api/v1/invoices')
.set('Authorization', `Bearer ${authToken}`)
.send({ customerId: CUSTOMER_UUID, items: [...];
})
// ASSERT
expect(res.status).toBe(201)
expect(res.body.invoiceNumber).toBe('INV-2026-001')
});
2.Unit Test Behavior,Pattern Not(pure Implementationlogic, no DB)
//import BAD{ —describe, testsit, internalexpect, mockvi call} orderfrom expect(mockService.createInvoice.mock.calls[0][0]).toBe(orgId)"vitest";
// GOODMock —@/lib/db entirely for pure unit tests
observablevi.mock("@/lib/db", API behavior
expect(res.status).toBe(201)
expect(res.body).toHaveProperty('invoiceNumber'() => 3.({
Alwaysdb: Clear{ Mocks
select: beforeEach(vi.fn(), insert: vi.fn() },
}));
describe("Feature", () => {
vi.clearAllMocks(it("validates input", () => {
// Arrange, Act, Assert — no DB needed
});
});
4.E2E Test Edge Cases for Financial Logic
Always test in core unit tests:
Zero amounts (calculateInvoiceTotals([]))Empty input arraysNegative values (should throw)Decimal precision (quantity: '3', unitPrice: '33.33')Large amounts (overflow protection)
5. Never Hard-Code UUIDs in TestsPattern
//import BAD{ consttest, orgIdexpect =} '123e4567-e89b-12d3-a456-426614174000'from "@playwright/test";
test.describe.configure({ mode: "serial" }); // GOODAvoid constrate orgIdlimits
test.describe("Feature", () => TEST_ORG_ID{
test("user flow", async ({ page }) => {
await page.goto("http://localhost:3000/login");
fromawait setup.ts,page.locator('input[type="email"]').fill("[email protected]");
generatedawait per-runpage.locator('input[type="password"]').fill("demo1234");
await page.locator('button[type="submit"]').click();
await page.waitForURL("**/dashboard", { timeout: 15000 });
});
});
12.Test Debugging TestsResults
Current status (2026-03-03): Migrated to PostgreSQL. All tests run against drop_test on port 5433.
Run in Verbose ModeVitest
npx# vitestRun: runmake --reporter=verbosetest
# All test files use PostgreSQL — no SQLite, no better-sqlite3
Run Single Test by NamePlaywright
npx vitest run -t "returns 201 with created invoice"
Debug Mode (Node Inspector)
npx vitest --inspect-brk
# Then attach VS Code debugger or open chrome://inspect
Print Mock Call History
// In a test, add temporarily:
console.log(mockService.createInvoice.mock.calls)
VS Code Launch Configuration
{
"type": "node",
"request": "launch",
"name": "Debug Vitest",
"runtimeExecutable": "npx",
"runtimeArgs": ["vitest", "--inspect-brk", "--no-coverage"],
"console": "integratedTerminal",
"cwd": "${workspaceFolder}/apps/api-express"
}
Related Documents
Test3Inventory:testTEST-INVENTORY.mdprojects configured:user-flows,full-flows,input-chaosBackendinput-chaosArchitecture:depends../backend/BACKEND-ARCHITECTURE.mdon CI/CD Pipeline:../infrastructure/CI-CD.mduser-flows
LastTest
Coverage Updated:2026-03-02Areas
Status:
Active
—
~390Area
testsUnit
acrossIntegration
27E2E
filesPerformance
Password hashing (
mockbcrypt)+auth.test.ts
unitapi-routes.test.ts
+--
E2Eapi-benchmarks.test.ts
+real-DB
suites)JWT
Coveragesign/verifyTarget:auth.test.ts
>95%--
for--
@bilko/core--
financiallogic,
>80%Rate
forlimitingAPImiddleware.test.ts
routesapi-routes.test.ts
user-flows.spec.ts
api-benchmarks.test.ts
Input validation
validation.test.ts
--
input-chaos.spec.ts
--
Database schema
db.test.ts
api-endpoints.test.ts
--
--
Feature flags
feature-flags.test.ts
--
--
--
Utility functions
utils.test.ts
--
--
--
Registration flow
--
api-endpoints.test.ts
user-flows.spec.ts
--
Login flow
--
api-endpoints.test.ts
user-flows.spec.ts
--
Remittance
--
api-endpoints.test.ts
full-flows.spec.ts
--
QR Payment
--
api-endpoints.test.ts
full-flows.spec.ts
--
XSS/SQLi prevention
validation.test.ts
--
input-chaos.spec.ts
--
Session management
--
api-routes.test.ts
full-flows.spec.ts
--
Known bug regressions
--
known-bugs.test.ts
--
--