Skip to content

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-summary

Coverage 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

Связанные документы