Skip to content
Africa OS Knowledgebase
Esc
navigateopen⌘Jpreview
On this page

Testing

The testing conventions - @effect/vitest, in-memory PGlite, focused tests, and the strongest-invariant rule.

The project’s testing standards live in .patterns/testing.md and every test follows them. This page explains the conventions and the concrete patterns used across the repo.

The two hard rules

  1. Use expect/it from @effect/vitest, never from Vitest. Effect programs are asserted through Effect-aware assertions.
  2. Every assertion must be able to fail, and assert the strongest invariant. No vacuous tests: un-awaited .rejects/.resolves, expectations inside callbacks that may never fire, loops over possibly-empty collections, and conditional assertions are all banned. Assert exact values - a specific error class/code/message (never bare toThrow()), toBe over toContain, real bytes not lengths.

Test placement

Tests live in __tests__ directories scoped to the package, or alongside the code:

  • Platform tests: platform/<package>/__tests__/.
  • Application tests: packages/applications/<app>/__tests__/.
  • The retail service test lives at packages/applications/retail/src/features/products/service.test.ts.

The service test pattern

The retail service.test.ts shows the standard way to test an Effect service. The key pieces:

In-memory database. Database.Test boots PGlite, so tests run without a Postgres server:

const testBase = Layer.mergeAll(Database.Test, Logger.Test);

Real migrations. The test applies the centralized migration files to the in-memory database, so the tables exist exactly as in production:

const applyMigrations = (sql: SqlClient.SqlClient) =>
  Effect.gen(function* () {
    const files = (yield* Effect.promise(() => readdir(migrationsDirectory)))
      .filter((name) => /^\d+_.+\.sql$/.test(name))
      .sort();
    for (const file of files) {
      const content = yield* Effect.promise(() =>
        readFile(resolve(migrationsDirectory, file), "utf-8")
      );
      for (const statement of splitSqlStatements(content)) {
        yield* sql.unsafe(statement);
      }
    }
  });

Fresh environment per test. Each test provides its own layer, so tests never share database state.

What to test

Focused tests on the strongest invariants, not smoke tests:

  • Tenant isolation - a product from one organization is invisible and untouchable from another.
  • Permission gates - a caller without the capability receives Forbidden, never data.
  • Validation - malformed input produces the typed InvalidProduct error, not a crash.
  • Lifecycle transitions - illegal transitions fail with the typed transition error.

The retail test pins exactly these behaviors: scoped queries, requirePermission gates, and Zod validation, against the real migrations.

What not to write

  • Endless smoke tests and “regression tests” for deleted features - the repo calls these slop.
  • Snapshots you have not read - a snapshot captured from buggy code certifies the bug.
  • -update combined with a name filter - it can silently rewrite the wrong tests.

Running tests

pnpm test                 # every package's suite
pnpm nx run @africaos/retail:test   # one package
pnpm --filter @africaos/retail test

Tests use @effect/vitest (from the catalog) and run through vp test. See vite-plus.

Last updated on August 18, 2026