Как написать тест-раннер в 80 строк кода на JavaScript/TypeScript

В экосистеме JavaScript насчитывается много средств выполнения тестов: Jest, Karma, Ava, Mocha. И это лишь малая их часть. Встроенный тест-раннер имеется в Deno. Экосистема со встроенным тестом и fmt  —  более зрелая и лучшая, на мой взгляд.

Итак, какова цель этой статьи? По сути, она заключается в том, чтобы показать: в сегодняшней экосистеме nodejs сделать эффективный тест-раннер несложно.

Изначально хотелось написать его на Rust, поэтому я задался вопросом: «Каковы основные технические требования?». Нужно:

  1. Исходный код теста вместе с его зависимостями, а значит, понадобится упаковщик вроде SWC.

SWC  —  это расширяемая платформа на Rust для следующего поколения быстрых инструментов разработчика. Она применяется в Next.js, Parcel и Deno и таких компаниях, как Vercel, ByteDance, Tencent, Shopify.

2. Поддержка TypeScript, поэтому понадобится предварительный этап транспиляции в JavaScript  —  здесь тоже пригодится SWC.

3. Запускать этот код в среде выполнения JavaScript. Попробуем крейт rusty_v8 из проекта deno (крейт  —  это библиотека, напоминает модуль node в диспетчере пакетов npm). API показался отличным  —  создаешь скрипт и запускаешь изолированно (если я правильно понимаю).

4. Сделать API самогó тестового набора и структуры тестов, причем в JavaScript, ведь применять их будут пользователи-разработчики этого языка.

К моменту формулирования этого требования стало понятно, что писать API на Rust не стоит и 80 строками он не ограничится.

CLI «Run-Chewy»

Я решил написать крошечный тест-раннер run-chewy на nodejs, просто чтобы лучше разобраться в основном пакете SWC и поэкспериментировать с рабочими потоками. В SWC предоставляется простой API на JavaScript.

Точка входа

Тест-раннер  —  это инструмент CLI. Ограничусь передачей в него одного аргумента isTS для указания пользователем, применяет он TypeScript или нет. Альтернативой может быть конфигурационный файл chewy.json.

Точка входа проекта  —  файл cli.js, которым запускается метод run():

const yargs = require('yargs/yargs')
const { hideBin } = require('yargs/helpers')
const argv = yargs(hideBin(process.argv)).argv
const ChewyRunner = require('./lib/runner');

if (argv.isTS) {
console.log("running")
ChewyRunner.run({isTS: argv.isTS});
} else {
console.log("supply the isTS arg to decide if running js or ts tests")
}

Тест-раннер

Вот основные технические требования по тест-раннеру. Что нужно:

  1. Список тестовых файлов для запуска, он должен браться из текущей запускаемой папки.
  2. Перебрать список и спарсить каждый файл, чтобы получить полный исходный код (с зависимостями).
  3. В случае с TypeScript перед выполнением транспилировать исходный код в JavaScript.
  4. Выполнить этот код параллельно.
  5. Вывести результаты теста или (в случае любой ошибки времени выполнения) ошибку.
const workerThreads = require('node:worker_threads');
const swc = require('@swc/core');
const fs = require('fs');
const path = require('path');

function grabFilesSync(currentDirPath) {
const files = [];

const walker = (_path, cb) => {
fs.readdirSync(_path).forEach((name) => {
const filePath = path.join(_path, name);
const stat = fs.statSync(filePath);
if (stat.isFile()) {
if (filePath.match(/.*.spec.ts|.*.test.ts|.*.spec.js|.*.test.js/)) {
cb(filePath);
}
} else if (stat.isDirectory()) {
walker(filePath, cb);
}
});
};

walker(currentDirPath, (filePath) => {
files.push(filePath);
});
return files;
}
function initOptions(config) {
return {
isModule: true,
module: {
type: "commonjs"
},
jsc: {
target: "es2020",
parser: config.isTS ? {syntax: "typescript"} : {syntax: "ecmascript"},
transform: null,
}
}
}

class ChewyRunner {
static run(config) {
if (workerThreads.isMainThread) {
const tests = grabFilesSync(process.cwd());
for (const t of tests) {
console.log("Running ", t); //выполнить добавление цветов и форматов

fs.promises.readFile(t)
.then((buff) => swc.transform(buff.toString(), initOptions(config)))
.then((output) => {
const worker = new workerThreads.Worker(output.code, { eval: true });
worker.on('exit', (c) => console.log('done')); // выполнить улучшенную обработку ошибок
});
}
}
}
}
module.exports = ChewyRunner;

Внимание! В менее чем 60 строках кода здесь включены основные, определенные нами требования. Но кое-что отсутствует:

  1. Пул потоков, которым бы ограничивалось их создание. Вместо этого для каждого теста инициируется новый рабочий поток.
  2. Метод run асинхронный, но отлова события ошибки в рабочем потоке (и отклонения) нет.
  3. В этой реализации нет поддержки es-модулей.
  4. Для написания раннера не применялся TypeScript.

Интересная, на мой взгляд, часть реализации  —  применение API на JavaScript из SWC. Здесь можно освоить его настройку.

API

В API должен быть группирующий блок для запуска тестов в «наборе» и описание этого набора, то же для тестового блока, а также поддержка асинхронных функций. Блок должен быть готов выполняться немедленно, аналогично структуре describe обычных фреймворков тестирования. В API должны быть блоки сравнения (необязательно при наличии внешних библиотек):

Chewy.suite('suite', async () => {
console.log('suite cb');
Chewy.test('test', () => {
Chewy.assertEq([[[1, 2, 3]], 4, 5], [[[1, 2, '3']], 4, 5]);
})
})

const assert = require("node:assert");

const isAsync = (fn) => fn.constructor.name === 'AsyncFunction'; // https://github.com/tc39/proposal-async-await/issues/78#issuecomment-164408675
const noop = () => {};
const group = (description, handler) => {
console.log(`Running: [ ${description} ]`);
if (isAsync(handler)) {
return handler().then(noop)
.catch((err) => {
console.error(err)
});
}
handler();
}

class Chewy {
static suite = group;
static test = group;
static x = Chewy; //выполнить пропуск реализации
static assertEq = (actual, expected) => assert.strict.deepEqual(actual, expected);
}

module.exports = Chewy;

Внимание! В ~20 строках кода здесь включены основные, определенные нами требования. Но кое-какой функционал отсутствует, например улучшенная обработка ошибок, хуки before/teardown, реализация пропуска теста/набора и, возможно, заглушки.

Заключение

Можно ли написать простой раннер на nodejs почти без зависимостей? Запросто! Я не публиковал в npm, так что не используйте его дома.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Liron Hazan: Write your own Javascript/Typescript tests runner in 80 lines of code </>

Предыдущая статьяБазовый класс Android ViewModel за 5 минут
Следующая статьяПроект API с точки зрения разработчика Android