Testing

@hopak/testing — внутрішньопроцесний тестовий сервер і типізований JSON-клієнт для end-to-end тестів.

Хелпери для тестування Hopak.js. Піднімають справжні внутрішньопроцесні сервери на випадкових портах і дають типізований fetch-клієнт — без моків і ручного налаштування.

Встановлення

bun add -d @hopak/testing

Написано під bun:test, але createTestServer не прив’язаний до фреймворку — його можна запускати під Vitest, Jest або вбудованим тест-раннером Node.

Швидкий приклад

import { afterEach, expect, test } from 'bun:test';
import { Router, crud, model, text, boolean } from '@hopak/core';
import { createTestServer, type TestServer } from '@hopak/testing';

const post = model('post', {
  title: text().required().min(3),
  published: boolean().default(false),
});

let env: TestServer | undefined;

afterEach(async () => {
  await env?.stop();
  env = undefined;
});

test('POST /api/posts creates a row', async () => {
  // Wire up CRUD explicitly — nothing auto-registers.
  const router = new Router();
  router.add('GET', '/api/posts', crud.list(post));
  router.add('POST', '/api/posts', crud.create(post));

  env = await createTestServer({ models: [post], router });

  const res = await env.client.post<{ id: number; title: string }>('/api/posts', {
    title: 'Hello',
  });

  expect(res.status).toBe(201);
  expect(res.body.title).toBe('Hello');
});

Жодної конфігурації HTTP-сервера, жодного обліку вільних портів — тест обирає випадковий порт (port: 0), зупиняється в afterEach, а in-memory SQLite живе лише протягом тесту.

createTestServer

Запускає справжній HTTP-сервер Hopak для тесту. Два режими:

Режим 1: вказати rootDir проєкту

Стартує так само, як hopak dev — сканує app/models/, завантажує файлові маршрути з app/routes/. Використовуйте для інтеграційних тестів, що перевіряють реальні згенеровані файли маршрутів.

const env = await createTestServer({ rootDir: process.cwd() });

Міграції + rootDir. Якщо в цільовому проєкті є app/migrations/, createApp пропускає db.sync() на старті — як це робить hopak dev. Тест-сьют тоді відповідає за підняття схеми: або запустити hopak migrate up для тестової БД до сьюта, або викликати раннер напряму:

import { applyUp, loadMigrations } from '@hopak/core';

const env = await createTestServer({ rootDir });
const { migrations } = await loadMigrations(`${rootDir}/app/migrations`);
await applyUp({ db: env.requireDb(), dialect: 'sqlite' }, migrations);

Режим 2 (models) зберігає прямолінійну поведінку db.sync() — ідеально для юніт-тестів, яким не потрібна закомічена історія схеми.

Режим 2: in-memory models + router

Для юніт-подібних тестів, де маленький роутер зібраний уручну.

const env = await createTestServer({
  models: [post],
  router: preBuiltRouter,
});

Сигнатура

interface TestServerOptions {
  rootDir?: string;                      // scan a full project (mutually exclusive with models/router)
  models?: readonly ModelDefinition[];   // in-memory SQLite opens + syncs
  router?: Router;                       // pre-populated with routes
  middleware?: Middleware;               // global before/after/wrap for every request
  log?: Logger;                          // override logger — useful for capturing output
  staticDir?: string;                    // path to a public/ directory
  exposeStack?: boolean;                 // include stack traces in 500 responses
}

interface TestServer {
  readonly url: string;                  // e.g. 'http://localhost:53248'
  readonly router: Router;               // add more routes after start
  readonly db: Database | null;          // null when no models were passed
  readonly client: JsonClient;           // fetch wrapper — see below
  readonly server: ListeningServer;      // raw handle (port, stop, etc.)
  requireDb(): Database;                 // throws if models weren't passed
  stop(): Promise<void>;                 // closes server + DB
}

declare function createTestServer(options?: TestServerOptions): Promise<TestServer>;

Опції

Завершення роботи

Завжди викликайте env.stop() в afterEach (або try/finally). Метод закриває HTTP-слухач і базу даних.

JsonClient

env.client — мінімальна типізована обгортка над fetch. Кожен метод повертає JsonResponse<T>:

interface JsonResponse<T = unknown> {
  status: number;
  body: T;              // parsed JSON, or raw text if non-JSON
  headers: Headers;
  raw: Response;        // original fetch Response
}

interface JsonClient {
  get<T>(path: string, init?: RequestInit): Promise<JsonResponse<T>>;
  post<T>(path: string, body?: unknown): Promise<JsonResponse<T>>;
  put<T>(path: string, body?: unknown): Promise<JsonResponse<T>>;
  patch<T>(path: string, body?: unknown): Promise<JsonResponse<T>>;
  delete<T>(path: string): Promise<JsonResponse<T>>;
}

Окреме використання

createJsonClient(baseUrl) також експортується, якщо потрібен клієнт без createTestServer:

import { createJsonClient } from '@hopak/testing';

const client = createJsonClient('http://localhost:3000');
const res = await client.get('/health');

Рецепти

End-to-end через rootDir (рекомендовано для інтеграційних тестів)

import { afterAll, beforeAll, expect, test } from 'bun:test';
import { createTestServer, type TestServer } from '@hopak/testing';

let env: TestServer;

beforeAll(async () => {
  env = await createTestServer({ rootDir: process.cwd() });
});
afterAll(() => env.stop());

test('auto-CRUD from scaffolded files works', async () => {
  const created = await env.client.post('/api/posts', { title: 'seed', content: 'x' });
  expect(created.status).toBe(201);
});

Використовує те, що згенерував hopak generate crud. Жодного тест-специфічного коду маршрутизації — ви тестуєте рівно ті файли, які рантайм і обслуговує.

Чутливі поля вирізаються у відповідях (top-level + include)

import { crud, Router, email, model, password, text } from '@hopak/core';

const user = model('user', {
  email: email().required().unique(),
  password: password().required().min(8),
});

test('password is never returned', async () => {
  const router = new Router();
  router.add('POST', '/api/users', crud.create(user));

  const env = await createTestServer({ models: [user], router });
  try {
    const res = await env.client.post<Record<string, unknown>>('/api/users', {
      email: 'a@b.com',
      password: 'secret12',
    });
    expect(res.status).toBe(201);
    expect(res.body.password).toBeUndefined();
  } finally {
    await env.stop();
  }
});

Лише власні маршрути, без БД

import { defineRoute, Router } from '@hopak/core';

const router = new Router();
router.add('GET', '/health', defineRoute({ handler: () => ({ ok: true }) }));

const env = await createTestServer({ router });
const res = await env.client.get<{ ok: boolean }>('/health');
expect(res.body.ok).toBe(true);

Пряме використання БД

const env = await createTestServer({ models: [post] });

// requireDb() throws if models weren't passed — great for type narrowing
const db = env.requireDb();
await db.model('post').create({ title: 'seed' });

Статичні файли

import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const dir = await mkdtemp(join(tmpdir(), 'hopak-static-'));
await mkdir(join(dir, 'public'), { recursive: true });
await writeFile(join(dir, 'public', 'hello.txt'), 'hi');

const env = await createTestServer({ staticDir: join(dir, 'public') });
const res = await env.client.get<string>('/hello.txt');
expect(res.body).toBe('hi');

Стиль перевірок

Оскільки body — це розпарсений JSON (або рядок), використовуйте стандартні expect-виклики напряму:

expect(res.status).toBe(200);
expect(res.body).toEqual({ id: 1, title: 'Hello' });
expect(res.headers.get('etag')).toBeTruthy();

Для помилкових відповідей перевіряйте типізовану форму HopakError:

const res = await env.client.get<{ error: string; message: string }>('/api/posts/9999');
expect(res.status).toBe(404);
expect(res.body.error).toBe('NOT_FOUND');