Пошук уроків, статей та іншого контенту
Налаштуєте TypeScript у Node.js і типізуєте тестовий код, конфігурацію та взаємодію з бібліотеками.
TypeScript у тестах допомагає перевіряти не лише production-код, а й сам тестовий код:
аргументи та результати функцій;
структуру тестових даних;
типи помилок;
параметри тестових утиліт;
взаємодію з API Node.js та сторонніми бібліотеками;
конфігурацію запуску й компіляції.
Тест може бути логічно правильним, але містити помилки типів. Наприклад, тест передає функції поле з неправильною назвою або перевіряє властивість, якої не існує. TypeScript допомагає виявити такі проблеми ще до запуску тестів.
Важливо розділяти два процеси:
Перевірка типів — виконується командою tsc.
Запуск тестів — виконується тестовим runner-ом, який має вміти виконувати TypeScript-файли.
Сам tsc зазвичай не запускає тести, а тестовий runner не завжди перевіряє типи.
Для прикладу використаємо:
вбудований тестовий runner Node.js — node:test;
вбудований модуль перевірок — node:assert/strict;
tsx для запуску TypeScript без попередньої компіляції;
@types/node для типів Node.js.
Встановіть залежності:
npm install --save-dev typescript tsx @types/nodeПриклад package.json:
{
"name": "typed-node-tests",
"version": "1.0.0",
"type": "module",
"scripts": {
"test": "tsx --test test/user-service.test.ts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}Поле "type": "module" вказує Node.js, що файли проєкту використовують ESM-модулі та синтаксис import/export.
Створіть файл tsconfig.json у корені проєкту:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"types": ["node"],
"skipLibCheck": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}Основні параметри:
target визначає версію JavaScript, під яку перевіряється код;
module: "NodeNext" налаштовує модулі відповідно до правил Node.js;
moduleResolution: "NodeNext" визначає, як TypeScript шукає імпортовані файли;
strict: true вмикає сувору перевірку типів;
noEmit: true забороняє створювати JavaScript-файли під час перевірки;
types: ["node"] додає типи вбудованих API Node.js;
include визначає, які production- і тестові файли перевіряти.
Окремий tsconfig для тестів потрібен не завжди. Якщо production-код і тести мають однакові правила, їх зручно перевіряти однією конфігурацією.
Створимо сервіс, який отримує користувача з репозиторію.
Файл src/user-service.ts:
export interface User {
id: number;
name: string;
email: string;
}
export interface UserRepository {
findById(id: number): Promise<User | null>;
}
export class UserService {
public constructor(private readonly repository: UserRepository) {}
public async getUserEmail(userId: number): Promise<string> {
const user = await this.repository.findById(userId);
if (user === null) {
throw new Error(`User ${userId} was not found`);
}
return user.email;
}
}Тут типізовано:
структуру користувача через User;
контракт репозиторію через UserRepository;
тип аргументу userId;
тип результату Promise<string>;
можливість отримати null з репозиторію.
Опис UserRepository важливий для тестів. Тесту не потрібно знати конкретну базу даних. Йому достатньо надати об’єкт, який відповідає цьому контракту.
Створимо файл test/user-service.test.ts:
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
UserService,
type UserRepository,
} from "../src/user-service.js";
describe("UserService", () => {
it("повертає електронну адресу користувача", async () => {
const repository: UserRepository = {
async findById(id: number) {
assert.equal(id, 1);
return {
id: 1,
name: "Olena",
email: "olena@example.com",
};
},
};
const service = new UserService(repository);
const email = await service.getUserEmail(1);
assert.equal(email, "olena@example.com");
});
it("викидає помилку, якщо користувача не знайдено", async () => {
const repository: UserRepository = {
async findById() {
return null;
},
};
const service = new UserService(repository);
await assert.rejects(
() => service.getUserEmail(42),
{
message: "User 42 was not found",
},
);
});
});У тесті TypeScript перевіряє кілька важливих речей:
repository справді відповідає UserRepository;
метод findById приймає число;
метод findById повертає Promise<User | null>;
UserService отримує правильний тип залежності;
результат getUserEmail має тип string.
Імпорт ../src/user-service.js може виглядати незвично, коли вихідний файл має розширення .ts. Для ESM-проєктів Node.js у імпортах зазвичай указують майбутнє розширення .js. tsx коректно знаходить відповідний TypeScript-файл під час виконання.
Спочатку перевірте типи:
npm run typecheckЦя команда виконує:
tsc --noEmitЯкщо типи правильні, TypeScript не створює файлів і завершує процес без помилки.
Потім запустіть тести:
npm testСкрипт виконує:
tsx --test test/user-service.test.tstsx транспілює TypeScript у пам’яті та передає код тестовому runner-у Node.js.
Рекомендується виконувати обидві перевірки:
npm run typecheck && npm testУ такому випадку тести не запускатимуться, якщо production-код або тестовий код містить помилки типів.
Пакет @types/node додає типи для модулів Node.js. Наприклад, це стосується node:fs/promises, node:path, node:test, node:assert/strict та інших модулів.
Приклад тесту функції, яка працює з JSON-файлом:
Файл src/config-loader.ts:
import { readFile } from "node:fs/promises";
export interface AppConfig {
port: number;
environment: "development" | "test" | "production";
}
export async function loadConfig(filePath: string): Promise<AppConfig> {
const fileContent = await readFile(filePath, "utf8");
const parsedConfig: unknown = JSON.parse(fileContent);
if (!isAppConfig(parsedConfig)) {
throw new Error("Invalid application config");
}
return parsedConfig;
}
function isAppConfig(value: unknown): value is AppConfig {
if (typeof value !== "object" || value === null) {
return false;
}
const config = value as Record<string, unknown>;
return (
typeof config.port === "number" &&
(config.environment === "development" ||
config.environment === "test" ||
config.environment === "production")
);
}Файл test/config-loader.test.ts:
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, it } from "node:test";
import { loadConfig } from "../src/config-loader.js";
describe("loadConfig", () => {
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.map((directory) =>
rm(directory, { recursive: true, force: true }),
),
);
temporaryDirectories.length = 0;
});
it("завантажує коректну конфігурацію", async () => {
const directory = await mkdtemp(join(tmpdir(), "config-test-"));
temporaryDirectories.push(directory);
const filePath = join(directory, "config.json");
await writeFile(
filePath,
JSON.stringify({
port: 3000,
environment: "test",
}),
"utf8",
);
const config = await loadConfig(filePath);
assert.deepEqual(config, {
port: 3000,
environment: "test",
});
});
it("відхиляє конфігурацію з неправильним форматом", async () => {
const directory = await mkdtemp(join(tmpdir(), "config-test-"));
temporaryDirectories.push(directory);
const filePath = join(directory, "config.json");
await writeFile(
filePath,
JSON.stringify({
port: "3000",
environment: "test",
}),
"utf8",
);
await assert.rejects(
() => loadConfig(filePath),
{
message: "Invalid application config",
},
);
});
});Тут типи Node.js застосовуються до:
readFile;
mkdtemp;
rm;
writeFile;
tmpdir;
join;
хуків afterEach;
асинхронних callback-функцій тестів.
Повернене значення JSON.parse має тип any, тому безпечніше одразу трактувати його як unknown:
const parsedConfig: unknown = JSON.parse(fileContent);Тип unknown змушує перевірити значення перед використанням. Це особливо важливо для конфігурацій і зовнішніх даних, оскільки TypeScript не може перевірити вміст JSON-файлу під час компіляції.
Тестова заміна повинна відповідати контракту залежності.
Наприклад, цей об’єкт коректний:
const repository: UserRepository = {
async findById() {
return null;
},
};А цей код TypeScript відхилить:
const repository: UserRepository = {
async findById() {
return {
username: "Olena"
};
},
};Причина — результат не містить обов’язкових властивостей id, name та email.
Також помилкою буде неправильний тип аргументу:
const repository: UserRepository = {
async findById(id: string) {
return null;
},
};Метод контракту очікує id: number, тому реалізація з string несумісна з UserRepository.
Явне зазначення типу залежності корисне:
const repository: UserRepository = {
// Реалізація відповідає контракту репозиторію
async findById(id) {
return id === 1
? {
id: 1,
name: "Olena",
email: "olena@example.com",
}
: null;
},
};У цьому випадку TypeScript виводить тип id із контексту. Не потрібно дублювати його в кожній реалізації.
У TypeScript значення, перехоплене в catch, у суворому режимі має тип unknown.
try {
await loadConfig("config.json");
} catch (error: unknown) {
if (error instanceof Error) {
assert.equal(error.message, "Invalid application config");
} else {
assert.fail("Очікувалася помилка типу Error");
}
}Для перевірки помилок у тестах зручно використовувати assert.rejects:
await assert.rejects(
() => service.getUserEmail(42),
{
message: "User 42 was not found",
},
);Використовуйте unknown, а не any, коли тип помилки або зовнішнього значення невідомий. Це змушує явно перевірити значення перед доступом до його властивостей.
У цьому прикладі використано вбудований node:test, типи якого надає @types/node.
Якщо проєкт використовує іншу тестову бібліотеку, потрібно встановити її типи лише тоді, коли вони не входять до самої бібліотеки. Наприклад, для деяких бібліотек типи постачаються окремим пакетом, а для інших уже включені в основний пакет.
Важливо, щоб TypeScript бачив типи саме тієї бібліотеки, яка використовується у тестах. Інакше можуть виникати помилки в імпортах, хуках, matcher-ах або контексті тесту.
Для вбудованого runner-а Node.js достатньо:
{
"compilerOptions": {
"types": ["node"]
}
}Не варто без потреби додавати до types випадкові пакети. Це може змінити глобальне тестове оточення та приховати проблеми в конфігурації.
.ts файлів без TypeScript-рантаймуКоманда на кшталт:
node test/user-service.test.tsне є універсальним способом запуску TypeScript-файлів у Node.js. Для цього потрібен налаштований механізм виконання TypeScript, наприклад tsx, або попередня компіляція в JavaScript.
tsx може запустити тест навіть тоді, коли в ньому є помилки TypeScript.
Тому команда:
npm testне замінює:
npm run typecheckВиконуйте обидві перевірки.
@types/nodeБез @types/node TypeScript може не розпізнати:
import { readFile } from "node:fs/promises";або імпорти з node:test і node:assert/strict.
any для JSON і відповідей бібліотекany вимикає значну частину перевірки типів. Для невідомих даних краще використовувати unknown, а потім виконувати перевірку структури.
Для конфігурації з "module": "NodeNext" і "type": "module" імпорти локальних модулів зазвичай мають містити .js:
import { UserService } from "../src/user-service.js";Навіть якщо вихідний файл під час розробки має розширення .ts, це відповідає розширенню майбутнього JavaScript-файлу.
Такий запис лише приховує проблему:
const config = parsedConfig as AppConfig;Приведення типу не перевіряє реальний вміст JSON. Якщо дані приходять із файлу або зовнішньої бібліотеки, спочатку перевірте їхню структуру, як у функції isAppConfig.
TypeScript у тестах перевіряє типи тестових даних, залежностей і результатів.
tsx може запускати TypeScript-тести без попередньої компіляції.
tsc --noEmit потрібно запускати окремо для повної перевірки типів.
@types/node додає типи вбудованих API Node.js.
Контракти інтерфейсів допомагають типізувати тестові замінники.
Значення з JSON та інших зовнішніх джерел слід розглядати як unknown і перевіряти перед використанням.
Для ESM-проєктів локальні імпорти мають використовувати .js у специфікаторі.
Надійний pipeline перевірки містить і typecheck, і запуск тестів.