Testing Guide
DropBilko — Testing Guide
Status: Active | Tests implemented across apps/api/ and packages/core/
Version: 1.0
Last updated:Updated: 2026-02-1325
Source:Author: src/drop-app/vitest.config.ts,ALAI playwright.config.ts,Documentation package.json, Teamtests/
Table of Contents
- Testing Philosophy
- Testing Pyramid
- Tech Stack
- Actual Test
FrameworksFiles - Running Tests
- Test Configuration
- Test Setup & Mocking
- CI Integration
- Writing New Tests
- Coverage Reporting
- Testing Best Practices
- Debugging Tests
1. Testing Philosophy
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 conversion
- Data Integrity — No lost transactions, no balance discrepancies
- Regression Prevention — Once fixed, bugs stay fixed
- Fast Feedback — Tests run without a real database (mocked Prisma)
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/core - 20% Integration Tests — Test API endpoints with mocked Prisma client
- 10% E2E Tests — Full API integration test (single file in
tests/e2e/)
Coverage Targets by Module
@bilko/core accounting engine |
Double-entry errors = financial loss |
||
@bilko/core tax/VAT calculations |
Tax miscalculations = regulatory penalty | ||
multi-currency |
90% | N/A | FX errors = revenue leakage |
| Auth API | 85% | 90% | Security boundary |
| Invoice API | 80% | 90% | Core revenue feature |
| Expense API | 80% | 85% | Core cost tracking |
| Reports API | 75% | 80% | Regulatory output |
| Banking API | 75% | 75% | Complex matching logic |
| Multi-tenant isolation | N/A | 100% | GDPR + security critical |
3. Tech Stack
| Test Type | Framework | Purpose |
|---|---|---|
| Unit | Vitest | @bilko/core business logic (financial engine) |
| Integration | Vitest + Supertest | API endpoint testing with mocked Prisma |
| E2E | Vitest + Supertest | Full API integration (tests/e2e/api.test.ts) |
Why Vitest (not Jest)
- Native ESM support, Vite-based — faster than Jest
- Compatible with Turborepo workspace structure
- Watch mode with HMR
- Same API as Jest (easy migration)
Why Supertest (not Postman)
- Programmatic API testing within Vitest
- Works with Express app instance directly
- No running server required
4. Actual Test Files
apps/api/tests/ — API Integration Tests (9 test files + 1 e2e)
| File | Tests | What It Covers |
|---|---|---|
setup.ts |
— | Shared test setup: Prisma mock, JWT helpers, test data factories |
auth.test.ts |
11 | Register, login, refresh token, logout, GET /me — auth flows with mocked DB |
invoices.test.ts |
11 | List, create, get, update, status change (send/pay), delete invoice endpoints |
expenses.test.ts |
9 | List, create, get, update, approve/reject, delete expense endpoints |
contacts.test.ts |
9 | List, create, get, update, delete contact endpoints |
accounts.test.ts |
4 | List, get, create, delete chart-of-accounts endpoints |
banking.test.ts |
10 | Bank account management, transaction import, reconciliation endpoints |
reports.test.ts |
9 | P&L, balance sheet, VAT report, trial balance endpoints |
transactions.test.ts |
9 | Transaction ledger list, filter, and detail endpoints |
country.test.ts |
27 | Country plugin integration — tax rates for RS/BA/HR, invoice number formats |
e2e/api.test.ts |
— | Full end-to-end API test (no mocks) |
Total: ~99 integration/E2E test cases
packages/core/tests/ — Unit Tests (5 test files)
| File | Tests | What It Covers |
|---|---|---|
accounting.test.ts |
20 | validateDoubleEntry, createJournalEntry, calculateTrialBalance — double-entry engine |
chart-of-accounts.test.ts |
32 | Chart of accounts operations: account creation, hierarchy, account types |
invoicing.test.ts |
22 | generateInvoiceNumber, calculateInvoiceTotals, validateLineItem |
multi-currency.test.ts |
24 | Currency conversion, exchange rate locking, precision handling |
tax.test.ts |
23 | VAT calculations for RS (20%), BA (17%), HR (25%), mixed rates, edge cases |
Total: 121 unit test cases
Grand Total: ~220 tests across 14 test files
5. Running Tests
UnitRun + IntegrationAll Tests (Vitest)Turborepo)
# SingleFrom project root — runs all tests in all packages
npm run (CItest
mode)# npmOr with turbo directly
npx turbo run test
Run API Integration Tests
cd apps/api
npx vitest run
# Watch mode
(development)npx npm run test:watchvitest
# Run specificSpecific test file
npx vitest run tests/unit/auth.test.ts
# Specific test by name pattern
npx vitest run --reporter=verbose -t "POST /api/v1/auth/login"
Run withCore Unit Tests
cd packages/core
npx vitest run
# Watch mode
npx vitest
# Specific file
npx vitest run tests/tax.test.ts
# With coverage
npx vitest run --coverage
Run in Verbose Mode
npx vitest run --reporter=verbose
Test Without Building
ConfigurationTests resolve workspace packages from TypeScript source (not dist/) via vitest.config.ts:4-14ts): aliases. No build step required before running tests.
Environment:6.
node- Test
pattern:Configurationapps/api/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/country-rs': path.resolve(__dirname, '../../packages/country-rs/src/index.ts'), '@bilko/country-ba': path.resolve(__dirname, '../../packages/country-ba/src/index.ts'), '@bilko/country-hr': path.resolve(__dirname, '../../packages/country-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'], exclude: ['node_modules', 'dist'], }, })packages/core/vitest.config.tsimport { defineConfig } from 'vitest/config' export default defineConfig({ test: { globals: true, root: '.', include: ['tests/**/*.test.ts'], }, })Key Configuration Differences
Setting apps/apipackages/coreglobalsfalse— imports explicittrue— describe/it/expect globalsetupFilesNone (per-file import of ./setup)None aliasesWorkspace package path aliases Not needed environmentnode(explicit)Default node
7. Test Setup & Mocking
apps/api/tests/setup.tsThe API integration tests use a mocked Prisma client — tests run without a real PostgreSQL database. The setup file:
- Sets required environment variables (JWT secrets, rate limit config)
- Mocks the entire
src/lib/prismamodule withvi.mock() SetupProvidesfile:test data factories and JWT token generators
// Import setup (tests/setup.tssetsmust be first import in test file) import { createTestUser, generateTestAccessToken, generateTestRefreshToken, TEST_USER_EMAIL, TEST_USER_ID, TEST_ORG_ID, } from './setup'Mock Prisma Pattern
NODE_ENV=// In test file — reference the mocked prisma import { prisma } from '../src/lib/prisma' const 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() })Auth Token Generation
// Generate a valid JWT for test requests const authToken = generateTestAccessToken() Path//alias:Generate token with specific role const adminToken = generateTestAccessToken({ role: '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', () => { 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
./srcgithub/workflows/ci.yml
E2ECI TestsPipeline (Playwright)4 parallel jobs)
# Run.github/workflows/ci.yml
allon:
E2Epush:
testsbranches: ["*"]
pull_request:
branches: [main, staging]
jobs:
lint: # npx playwrightturbo run lint
type-check: # npx turbo run type-check (after prisma generate)
test: # npx turbo run test (unit + integration)
build: # Run specific project npx playwrightturbo run build (needs lint, type-check, test)
Job Details
| Job | Command | Needs Prisma Generate | Blocks |
|---|---|---|---|
lint |
npx turbo run lint |
No | build |
type-check |
npx turbo run type-check |
Yes | build |
test |
npx turbo run test |
Yes | build |
build |
npx turbo run build |
Yes | Deploy |
Prisma Client Generation (required in CI)
- name: Generate Prisma Client
run: npx prisma generate --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-reportschema=packages/database/prisma/schema.prisma
ConfigurationThis (is required before playwright.config.ts:3-38type-check): and test because the API source references @prisma/client types.
Serialconcurrency:executiongroup:(1ci-${{worker)github.refto}}avoidcancel-in-progress:ratetruelimitconflictsAuto-startsin-flightdevCIserver (npm run dev) if not runningHTML reporterTraceruns onfirsttheretrysame(branch when new commits are pushed.CI
mode:Gate2Rulesretries)next/server--NextResponse.json()returns plain objectsnext/headers--cookies()returns mock getters/setters@/lib/db-- Uses in-memory SQLite (better-sqlite3with:memory:) for isolationEmpty, spaces, XSS, SQL injection, HTML injectionUnicode, RTL override, null bytesVery long strings (10K chars)Norwegian (aeoa), Bosnian (sdccz), Japanese charactersZalgo text, special characters
Concurrency
Cancels
| Gate | Condition | Blocks |
|---|---|---|
| Lint | Any lint error | PR merge + build |
| Type check | Any TypeScript error | PR merge + build |
| Tests | Any test failure | PR merge + build |
| Build | Lint/type-check/test all pass | Deploy |
Test9. Architecture
Writing Setup
File: tests/setup.ts:1
Sets NODE_ENV=test for all Vitest tests.
Mocking Strategy
All unit/integration tests mock the following Next.js modules:
Each test file creates and destroys its own database in beforeEach/afterEach.
E2E Test Helpers
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:
WritingNew Tests
UnitAPI Integration Test — Pattern
// apps/api/tests/my-feature.test.ts
import { describe, it, expect, vi, beforeEach, afterEachbeforeEach } from "vitest";'vitest'
import Databaserequest from "better-sqlite3";'supertest'
letimport testDb:{ Database.Database;generateTestAccessToken, TEST_ORG_ID, TEST_USER_ID } from './setup'
import app from '../src/app'
// Mock dependenciesthe before importservice
vi.mock("@/lib/db"'../src/services/my-feature.service', () => ({
getDb:return {
myFeatureService: {
listItems: vi.fn(() => testDb),
//createItem: ...vi.fn(),
other}
db functions}
})
import { myFeatureService } from '../src/services/my-feature.service'
const mockService = myFeatureService as any
const authToken = generateTestAccessToken();
describe("Feature"'GET /api/v1/my-feature', () => {
beforeEach(() => {
testDb = new Database(":memory:"vi.clearAllMocks();
// Create schema...
});
afterEach(it('returns 200 with paginated list', async () => {
testDb.close();mockService.listItems.mockResolvedValue({ data: [], total: 0, page: 1, pageSize: 20 });
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("does'returns something"401 without auth', async () => {
const res = await request(app).get('/api/v1/my-feature')
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', () => { ... })
// It block: be specific about scenario and expected outcome
it('returns 201 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
| Category | Target | Rationale |
|---|---|---|
Financial logic (@bilko/core) |
>95% | Critical for correctness |
API routes (apps/api) |
>80% | Integration-level coverage |
| Services | >80% | Business logic layer |
11. Testing Best Practices
1. Arrange-Act-Assert (AAA)
it('creates invoice with correct totals', async () => {
// Arrange,ARRANGE
Act,const AssertnewInvoice = mockInvoice({ status: 'draft' });
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')
})
E2E2. Test PatternBehavior, Not Implementation
import// {BAD test,— expecttests }internal frommock "@playwright/test";call test.describe.configure({order
mode: "serial" }expect(mockService.createInvoice.mock.calls[0][0]);.toBe(orgId)
// AvoidGOOD rate— limitstests test.describe("Feature",observable API behavior
expect(res.status).toBe(201)
expect(res.body).toHaveProperty('invoiceNumber')
3. Always Clear Mocks
beforeEach(() => {
test("user flow", async ({ pagevi.clearAllMocks()
})
=>
4. awaitTest page.goto("http:Edge Cases for Financial Logic
Always test in core unit tests:
- Zero amounts (
calculateInvoiceTotals([])) - Empty input arrays
- Negative values (should throw)
- Decimal precision (
quantity: '3', unitPrice: '33.33') - Large amounts (overflow protection)
5. Never Hard-Code UUIDs in Tests
//localhost:3000/login"); awaitBAD
page.locator(const orgId = 'input[type="email"]').fill("[email protected]");123e4567-e89b-12d3-a456-426614174000'
await// page.locator('input[type="password"]').fill("demo1234");GOOD
awaitconst page.locator('button[type="submit"]').click();orgId await= page.waitForURL("**TEST_ORG_ID /dashboard",/ {from timeout:setup.ts, 15000generated });
});
});per-run
Test12. ResultsDebugging Tests
CurrentRun
statusin (2026-02-13):
VitestMode
Testnpx Filesvitest 6run passed (6)
Tests 40 passed (40)
Duration 1.40s--reporter=verbose
PlaywrightRun Single Test by Name
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"
}
Related Documents
3TesttestInventory:projects configured:user-flows,full-flows,TEST-INVENTORY.mdinput-chaosBackendinput-chaosdependsArchitecture:on../backend/BACKEND-ARCHITECTURE.md- CI/CD Pipeline: ../infrastructure/CI-CD.md
user-flowsTest
Last Updated: 2026-02-25
Status: Active — tests implemented
Coverage AreasTarget: