Testing Guide
Полное руководство по тестированию интеграций с использованием Vitest.
Настройка
Корневой конфиг
Проект использует единый конфиг vitest.config.ts в корне:
typescript
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/tests/**"],
},
},
});Структура тестов
Тесты располагаются рядом с файлами которые тестируют:
integrations/{name}/src/
├── handlers/
│ ├── init.ts # Исходный код
│ ├── init.test.ts # Тесты
│ ├── webhook.ts
│ └── webhook.test.ts
├── utils/
│ ├── validation.ts
│ └── validation.test.ts
└── index.tsКоманды
bash
# Все тесты проекта
pnpm test
# Тесты конкретной интеграции
pnpm --filter sofa test
# С coverage
pnpm test -- --coverage
# Watch mode (для разработки)
pnpm test -- --watch
# Конкретный файл
pnpm test -- init.test.ts
# Конкретный тест
pnpm test -- -t "should initialize successfully"
# Verbose output
pnpm test -- --reporter=verboseБазовый тест handler-а
Простой тест
typescript
// handlers/init.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleInit } from "./init";
describe("handleInit", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should initialize successfully", async () => {
const result = await handleInit({});
expect(result.success).toBe(true);
expect(result.created.database).toBe(true);
expect(result.created.assistant).not.toBeNull();
});
it("should use custom assistant name", async () => {
const result = await handleInit({ assistantName: "Custom" });
expect(result.success).toBe(true);
expect(result.created.assistant?.name).toBe("Custom");
});
});Тест с валидацией
typescript
// handlers/webhook.test.ts
import { describe, it, expect } from "vitest";
import { handleWebhook } from "./webhook";
describe("handleWebhook", () => {
describe("validation", () => {
it("should fail without recordId", async () => {
const result = await handleWebhook({ phone: "+380501234567" });
expect(result.success).toBe(false);
expect(result.error).toContain("recordId");
});
it("should fail without phone", async () => {
const result = await handleWebhook({ recordId: "123" });
expect(result.success).toBe(false);
expect(result.error).toContain("phone");
});
it("should succeed with valid payload", async () => {
const result = await handleWebhook({
recordId: "123",
phone: "+380501234567",
});
expect(result.success).toBe(true);
});
});
});Мокирование
Mock внутренних клиентов
typescript
// handlers/webhook.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { handleWebhook } from "./webhook";
// Mock модуля clients
vi.mock("../utils/clients", () => ({
getDb: vi.fn(() => ({
select: vi.fn().mockResolvedValue([]),
selectOne: vi.fn().mockResolvedValue(null),
insert: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
raw: vi.fn().mockResolvedValue([{ count: 0 }]),
healthCheck: vi.fn().mockResolvedValue(true),
})),
getCreds: vi.fn(() => ({
get: vi.fn().mockResolvedValue({
API_KEY: "test-key",
API_SECRET: "test-secret",
}),
set: vi.fn().mockResolvedValue(undefined),
listKeys: vi.fn().mockResolvedValue([]),
})),
getCache: vi.fn(() => ({
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
})),
getStore: vi.fn(() => ({
cacheAside: vi.fn((key, ttl, fn) => fn()),
set: vi.fn().mockResolvedValue(undefined),
get: vi.fn().mockResolvedValue(null),
})),
getEnv: vi.fn(() => ({
INTEG_DB: {},
INTEG_KV: {},
CRYPTO_KEY: "test-key",
CRYPTO_SALT: "test-salt",
})),
setEnv: vi.fn(),
__resetClients: vi.fn(),
}));
describe("handleWebhook", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should process webhook", async () => {
const result = await handleWebhook({
recordId: "123",
phone: "+380501234567",
});
expect(result.success).toBe(true);
});
});Mock внешних пакетов
typescript
// handlers/crm.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleCRMSync } from "./crm";
// Mock NethuntClient
vi.mock("@happ-integ/nethunt", () => ({
NethuntClient: vi.fn().mockImplementation(() => ({
getRecord: vi.fn().mockResolvedValue({
id: "123",
fields: {
name: "Test Lead",
phone: "+380501234567",
email: "test@example.com",
},
}),
updateRecord: vi.fn().mockResolvedValue(undefined),
createRecord: vi.fn().mockResolvedValue({ id: "new-123" }),
deleteRecord: vi.fn().mockResolvedValue(undefined),
})),
}));
// Mock LLM
vi.mock("@happ-integ/llm", () => ({
LLMService: vi.fn().mockImplementation(() => ({
chat: vi.fn().mockResolvedValue({
text: "Generated context for the call",
usage: { tokens: 100 },
}),
})),
}));
describe("handleCRMSync", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should sync CRM record", async () => {
const result = await handleCRMSync({ recordId: "123" });
expect(result.success).toBe(true);
});
});Mock fetch
typescript
// handlers/api-call.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleAPICall } from "./api-call";
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("handleAPICall", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should handle successful API call", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: "call-123", status: "initiated" }),
});
const result = await handleAPICall({
phoneNumber: "+380501234567",
authToken: "valid-token",
});
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Content-Type": "application/json",
}),
})
);
});
it("should handle API error", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => "Internal Server Error",
});
const result = await handleAPICall({
phoneNumber: "+380501234567",
authToken: "valid-token",
});
expect(result.success).toBe(false);
expect(result.error).toContain("500");
});
it("should handle network error", async () => {
mockFetch.mockRejectedValueOnce(new Error("Network error"));
const result = await handleAPICall({
phoneNumber: "+380501234567",
authToken: "valid-token",
});
expect(result.success).toBe(false);
expect(result.error).toContain("Network error");
});
});Тестирование сценариев
Тест concurrent limits
typescript
// handlers/test-call.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleTestCall } from "./test-call";
const mockDb = {
raw: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
};
vi.mock("../utils/clients", () => ({
getDb: () => mockDb,
}));
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("handleTestCall", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ id: "call-123" }),
});
});
describe("concurrent limits", () => {
it("should allow call when under limit", async () => {
mockDb.raw.mockResolvedValueOnce([{ count: 2 }]); // 2 < 3
const result = await handleTestCall({
phoneNumber: "+380501234567",
authToken: "valid-token",
});
expect(result.success).toBe(true);
});
it("should reject when at limit", async () => {
mockDb.raw.mockResolvedValueOnce([{ count: 3 }]); // 3 >= 3
const result = await handleTestCall({
phoneNumber: "+380501234567",
authToken: "valid-token",
});
expect(result.success).toBe(false);
expect(result.error).toContain("limit");
});
it("should reject when over limit", async () => {
mockDb.raw.mockResolvedValueOnce([{ count: 5 }]); // 5 > 3
const result = await handleTestCall({
phoneNumber: "+380501234567",
authToken: "valid-token",
});
expect(result.success).toBe(false);
});
});
});Тест retry логики
typescript
// handlers/retry.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleRetry } from "./retry";
const mockDb = {
selectOne: vi.fn(),
update: vi.fn(),
};
const mockCreds = {
get: vi.fn(),
};
vi.mock("../utils/clients", () => ({
getDb: () => mockDb,
getCreds: () => mockCreds,
}));
describe("handleRetry", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreds.get.mockResolvedValue({
MAX_RETRY_ATTEMPTS: "3",
RETRY_INTERVAL_HOURS: "4",
});
});
it("should schedule retry for first attempt", async () => {
mockDb.selectOne.mockResolvedValueOnce({
id: "call-123",
retry_attempt: 0,
});
const result = await handleRetry({ callId: "call-123" });
expect(result.success).toBe(true);
expect(result.attempt).toBe(1);
expect(mockDb.update).toHaveBeenCalledWith(
"calls",
expect.objectContaining({
status: "pending_retry",
retry_attempt: 1,
}),
{ id: "call-123" }
);
});
it("should reject when max retries reached", async () => {
mockDb.selectOne.mockResolvedValueOnce({
id: "call-123",
retry_attempt: 3, // already at max
});
const result = await handleRetry({ callId: "call-123" });
expect(result.success).toBe(false);
expect(result.error).toContain("Max retry");
});
it("should fail for non-existent call", async () => {
mockDb.selectOne.mockResolvedValueOnce(null);
const result = await handleRetry({ callId: "non-existent" });
expect(result.success).toBe(false);
expect(result.error).toContain("not found");
});
});Тест с временными зависимостями
typescript
// handlers/scheduled.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { handleScheduledTask } from "./scheduled";
describe("handleScheduledTask", () => {
beforeEach(() => {
// Фиксируем время
vi.useFakeTimers();
vi.setSystemTime(new Date("2024-01-15T10:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("should schedule for correct time", async () => {
const result = await handleScheduledTask({
callId: "123",
delayHours: 4,
});
expect(result.success).toBe(true);
// Ожидаем +4 часа от текущего времени
expect(result.scheduledFor).toBe("2024-01-15T14:00:00.000Z");
});
it("should handle timezone correctly", async () => {
vi.setSystemTime(new Date("2024-01-15T23:00:00Z"));
const result = await handleScheduledTask({
callId: "123",
delayHours: 4,
});
// Должен перейти на следующий день
expect(result.scheduledFor).toBe("2024-01-16T03:00:00.000Z");
});
});Тестирование ошибок
Error handling
typescript
// handlers/risky-operation.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleRiskyOperation } from "./risky-operation";
const mockDb = {
insert: vi.fn(),
update: vi.fn(),
};
vi.mock("../utils/clients", () => ({
getDb: () => mockDb,
}));
describe("handleRiskyOperation", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should handle database error gracefully", async () => {
mockDb.insert.mockRejectedValueOnce(new Error("Database connection lost"));
const result = await handleRiskyOperation({ data: "test" });
expect(result.success).toBe(false);
expect(result.error).toBe("Database connection lost");
});
it("should handle unexpected error", async () => {
mockDb.insert.mockRejectedValueOnce("Unknown error type");
const result = await handleRiskyOperation({ data: "test" });
expect(result.success).toBe(false);
expect(result.error).toBe("Unknown error");
});
it("should handle timeout", async () => {
mockDb.insert.mockImplementationOnce(
() => new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), 100))
);
const result = await handleRiskyOperation({ data: "test" });
expect(result.success).toBe(false);
});
});Интеграционные тесты
Тест полного flow
typescript
// handlers/full-flow.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleWebhook } from "./webhook";
import { handleCallComplete } from "./call-complete";
const mockDb = {
insert: vi.fn(),
update: vi.fn(),
selectOne: vi.fn(),
raw: vi.fn(),
};
vi.mock("../utils/clients", () => ({
getDb: () => mockDb,
getCreds: () => ({
get: vi.fn().mockResolvedValue({ API_KEY: "test" }),
}),
getStore: () => ({
cacheAside: vi.fn((key, ttl, fn) => fn()),
}),
}));
describe("Full call flow", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDb.raw.mockResolvedValue([{ count: 0 }]);
});
it("should complete full webhook -> call -> complete flow", async () => {
// Step 1: Webhook creates call
mockDb.insert.mockResolvedValueOnce(undefined);
const webhookResult = await handleWebhook({
recordId: "lead-123",
phone: "+380501234567",
});
expect(webhookResult.success).toBe(true);
expect(webhookResult.callId).toBeDefined();
const callId = webhookResult.callId;
// Step 2: Call completes
mockDb.selectOne.mockResolvedValueOnce({
id: callId,
status: "in_progress",
metadata: "{}",
});
mockDb.update.mockResolvedValueOnce(undefined);
const completeResult = await handleCallComplete({
callId: callId!,
status: "completed",
duration: 120,
});
expect(completeResult.success).toBe(true);
expect(completeResult.status).toBe("completed");
// Verify update was called with correct data
expect(mockDb.update).toHaveBeenCalledWith(
"calls",
expect.objectContaining({
status: "completed",
duration: 120,
}),
{ id: callId }
);
});
});Fixtures и helpers
Test fixtures
typescript
// tests/fixtures.ts
export const mockLead = {
id: "lead-123",
fields: {
name: "Test Lead",
phone: "+380501234567",
email: "test@example.com",
company: "Test Company",
},
};
export const mockCall = {
id: "call-123",
phone: "+380501234567",
lead_id: "lead-123",
status: "initiated",
retry_attempt: 0,
created_at: "2024-01-15T10:00:00Z",
updated_at: "2024-01-15T10:00:00Z",
};
export const mockCredentials = {
API_KEY: "test-api-key",
API_SECRET: "test-api-secret",
WEBHOOK_URL: "https://webhook.example.com",
MAX_RETRY_ATTEMPTS: "3",
RETRY_INTERVAL_HOURS: "4",
};Test helpers
typescript
// tests/helpers.ts
import { vi } from "vitest";
export function createMockDb(overrides = {}) {
return {
select: vi.fn().mockResolvedValue([]),
selectOne: vi.fn().mockResolvedValue(null),
insert: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
raw: vi.fn().mockResolvedValue([]),
healthCheck: vi.fn().mockResolvedValue(true),
...overrides,
};
}
export function createMockCreds(data = {}) {
return {
get: vi.fn().mockResolvedValue(data),
set: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
listKeys: vi.fn().mockResolvedValue(Object.keys(data)),
};
}
export function createMockFetch(responses: Array<{ ok: boolean; data?: unknown; error?: string }>) {
const mockFn = vi.fn();
responses.forEach((response, index) => {
if (response.ok) {
mockFn.mockResolvedValueOnce({
ok: true,
json: async () => response.data,
text: async () => JSON.stringify(response.data),
});
} else {
mockFn.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => response.error || "Error",
});
}
});
return mockFn;
}Использование helpers
typescript
// handlers/webhook.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleWebhook } from "./webhook";
import { createMockDb, createMockCreds, mockLead, mockCredentials } from "../tests/fixtures";
const mockDb = createMockDb();
const mockCreds = createMockCreds(mockCredentials);
vi.mock("../utils/clients", () => ({
getDb: () => mockDb,
getCreds: () => mockCreds,
}));
describe("handleWebhook", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should work with fixtures", async () => {
mockDb.selectOne.mockResolvedValueOnce(mockLead);
const result = await handleWebhook({
recordId: mockLead.id,
phone: mockLead.fields.phone,
});
expect(result.success).toBe(true);
});
});Coverage
Запуск с coverage
bash
# Полный отчёт
pnpm test -- --coverage
# HTML отчёт (открывается в браузере)
pnpm test -- --coverage --reporter=html
open coverage/index.html
# Только summary
pnpm test -- --coverage --reporter=text-summaryCoverage thresholds
typescript
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
},
});Best Practices
DO ✅
typescript
// ✅ Используй describe для группировки
describe("handleInit", () => {
describe("validation", () => {
it("should validate...", async () => {});
});
describe("success cases", () => {
it("should succeed...", async () => {});
});
describe("error cases", () => {
it("should handle error...", async () => {});
});
});
// ✅ Очищай моки перед каждым тестом
beforeEach(() => {
vi.clearAllMocks();
});
// ✅ Тестируй edge cases
it("should handle empty string", async () => {});
it("should handle null", async () => {});
it("should handle undefined", async () => {});
// ✅ Используй конкретные assertions
expect(result.error).toBe("recordId is required");
// вместо
expect(result.error).toBeTruthy();DON'T ❌
typescript
// ❌ Не используй общие assertions
expect(result).toBeTruthy(); // Что именно проверяем?
// ❌ Не забывай очищать моки
// (иначе тесты будут влиять друг на друга)
// ❌ Не делай тесты зависимыми друг от друга
// Каждый тест должен работать независимо
// ❌ Не игнорируй async/await
it("should work", () => { // Забыли async!
const result = handleInit({}); // Забыли await!
expect(result.success).toBe(true); // Проверяем Promise, не результат
});
// ❌ Не хардкодь значения в assertions
expect(result.callId).toBe("call_1705330000000_abc123");
// Лучше
expect(result.callId).toMatch(/^call_\d+_[a-z0-9]+$/);Debugging
Verbose output
bash
# Подробный вывод
pnpm test -- --reporter=verbose
# С логами
DEBUG=* pnpm testЗапуск одного теста
bash
# По имени файла
pnpm test -- init.test.ts
# По имени теста
pnpm test -- -t "should initialize"
# Только failed тесты
pnpm test -- --failedОтладка в VS Code
json
// .vscode/launch.json
{
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Tests",
"program": "${workspaceFolder}/node_modules/vitest/vitest.mjs",
"args": ["--run", "--reporter=verbose"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
}
]
}CI/CD интеграция
GitHub Actions
yaml
# .github/workflows/test.yml
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
- run: pnpm install
- run: pnpm test -- --coverage
- uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.jsonСвязанные документы
- HANDLERS_REFERENCE — паттерны handlers
- DEVELOPMENT — локальная разработка
- CODE_RULES — стандарты кода
- CI_CD — настройка CI/CD