Skip to main content

Testing Guide

DropBilko — Testing Guide

Status: Active | Tests implemented across apps/api/ and packages/core/ Version: 2.0 Last updated:Updated: 2026-03-0302 Source:Author: src/drop-app/vitest.config.ts,ALAI playwright.config.ts,Documentation package.json, tests/Team


Table of Contents

  1. Testing Philosophy
  2. Testing Pyramid
  3. Tech Stack
  4. Actual Test FrameworksFiles
  5. Running Tests
  6. Test Configuration
  7. Test Setup & Mocking
  8. CI Integration
  9. Writing New Tests
  10. Coverage Reporting
  11. Testing Best Practices
  12. Debugging Tests

1. Testing Philosophy

Financial software has a higher correctness bar than typical web apps. Bilko's testing strategy prioritizes:

  1. Financial Logic Accuracy — VAT calculations, double-entry bookkeeping, currency conversion
  2. Data Integrity — No lost transactions, no balance discrepancies
  3. Regression Prevention — Once fixed, bugs stay fixed
  4. 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

FrameworkModule VersionUnit TypeIntegration ConfigReason
Vitest@bilko/core accounting engine ^4.0.1895% Unit, Integration, Performance, RegressionN/A vitest.config.tsDouble-entry errors = financial loss
Playwright@bilko/core tax/VAT calculations ^1.58.295% E2E (browser)N/A Tax miscalculations = regulatory penalty
playwright.config.ts@bilko/core multi-currency90%N/AFX errors = revenue leakage
Auth API85%90%Security boundary
Invoice API80%90%Core revenue feature
Expense API80%85%Core cost tracking
Reports API75%80%Regulatory output
Banking API75%75%Complex matching logic
Multi-tenant isolationN/A100%GDPR + security critical

Prerequisites3. Tech Stack

Test TypeFrameworkPurpose
UnitVitest@bilko/core business logic (financial engine)
IntegrationVitest + SupertestAPI endpoint testing with mocked Prisma
E2EVitest + SupertestFull 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/ — Mock Suite (11 test files)

Tests with mocked Prisma — fast, no database required.

FileTestsWhat It Covers
setup.tsShared test setup: Prisma mock, JWT helpers, test data factories
auth.test.ts11Register, login, refresh token, logout, GET /me — auth flows with mocked DB
invoices.test.ts11List, create, get, update, status change (send/pay), delete invoice endpoints
expenses.test.ts9List, create, get, update, approve/reject, delete expense endpoints
contacts.test.ts9List, create, get, update, delete contact endpoints
accounts.test.ts4List, get, create, delete chart-of-accounts endpoints
banking.test.ts10Bank account management, transaction import, reconciliation endpoints
reports.test.ts9P&L, balance sheet, VAT report, trial balance endpoints
transactions.test.ts9Transaction ledger list, filter, and detail endpoints
country.test.ts27Country plugin integration — tax rates for RS/BA/HR, invoice number formats
chatbot.test.ts9Chatbot message, history, clear history — rate limit (429) test
invoice-gl-reversal.test.ts6Invoice cancellation GL reversal — double-entry stays balanced
new-endpoints.test.ts~10Receipt, VAT export PDF/XML, dashboard, security audit log, data export

DockerTotal: is~120+ mock suite test cases

apps/api/tests/unit/ — Unit Suite (4 test files)

Service-layer tests. Mocked Prisma. No HTTP layer.

FileTestsWhat It Covers
invoice-service-calculations.test.ts~15InvoiceService.createInvoice() arithmetic — line totals, VAT, FX
two-factor.test.ts8TwoFactorService — enable, verify, disable TOTP with backup codes
sef-submission.test.ts~10SefClient HTTP calls, InvoiceService.submitToSef() fire-and-forget
vat-calculation.test.ts~20Pure VAT functions from country-rs, country-ba, country-hr packages

apps/api/tests/e2e/ — E2E Suite (2 test files)

End-to-end workflow tests. Mocked services, no real DB required.

FileTestsWhat It Covers
api.test.tsFull Express stack integration test (live server, no mocks)
billing-flow.e2e.test.ts~8Full billing workflow: contact → invoice → send → pay → P&L → credit note

apps/api/tests/integration/ — Real DB Suite (5 test files)

Requires docker-compose.test.yml PostgreSQL. Run with npm run test:integration.

FileTestsWhat It Covers
auth.integration.test.ts4Registration + login + refresh against real DB
invoice.integration.test.ts5Full invoice CRUD lifecycle against real DB
credit-note-gl.integration.test.ts3Credit note GL entries balance in real DB
report.integration.test.ts3Reports with real seeded transactions
tenant-isolation.integration.test.ts5Cross-tenant data isolation (org A cannot read org B's data)

Grand Total: ~390 tests across 27 test files

packages/core/tests/ — Unit Tests run(5 againsttest afiles)

realPostgreSQL16instance(port5433).noin-memorySQLitefallback.

One-timesetup (first time or after fresh clone)
make db-test-setup
# This: starts Docker, creates drop_test DB, enables pgcrypto, pushes Drizzle schema























File Tests What ThereIt isCovers
#accounting.test.ts 20 validateDoubleEntry

The, DATABASE_URLcreateJournalEntry, calculateTrialBalance — double-entry engine

chart-of-accounts.test.ts32Chart of accounts operations: account creation, hierarchy, account types
invoicing.test.ts22generateInvoiceNumber, calculateInvoiceTotals, validateLineItem
multi-currency.test.ts24Currency conversion, exchange rate locking, precision handling
tax.test.ts23VAT calculations for RS (20%), BA (17%), HR (25%), mixed rates, edge cases

Total: 121 unit test cases

Grand Total: ~220 tests isacross set14 automaticallytest byfiles

tests/setup.ts:

postgresql://drop:dev_only_not_a_secret@localhost:5433/drop_test

5. Running Tests

UnitRun + IntegrationAll Tests (Vitest)Turborepo)

# Recommended:From viaproject Makefileroot (handles Dockerruns +all DATABASE_URLtests automatically)in makeall packages
npm run test

# DirectOr with turbo directly
npx turbo run (Dockertest
must
already

Run beAPI up)Mock Suite

cd src/drop-app && DATABASE_URL=postgresql://drop:dev_only_not_a_secret@localhost:5433/drop_testapps/api
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

# Run specificSpecific 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 src/drop-appapps/api
&&npx DATABASE_URL=postgresql://drop:dev_only_not_a_secret@localhost:5433/drop_testvitest 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:
npx vitest run tests/integration/api-endpoints.

# 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.test.ts

# Run withWith 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

ConfigurationTests resolve workspace packages from TypeScript source (not dist/) via vitest.config.ts): aliases. No build step required before running tests.


  • Environment:

    6. node

  • Test pattern:Configuration

    apps/api/vitest.config.ts

    import { 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.ts

  • Setup
    import file:{ tests/setup.tsdefineConfig (sets} NODE_ENV=testfrom 'vitest/config'
    
    export default defineConfig({
      test: {
        globals: true,
        root: '.',
        setsinclude: ['tests/**/*.test.ts'],
      },
    })
    

    Key Configuration Differences

  • Path
  • alias: -> sequentiallyraceconditions

    E2E Tests

    RunallE2EtestsnpxRunprojectnpxplaywrighttest --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-report
      execution(1worker) to avoid rate limit conflicts
    • Auto-starts dev server (npm run dev) if not running
    • HTML reporter
    • Trace on first retry (CI mode: 2 retries)
    • SettingDATABASE_URLapps/api) packages/core
      @globals ./src
    • fileParallelism: falsetestsimports runexplicit
    • true to avoiddescribe/it/expect PostgreSQLglobal
      setupFiles None (Playwright)per-file
      import of #./setup)
      None
      aliases Workspace playwrightpackage testpath #aliases Not specificneeded
      environment node

      Configuration (playwright.config.ts:3-38):

      explicit)
      Default
    • Serialnode

    • 7. Test ArchitectureSetup & Mocking

      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)

      AllThe API integration tests use a sharedmocked Prisma client — tests run without a real PostgreSQL testdatabase. helperThe setup file:

      1. Sets required environment variables (tests/helpers/pg-test-db.ts):

        JWT
          secrets, rate limit config)
        • Connects toMocks the realentire drop_testsrc/lib/prisma databasemodule onwith port 5433vi.mock()
        • Runs Drizzle schema push once before eachProvides test suitedata factories and JWT token generators
        • Truncates all tables between tests for isolation
        • No in-memory SQLite — full test/prod parity
      // IntegrationImport setup (must be first import in test pattern (post-migration)file)
      import {
        getTestDb,createTestUser,
        cleanupTestDbgenerateTestAccessToken,
        generateTestRefreshToken,
        TEST_USER_EMAIL,
        TEST_USER_ID,
        TEST_ORG_ID,
      } from ".'./helpers/pg-test-db";setup'
      

      Mock Prisma Pattern

      // In test file — reference the mocked prisma
      import { dbprisma } from "@/'../src/lib/db";prisma'
      beforeAll(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()
      
      // 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 .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

      JobCommandNeeds Prisma GenerateBlocks
      lintnpx turbo run lintNobuild
      type-checknpx turbo run type-checkYesbuild
      testnpx turbo run testYesbuild
      buildnpx turbo run buildYesDeploy

      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

      GateConditionBlocks
      LintAny lint errorPR merge + build
      Type checkAny TypeScript errorPR merge + build
      TestsAny test failurePR merge + build
      BuildLint/type-check/test all passDeploy

      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', async () => {
          awaitmockService.listItems.mockResolvedValue({ getTestDb();data: [], total: 0, page: 1, pageSize: 20 });
      
          afterEach(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', async () => {
          const res = await cleanupTestDb(request(app).get('/api/v1/my-feature');
          expect(res.status).toBe(401)
        });
      })
      

      Core 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.

      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:

      • 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)

      // packages/core/tests/my-calculation.test.ts
      import { describe, it, expect, beforeAll, afterEachexpect } from "vitest";'vitest'
      import Decimal from 'decimal.js'
      import { getTestDb, cleanupTestDbmyCalculation } from "'../helpers/pg-test-db";
      
      // DATABASE_URL is set by tests/setup.ts — no manual config neededsrc/my-module/index'
      
      describe("Feature"'myCalculation', () => {
        beforeAll(asyncit('returns correct result for standard input', () => {
          awaitconst getTestDb(result = myCalculation('100', '20');
          //expect(result.eq(new Ensure schema is pushed to drop_testDecimal('20'))).toBe(true)
        });
      
        afterEach(asyncit('handles zero input', () => {
          awaitconst cleanupTestDb(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', () => { ... })
      
      // TruncateIt allblock: tablesbe betweenspecific testsabout });scenario and expected outcome
      it("does'returns something201 with DB"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

      CategoryTargetRationale
      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 against= realmockInvoice({ PostgreSQLstatus: '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')
      })
      

      Unit2. Test PatternBehavior, (pureNot logic, no DB)Implementation

      import// {BAD describe, it,tests expect,internal vimock }call fromorder
      "vitest";expect(mockService.createInvoice.mock.calls[0][0]).toBe(orgId)
      
      // MockGOOD @/lib/db entirely for pure unit tests vi.mock("@/lib/db",observable (API behavior
      expect(res.status).toBe(201)
      expect(res.body).toHaveProperty('invoiceNumber')
      =>
      ({

      3. db:Always {Clear select:Mocks

      vi.fn(), insert: vi.fn() }, })); describe("Feature",
      beforeEach(() => {
        it("validates input", (vi.clearAllMocks() => {
          // Arrange, Act, Assert — no DB needed
      });
      });
      

      E2E4. Test PatternEdge 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

      import// {BAD
      test,const expectorgId }= from "@playwright/test";
      
      test.describe.configure({ mode: "serial" });'123e4567-e89b-12d3-a456-426614174000'
      
      // AvoidGOOD
      rateconst limits
      
      test.describe("Feature", ()orgId => {TEST_ORG_ID test("user flow", async ({ page }) => {
          await page.goto("http://localhost:3000/login"); awaitfrom page.locator('input[type="email"]').fill("[email protected]");setup.ts, awaitgenerated page.locator('input[type="password"]').fill("demo1234");
          await page.locator('button[type="submit"]').click();
          await page.waitForURL("**/dashboard", { timeout: 15000 });
        });
      });per-run
      

      Test12. ResultsDebugging Tests

      Current

      Run statusin (2026-03-03):Verbose Migrated to PostgreSQL. All tests run against drop_test on port 5433.

      VitestMode

      #npx Run:vitest makerun test
      # All test files use PostgreSQL — no SQLite, no better-sqlite3--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"
      }
      


      Test

      Last Updated: 2026-03-02 Status: Active — ~390 tests across 27 files (mock + unit + E2E + real-DB suites) Coverage Areas

      Target: >95%for@bilko/corefinanciallogic,>80%forAPIroutes

      Area Unit Integration E2E Performance
      Password hashing (bcrypt)auth.test.tsapi-routes.test.ts--api-benchmarks.test.ts
      JWT sign/verifyauth.test.ts------
      Rate limitingmiddleware.test.tsapi-routes.test.tsuser-flows.spec.tsapi-benchmarks.test.ts
      Input validationvalidation.test.ts--input-chaos.spec.ts--
      Database schemadb.test.tsapi-endpoints.test.ts----
      Feature flagsfeature-flags.test.ts------
      Utility functionsutils.test.ts------
      Registration flow--api-endpoints.test.tsuser-flows.spec.ts--
      Login flow--api-endpoints.test.tsuser-flows.spec.ts--
      Remittance--api-endpoints.test.tsfull-flows.spec.ts--
      QR Payment--api-endpoints.test.tsfull-flows.spec.ts--
      XSS/SQLi preventionvalidation.test.ts--input-chaos.spec.ts--
      Session management--api-routes.test.tsfull-flows.spec.ts--
      Known bug regressions--known-bugs.test.ts----