Тестове с node:test
Проблем
Отстъпката, която се счупи тихо
Правилото на магазина е: отстъпката е в проценти, а при покупка от 100 € нагоре се добавят още 5%. Пишеш функцията и я пробваш на ръка:
// Отстъпка в проценти. От 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));4517050 € с 10% са 45 €, а 200 € с 15% са 170 €. Изглежда наред и го пускаш. След седмица клиент с покупка за точно 100 € плаща 90 € вместо 85 €. Условието е price > 100, а трябва да е price >= 100.
Никой не е пробвал точно 100 €. А при всяка следваща промяна във функцията трябва да пробваш пак всички случаи на ръка.
Идеята
Код, който проверява код
Вместо да гледаш изхода с очите си, пишеш малки функции, които викат твоя код и сравняват резултата с очаквания. Това е тест. Пускаш всички тестове с едно натискане, след всяка промяна, и те казват кое се е счупило.
| Вид | Какво проверява | Пример |
|---|---|---|
| Unit тест | едно малко парче, обикновено една функция, отделно от останалото | applyDiscount(100, 10) връща 85 |
| Интеграционен | как парчетата работят заедно: с база данни, файл, друга услуга | поръчката се записва в базата и се чете обратно |
| End-to-end | цялото приложение, както го ползва човек | клиентът слага в количката, плаща и получава имейл |
Защо си струва:
- грешката се вижда веднага, докато още помниш какво си променил, а не когато клиентът се оплаче;
- тестът показва как се ползва кодът: кой случай какво връща;
- можеш да подреждаш кода смело. Ако тестовете са зелени и след промяната, поведението не се е променило.
По-точно, за любопитните
Има и други видове тестове: функционални (отговаря ли програмата на изискванията), за производителност (колко заявки издържа), за сигурност (има ли пробиви) и на потребителския интерфейс (бутоните, формите). Всички те стъпват на unit тестовете: те са най-много, най-бързи и най-евтини за поддържане.
Освен node:test ще срещнеш Jest и Vitest: отделни библиотеки, особено за код в браузъра. Идеите са същите:test, expect, beforeEach.Виж го
Първият тест
applyDiscount е във файла discount.js, с export пред function, за да може друг файл да я внесе. Тестовете за нея са в отделен файл 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);});Стъпка по стъпка, с думи
- Файлът има два теста. node:test пуска всеки поотделно и пали лампичка до него.
- Тест 1, подготовка: цена 50 € и 10% отстъпка.
- Действие: applyDiscount(50, 10) връща 45.
- Проверка: assert.equal сравнява очакваното 45 с върнатото 45. Еднакви са, тестът минава.
- Тест 2: точно 100 € и 10%. Очакваме 85, защото от 100 € нагоре има още 5%.
- Условието във функцията е price > 100, а 100 не е по-голямо от 100. Функцията връща 90.
- Очаквано 85, върнато 90: тестът пада и казва точно това.
- Поправяш условието на price >= 100 и пускаш тестовете пак.
- Сега 100 минава условието и функцията връща 85.
- Очаквано и върнато съвпадат. И двата теста са зелени.
По-точно, за любопитните
- В проекта кодът и тестовете са файлове един до друг. Тестовите файлове завършват на
.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);});3 по 1,2 в number не е точно 3,6. Дробните числа се пазят в двоичен вид и при сметките се губи малка част. assert.equal сравнява точно, затова тестът пада:
ОК кафеПАДА чай: 3.5999999999999996 !== 3.6Тестове: 2, минали: 1, паднали: 1node:assert няма проверка с допустима разлика, затова я пишеш сам: assert.ok(Math.abs(withVat(3) - 3.6) < 0.001) минава, защото разликата е под 0,001. assert.ok пада, ако условието не е вярно. А 12 !== 12.0 не би паднал: в JavaScript 12 и 12.0 са едно и също число.Идеята
Да или не, и очаквано изключение
Освен равенство често проверяваш условие или че кодът отказва. Ето Account от модула за изключенията, с нов метод canPay:
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; }}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, паднали: 0assert.ok(условие)пада, ако условието не е вярно. За „не е вярно“ сравняваме сfalse.assert.throws(функция, какво)пада, ако функцията не хвърли. Кодът се подава като стрелкова функция:assert.throwsсам я пуска и хваща грешката. Вторият аргумент описва очакваната грешка: тук името и съобщението ѝ.
Вторият тест проверява и нещо, което лесно се забравя: след отказа балансът е същият.
Идеята
Всеки тест започва на чисто
Тук двата теста делят една сметка от 100 €:
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, паднали: 1account е създадена веднъж за целия файл. node:test пуска тестовете по реда във файла, затова тестът за 40 € е първи. На втория са останали 60 €, а той тегли 70 €. Достатъчно е някой да размести тестовете или да пусне само втория, и резултатът е друг. Тест, който зависи от друг, е капан. Затова сметката се създава наново преди всеки тест, в beforeEach:
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, паднали: 0beforeEach от node:test се вика преди всеки тест във файла. Името на теста е текст, затова може да е цяло изречение.По-точно, за любопитните
beforeEach / afterEach преди / след всеки тест (почистване)before / after веднъж преди / след всички тестовеdescribe("...", () => { ... }) група свързани тестовеtest.skip("...", () => { ... }) тестът временно не се пускаtest.todo("...") тест, който още не е написанtest("...", { timeout: 5000 }, () => { ... }) пада, ако продължи повече от 5 секундиnode --test --test-name-pattern="теглене" само тестовете с „теглене“ в иметоИдеята
Една проверка, много случаи
Четири случая за отстъпката значат четири почти еднакви теста. Понеже test е обикновена функция, викаш я в цикъл: по един тест за всеки случай „цена, процент, очаквано“:
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 € нагоре е безплатна. Тестът:
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На кой ред в кода е грешката?
Избери реда с грешката.
Падналият тест казва: при поръчка за точно 50 € доставката е 4 €, а трябва да е 0. Значи 50 не минава условието. total > 50 изключва самото 50. Трябва да е total >= 50. Тестът сочи случая, а кодът показва причината.
Идеята
Първо тестът
Касата трябва да приема промокод ESEN10 за 10%, без значение дали е с малки или главни букви. Този път пишеш тестовете първо:
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);});- Червено. Функцията засега връща 0. Тестовете падат, и това е добре: значи наистина проверяват нещо.
export function promoPercent(code) { return 0;}ОК непознат кодПАДА известен код: 0 !== 10ПАДА кодът не зависи от малки и главни букви: 0 !== 10Тестове: 3, минали: 1, паднали: 2- Зелено. Пишеш най-простия код, с който минават:
export function promoPercent(code) { if (code.toUpperCase() === "ESEN10") { return 10; } return 0;}ОК известен кодОК кодът не зависи от малки и главни буквиОК непознат кодТестове: 3, минали: 3, паднали: 0- Подреждане. Сега можеш да изнесеш кодовете в Map, да добавиш нови и да подредиш функцията. Тестовете ще кажат, ако счупиш нещо.
Това е TDD (test-driven development, разработка, водена от тестовете). Тестът първо те кара да решиш какво точно трябва да прави функцията, преди да мислиш как.
По-точно
Сюит и фалшиви обекти
В node:test група от свързани тестове е describe: тестовете вътре се показват заедно, под името на групата. Тестовете на доставката, събрани в група:
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(): функция, която връща каквото ѝ кажеш и помни как е извикана:
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, паднали: 22. Сметката. Напиши account.test.js за Account от картата „Да или не, и очаквано изключение“. Създавай сметката с 100 € в beforeEach. Провери, че:
- теглене на 30 € оставя 70 €;
- може да се изтегли всичко и остава 0;
- теглене на 150 € хвърля
InsufficientFundsErrorсъс съобщение „Не стигат 50 €“, а балансът остава 100 €; - 0 и -20 € хвърлят
RangeError(тестове в цикъл по двете суми).
3. Бъгът в средната оценка. Функцията изглежда наред:
export function average(grades) { let total = 0; for (const grade of grades) { total += grade; } return Math.trunc(total / grades.length);}assert.equal стига: 5,5 се пази в двоичен вид точно. Единият пада:ОК цяло средноПАДА средно с дробна част: 5 !== 5.5Тестове: 2, минали: 1, паднали: 1Намери причината и поправи функцията, така че и двата да минат.
Тест