Files
zalbot/tests/README.md
Kaysser Kayyali fbd991a2b0
Some checks failed
tests / Unit tests (Node 22) (push) Failing after 28s
feat: docs pass, test fixes, advanced review
2026-06-19 16:15:06 +00:00

5.2 KiB

Tests

This directory holds the project's automated test suite.

Layout

tests/
├── fixtures/        Shared test fixtures (spec, session, etc.)
├── integration/     Integration tests (require live infrastructure)
├── unit/            Unit tests (default CI gate)
└── README.md        You are here
  • unit/ — fast, isolated tests for individual modules. No network, no Redis, no Discord gateway. The CI default runs only this directory.
  • integration/ — slower tests that exercise real services (or mocks close to the wire). Run explicitly; not part of the default test command.
  • fixtures/ — reusable mocks (mockSession, mockSpec) shared by multiple unit tests.

Running

npm test              # vitest run — runs ALL tests, including integration (requires live infra)
npm run test:unit     # run only tests/unit
npm run test:int      # run only tests/integration (requires live infra)
npm run test:coverage # run unit tests with v8 coverage report
npm run test:watch    # vitest in watch mode

CI default: .gitea/workflows/test.yml runs npm run test:unit (and the coverage report), not npm test — so a missing Redis or GraphMCP endpoint will not fail CI. Run npm run test:int explicitly only when you intend to exercise the live-infrastructure path.

Conventions

1. One module per file

A test file covers one source module. File name: <moduleName>.test.ts, placed under tests/unit/. If a source module exports multiple functions worth testing, group them with describe blocks in the same file.

2. Mock before import — always

vi.mock calls must appear before the import of the module under test, otherwise the unmocked module is already cached. The pattern:

import { vi, describe, it, expect, beforeEach } from 'vitest';

const { mockFn } = vi.hoisted(() => ({ mockFn: vi.fn() }));

vi.mock('../../src/lib/logger.js', () => ({
  log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));

// ...more mocks...

import { myFunction } from '../../src/some/module.js'; // AFTER mocks

vi.hoisted lets you share mock state between a vi.mock factory and the test body — both run in the same scope.

3. vi.clearAllMocks() in beforeEach

Prevents test bleed-through. If you also mutate config or module-level state, reset it explicitly in beforeEach.

4. Reuse mockSession and mockSpec

Import from ../fixtures/spec.js. Don't redefine session shape per file — schema drift is one of the easier ways for tests to silently rot.

5. Test the behavior, not the implementation

Assert outcomes (return values, side effects on real collaborators, error messages) rather than calling patterns. When a test would only pass with a specific internal implementation, ask whether the contract is what's documented in the source's doc comment.

6. Don't hit the network

  • fetch → use vi.stubGlobal('fetch', ...) (see foundryClientRetry.test.ts).
  • Discord client → pass a hand-rolled mock with only the methods the code uses (e.g. messages.fetch, send, sendTyping, setArchived).
  • Redis → use ioredis-mock (see sessionManager.test.ts).
  • LLM SDKs → mock the constructor (see litellmClient.test.ts, ollamaClient.test.ts).
  • Filesystem → use mkdtempSync from node:os.tmpdir() (see personaLoader.test.ts).

7. Player-facing strings

When a test asserts on a string the bot would say to a player, prefer in-world language over utility terms. (Same rule that applies to production code — see feedback-in-world-voice memory.)

Anti-patterns

  • Asserting private state. Reach for behaviour-side assertions first.
  • Resetting state with vi.resetModules() for the sake of it. It breaks shared mock state. Use it only when a module-scoped cache (e.g. a lazy client) needs to be re-constructed.
  • Catching all errors in a test. If a test passes by accident because an unhandled rejection was swallowed, it's not testing anything.
  • Mocking the module under test. If you have to mock the file you're testing, the test is asserting nothing.
  • Timeouts in it() callbacks. Use vi.useFakeTimers() and vi.advanceTimersByTimeAsync to step time deterministically (see messageRouterRunLLMTurn.test.ts for the typing-indicator pattern).

Adding a new test

  1. Create tests/unit/<name>.test.ts.
  2. Use the closest existing test as a template — goalRegister.test.ts for tool plugins, foundryClientRetry.test.ts for HTTP, relaySession.test.ts for node:https / node:crypto, sessionManager.test.ts for Redis.
  3. Run npm run test:unit -- <your-file> to iterate quickly.
  4. When green, run the full suite: npm run test:unit.
  5. Optional: check npm run test:coverage to confirm the file's coverage.

Coverage

npm run test:coverage produces a v8 coverage report in the terminal. Directories worth watching:

  • src/bot/handlers/ — message routing; runLLMTurn is the runtime heart.
  • src/harness/tools/ — the tool plugin contracts.
  • src/vtt/ — Foundry relay; foundryClient is the biggest single file.

Coverage is informational, not a gate. The goal is to grow the unit test surface for the modules that own irreversible or user-facing behavior.