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>;
Опції
rootDir— піднімає повний проєкт із диска. Сканує моделі + файлові маршрути тим самим конвеєром, що йhopak dev. Взаємо-виключний ізmodels/router(конструктор кидає помилку, якщо передано обидва).models— масивModelDefinition. Коли заданий, відкривається in-memory SQLite і виконуєтьсяdb.sync(), щоб у тесті можна було викликатиenv.db.model('post').create(...).router— власнийRouter(вже заповнений маршрутами черезcrud.*абоdefineRoute) замість дефолтного порожнього.middleware—{ before, after, wrap }, що застосовуються до кожного запиту; та сама форма, що йhopak().before(...).after(...).wrap(...)у продакшн-коді. Дозволяє тестувати глобальні мідлвари (request-log, auth) ізольовано:import { requestLog } from '@hopak/core'; env = await createTestServer({ router, middleware: { before: [], after: [requestLog()], wrap: [] }, });log— підмінити логер, щоб ловити вивід. Пара з логером-стабом корисна в тестах, де перевіряються рядки логу (див. приклад у тестах request-log у@hopak/core).staticDir— директорія, яку треба обслуговувати з кореня (для тестів статичних файлів).exposeStack: true— включати стек у відповіді 500. Зручно при дебазі тесту, який спровокував помилку на сервері.
Завершення роботи
Завжди викликайте 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>>;
}
post/put/patchвстановлюютьcontent-type: application/jsonіJSON.stringifyтілу.getприймає необов’язковий другийRequestInit, якщо потрібні власні заголовки.- Працює як над
http, так і надhttps— клієнт слідує за тим, що повертаєenv.url.
Окреме використання
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');