Към съдържанието
wannacode

Тестове с node:test

Проблем

Отстъпката, която се счупи тихо

Правилото на магазина е: отстъпката е в проценти, а при покупка от 100 € нагоре се добавят още 5%. Пишеш функцията и я пробваш на ръка:

main.js
// Отстъпка в проценти. От 100 € нагоре се добавят още 5%.
function applyDiscount(price, percent) {
if (price > 100) {
percent = percent + 5;
}
return price * (100 - percent) / 100;
}
console.log(applyDiscount(50, 10));
console.log(applyDiscount(200, 10));
Изход
45
170

50 € с 10% са 45 €, а 200 € с 15% са 170 €. Изглежда наред и го пускаш. След седмица клиент с покупка за точно 100 € плаща 90 € вместо 85 €. Условието е price > 100, а трябва да е price >= 100.

Никой не е пробвал точно 100 €. А при всяка следваща промяна във функцията трябва да пробваш пак всички случаи на ръка.

Идеята

Код, който проверява код

Вместо да гледаш изхода с очите си, пишеш малки функции, които викат твоя код и сравняват резултата с очаквания. Това е тест. Пускаш всички тестове с едно натискане, след всяка промяна, и те казват кое се е счупило.

Вид Какво проверява Пример
Unit тест едно малко парче, обикновено една функция, отделно от останалото applyDiscount(100, 10) връща 85
Интеграционен как парчетата работят заедно: с база данни, файл, друга услуга поръчката се записва в базата и се чете обратно
End-to-end цялото приложение, както го ползва човек клиентът слага в количката, плаща и получава имейл
В този урок пишем unit тестове. В Node.js за тях има вграден модул node:test, а проверките са от node:assert: нищо не се инсталира.

Защо си струва:

  • грешката се вижда веднага, докато още помниш какво си променил, а не когато клиентът се оплаче;
  • тестът показва как се ползва кодът: кой случай какво връща;
  • можеш да подреждаш кода смело. Ако тестовете са зелени и след промяната, поведението не се е променило.
По-точно, за любопитните

Има и други видове тестове: функционални (отговаря ли програмата на изискванията), за производителност (колко заявки издържа), за сигурност (има ли пробиви) и на потребителския интерфейс (бутоните, формите). Всички те стъпват на unit тестовете: те са най-много, най-бързи и най-евтини за поддържане.

Освен node:test ще срещнеш Jest и Vitest: отделни библиотеки, особено за код в браузъра. Идеите са същите: test, expect, beforeEach.

Виж го

Първият тест

applyDiscount е във файла discount.js, с export пред function, за да може друг файл да я внесе. Тестовете за нея са в отделен файл discount.test.js:
discount.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyDiscount } from "./discount.js";
test("10% от 50 €", () => {
assert.equal(applyDiscount(50, 10), 45);
});
test("още 5% от 100 € нагоре", () => {
assert.equal(applyDiscount(100, 10), 85);
});
  • test(име, функция) записва тест. Името е текст, който казва какво се проверява.
  • assert.equal(истинско, очаквано) сравнява двете стойности с ===. Ако се различават, тестът пада. Внасяме го от node:assert/strict: там всяка проверка е строга.

Пускаме двата теста срещу кода от първата карта:

Резултат
ОК 10% от 50 €
ПАДА още 5% от 100 € нагоре: 90 !== 85
Тестове: 2, минали: 1, паднали: 1

Първият тест минава. Вторият пада и казва точно какво е станало: очаквал е 85, а функцията е върнала 90. Поправяш условието на price >= 100, пускаш пак и двата са зелени:

Резултат
ОК 10% от 50 €
ОК още 5% от 100 € нагоре
Тестове: 2, минали: 2, паднали: 0

Всеки тест прави три неща: подготвя стойностите, вика кода и проверява резултата.

test("10% от 50 €", () => {
assert.equal(applyDiscount(50, 10), 45);
});
test("още 5% от 100 € нагоре", () => {
assert.equal(applyDiscount(100, 10), 85);
});
Стъпка по стъпка, с думи
  1. Файлът има два теста. node:test пуска всеки поотделно и пали лампичка до него.
  2. Тест 1, подготовка: цена 50 € и 10% отстъпка.
  3. Действие: applyDiscount(50, 10) връща 45.
  4. Проверка: assert.equal сравнява очакваното 45 с върнатото 45. Еднакви са, тестът минава.
  5. Тест 2: точно 100 € и 10%. Очакваме 85, защото от 100 € нагоре има още 5%.
  6. Условието във функцията е price > 100, а 100 не е по-голямо от 100. Функцията връща 90.
  7. Очаквано 85, върнато 90: тестът пада и казва точно това.
  8. Поправяш условието на price >= 100 и пускаш тестовете пак.
  9. Сега 100 минава условието и функцията връща 85.
  10. Очаквано и върнато съвпадат. И двата теста са зелени.
По-точно, за любопитните
  • В проекта кодът и тестовете са файлове един до друг. Тестовите файлове завършват на .test.js, за да ги намери node --test:
Структура
discount.js кодът (с export)
discount.test.js тестовете за него
  • Пускат се от терминала. В терминала node --test печата всеки тест с ✔ или ✖, колко време е отнел и подробности за падналите:
Команди
node --test намира *.test.js и ги пуска
node --test discount.test.js само този файл
node --test --watch пуска ги пак при всяка промяна
  • Тук, на сайта, файловете се записват във временна папка и се пускат с run() от node:test. Функцията по-долу слуша събитията test:pass и test:fail и печата резултатите, подредени по име, за да е изходът еднакъв при всяко пускане:
Пускачът
import { run } from "node:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
async function runTests(files) {
const dir = mkdtempSync(join(tmpdir(), "tests-"));
for (const [name, code] of Object.entries(files)) {
writeFileSync(join(dir, name), code);
}
const tests = Object.keys(files).filter((name) => name.endsWith(".test.js")).map((name) => join(dir, name));
const lines = [];
let failed = 0;
for await (const { type, data } of run({ files: tests })) {
if (data.details?.type !== "test") {
continue;
}
if (type === "test:pass") {
lines.push(`ОК ${data.name}`);
} else if (type === "test:fail") {
failed++;
const error = data.details.error.cause ?? data.details.error;
const message = error.operator === "strictEqual" ? `${error.actual} !== ${error.expected}` : error.message;
lines.push(`ПАДА ${data.name}: ${message}`);
}
}
lines.sort();
for (const line of lines) {
console.log(line);
}
console.log(`Тестове: ${lines.length}, минали: ${lines.length - failed}, паднали: ${failed}`);
}

Пробвай

ДДС с дробно число

Цената с ДДС е цената по 1,2. Два теста:

Какво ще покажат тестовете?

export function withVat(price) {
return price * 1.2;
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { withVat } from "./vat.js";
test("кафе", () => {
assert.equal(withVat(10), 12);
});
test("чай", () => {
assert.equal(withVat(3), 3.6);
});
Отговор

Идеята

Да или не, и очаквано изключение

Освен равенство често проверяваш условие или че кодът отказва. Ето Account от модула за изключенията, с нов метод canPay:

account.js
export class InsufficientFundsError extends Error {
constructor(shortfall) {
super(`Не стигат ${shortfall} €`);
this.name = "InsufficientFundsError";
}
}
export class Account {
#balance;
constructor(balance) {
this.#balance = balance;
}
get balance() {
return this.#balance;
}
canPay(amount) {
return amount <= this.#balance;
}
withdraw(amount) {
if (amount <= 0) {
throw new RangeError("Сумата трябва да е положителна");
}
if (amount > this.#balance) {
throw new InsufficientFundsError(amount - this.#balance);
}
this.#balance -= amount;
}
}
account.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { Account } from "./account.js";
test("може да плати, колкото има", () => {
const account = new Account(100);
assert.ok(account.canPay(100));
assert.equal(account.canPay(101), false);
});
test("теглене на повече, отколкото има", () => {
const account = new Account(100);
assert.throws(() => account.withdraw(130), {
name: "InsufficientFundsError",
message: "Не стигат 30 €",
});
assert.equal(account.balance, 100);
});
Изход
ОК може да плати, колкото има
ОК теглене на повече, отколкото има
Тестове: 2, минали: 2, паднали: 0
  • assert.ok(условие) пада, ако условието не е вярно. За „не е вярно“ сравняваме с false.
  • assert.throws(функция, какво) пада, ако функцията не хвърли. Кодът се подава като стрелкова функция: assert.throws сам я пуска и хваща грешката. Вторият аргумент описва очакваната грешка: тук името и съобщението ѝ.

Вторият тест проверява и нещо, което лесно се забравя: след отказа балансът е същият.

Идеята

Всеки тест започва на чисто

Тук двата теста делят една сметка от 100 €:

account.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { Account } from "./account.js";
const account = new Account(100);
test("теглене на 40 € оставя 60 €", () => {
account.withdraw(40);
assert.equal(account.balance, 60);
});
test("теглене на 70 € оставя 30 €", () => {
account.withdraw(70);
assert.equal(account.balance, 30);
});
Изход
ОК теглене на 40 € оставя 60 €
ПАДА теглене на 70 € оставя 30 €: Не стигат 10 €
Тестове: 2, минали: 1, паднали: 1

account е създадена веднъж за целия файл. node:test пуска тестовете по реда във файла, затова тестът за 40 € е първи. На втория са останали 60 €, а той тегли 70 €. Достатъчно е някой да размести тестовете или да пусне само втория, и резултатът е друг. Тест, който зависи от друг, е капан. Затова сметката се създава наново преди всеки тест, в beforeEach:

account.test.js
import { test, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { Account } from "./account.js";
let account;
beforeEach(() => {
account = new Account(100);
});
test("теглене на 40 € оставя 60 €", () => {
account.withdraw(40);
assert.equal(account.balance, 60);
});
test("теглене на 70 € оставя 30 €", () => {
account.withdraw(70);
assert.equal(account.balance, 30);
});
Изход
ОК теглене на 40 € оставя 60 €
ОК теглене на 70 € оставя 30 €
Тестове: 2, минали: 2, паднали: 0
beforeEach от node:test се вика преди всеки тест във файла. Името на теста е текст, затова може да е цяло изречение.
По-точно, за любопитните
Още неща от node:test, които ще срещнеш:
node:test
beforeEach / afterEach преди / след всеки тест (почистване)
before / after веднъж преди / след всички тестове
describe("...", () => { ... }) група свързани тестове
test.skip("...", () => { ... }) тестът временно не се пуска
test.todo("...") тест, който още не е написан
test("...", { timeout: 5000 }, () => { ... })
пада, ако продължи повече от 5 секунди
node --test --test-name-pattern="теглене"
само тестовете с „теглене“ в името

Идеята

Една проверка, много случаи

Четири случая за отстъпката значат четири почти еднакви теста. Понеже test е обикновена функция, викаш я в цикъл: по един тест за всеки случай „цена, процент, очаквано“:

discount.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyDiscount } from "./discount.js";
const cases = [
[50, 10, 45],
[99, 0, 99],
[100, 10, 85],
[200, 20, 150],
];
for (const [price, percent, expected] of cases) {
test(`${price} € с ${percent}% = ${expected} €`, () => {
assert.equal(applyDiscount(price, percent), expected);
});
}
Изход
ОК 100 € с 10% = 85 €
ОК 200 € с 20% = 150 €
ОК 50 € с 10% = 45 €
ОК 99 € с 0% = 99 €
Тестове: 4, минали: 4, паднали: 0

Всеки случай е отделен тест с отделен резултат, а името му се прави от стойностите с шаблонен низ. Нов случай е нов ред в масива, не нов тест. Важните случаи са на границите: точно 100 €, 99 €, 0%.

Пробвай

Намери грешката по теста

Доставката е 4 €, а от 50 € нагоре е безплатна. Тестът:

delivery.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { deliveryPrice } from "./delivery.js";
for (const [total, expected] of [[20, 4], [50, 0], [80, 0]]) {
test(`поръчка за ${total} €: доставка ${expected} €`, () => {
assert.equal(deliveryPrice(total), expected);
});
}
Изход
ОК поръчка за 20 €: доставка 4 €
ОК поръчка за 80 €: доставка 0 €
ПАДА поръчка за 50 €: доставка 0 €: 4 !== 0
Тестове: 3, минали: 2, паднали: 1

На кой ред в кода е грешката?

Избери реда с грешката.

Идеята

Първо тестът

Касата трябва да приема промокод ESEN10 за 10%, без значение дали е с малки или главни букви. Този път пишеш тестовете първо:

promo.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { promoPercent } from "./promo.js";
test("известен код", () => {
assert.equal(promoPercent("ESEN10"), 10);
});
test("кодът не зависи от малки и главни букви", () => {
assert.equal(promoPercent("esen10"), 10);
});
test("непознат код", () => {
assert.equal(promoPercent("NIKAKAV"), 0);
});
  1. Червено. Функцията засега връща 0. Тестовете падат, и това е добре: значи наистина проверяват нещо.
export function promoPercent(code) {
return 0;
}
Изход
ОК непознат код
ПАДА известен код: 0 !== 10
ПАДА кодът не зависи от малки и главни букви: 0 !== 10
Тестове: 3, минали: 1, паднали: 2
  1. Зелено. Пишеш най-простия код, с който минават:
export function promoPercent(code) {
if (code.toUpperCase() === "ESEN10") {
return 10;
}
return 0;
}
Изход
ОК известен код
ОК кодът не зависи от малки и главни букви
ОК непознат код
Тестове: 3, минали: 3, паднали: 0
  1. Подреждане. Сега можеш да изнесеш кодовете в Map, да добавиш нови и да подредиш функцията. Тестовете ще кажат, ако счупиш нещо.

Това е TDD (test-driven development, разработка, водена от тестовете). Тестът първо те кара да решиш какво точно трябва да прави функцията, преди да мислиш как.

По-точно

Сюит и фалшиви обекти

В node:test група от свързани тестове е describe: тестовете вътре се показват заедно, под името на групата. Тестовете на доставката, събрани в група:

delivery.test.js
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { deliveryPrice } from "./delivery.js";
describe("доставка", () => {
for (const [total, expected] of [[20, 4], [50, 0], [80, 0]]) {
test(`поръчка за ${total} €: доставка ${expected} €`, () => {
assert.equal(deliveryPrice(total), expected);
});
}
});

Сюит от няколко файла е просто команда, която ги пуска заедно. Ето трите файла на касата:

node --test discount.test.js account.test.js delivery.test.js
Изход
ОК 10% от 50 €
ОК още 5% от 100 € нагоре
ОК поръчка за 20 €: доставка 4 €
ОК поръчка за 50 €: доставка 0 €
ОК поръчка за 80 €: доставка 0 €
ОК теглене на 40 € оставя 60 €
ОК теглене на 70 € оставя 30 €
Тестове: 7, минали: 7, паднали: 0
По-точно, за любопитните
  • Понякога кодът зависи от нещо, което в тест не искаш да викаш: курс на валута от банка, плащане, имейл. Тогава подаваш фалшив обект (mock), който връща каквото му кажеш. В node:test има mock.fn(): функция, която връща каквото ѝ кажеш и помни как е извикана:
converter.test.js
import { test, mock } from "node:test";
import assert from "node:assert/strict";
class Converter {
constructor(rates) {
this.rates = rates;
}
toUsd(eur) {
return eur * this.rates.eurTo("USD");
}
}
test("превръща по днешния курс", () => {
const rates = { eurTo: mock.fn(() => 1.1) };
const converter = new Converter(rates);
assert.ok(Math.abs(converter.toUsd(10) - 11) < 0.001);
assert.equal(rates.eurTo.mock.callCount(), 1);
assert.deepEqual(rates.eurTo.mock.calls[0].arguments, ["USD"]);
});
Изход
ОК превръща по днешния курс
Тестове: 1, минали: 1, паднали: 0

Фалшивият обект е обикновен обект с метод eurTo. mock.callCount() казва колко пъти е извикан, а mock.calls[0].arguments с какво.

  • Още проверки освен assert.equal, assert.ok и assert.throws:
Проверки
assert.notEqual(a, b) различни ли са
assert.deepEqual(a, b) масиви и обекти с еднакво съдържание
assert.notDeepEqual(a, b)
assert.ok(x) условието е вярно
assert.match(text, /израз/) текстът отговаря на регулярен израз
assert.throws(() => ...) кодът хвърля изключение
assert.rejects(async () => ...) обещанието завършва с грешка
assert.fail("съобщение") тестът пада веднага

Упражнение

Напиши тестовете

Реши задачите в редактора си, с node:test (той е в самия Node).

1. Калкулаторът на отстъпки. Напиши discount.test.js за applyDiscount от тази карта. Покрий тези случаи: 80 € с 10% са 72 €, 100 € с 0% са 95 €, 150 € с 20% са 112,50 €, 0 € с 30% са 0 € и точно 100 € с 10% са 85 €. Пусни ги и срещу счупената версия с price > 100. Трябва да паднат точно двата случая с 100 €:

Резултат срещу счупената версия
ОК 0 € с 30% = 0 €
ОК 150 € с 20% = 112.5 €
ОК 80 € с 10% = 72 €
ПАДА 100 € с 0% = 95 €: 100 !== 95
ПАДА точно 100 € получава още 5%: 90 !== 85
Тестове: 5, минали: 3, паднали: 2

2. Сметката. Напиши account.test.js за Account от картата „Да или не, и очаквано изключение“. Създавай сметката с 100 € в beforeEach. Провери, че:

  • теглене на 30 € оставя 70 €;
  • може да се изтегли всичко и остава 0;
  • теглене на 150 € хвърля InsufficientFundsError със съобщение „Не стигат 50 €“, а балансът остава 100 €;
  • 0 и -20 € хвърлят RangeError (тестове в цикъл по двете суми).

3. Бъгът в средната оценка. Функцията изглежда наред:

grades.js
export function average(grades) {
let total = 0;
for (const grade of grades) {
total += grade;
}
return Math.trunc(total / grades.length);
}
Напиши два теста: средното на 4 и 6 е 5, а средното на 5 и 6 е 5,5. Тук assert.equal стига: 5,5 се пази в двоичен вид точно. Единият пада:
Резултат
ОК цяло средно
ПАДА средно с дробна част: 5 !== 5.5
Тестове: 2, минали: 1, паднали: 1

Намери причината и поправи функцията, така че и двата да минат.

Тест

Провери се

1. Какво проверява един unit тест?
2. assert.equal(applyDiscount(100, 10), 85) пада с „90 !== 85“. Колко върна функцията?
3. Защо сметката се създава в beforeEach, а не веднъж най-горе във файла?
4. Как проверяваш, че withdraw(130) хвърля InsufficientFundsError?
5. Кой е редът при TDD?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·