Фреймворк модульного тестирования
Суть модульного тестирования заключается в проверке небольших изолированных фрагментов кода. Если тест использует внешний ресурс, например сеть или базу данных, он уже не является модульным.
Фреймворки модульного тестирования описывают тесты в человекочитаемом формате, чтобы те, кто не связан с технической сферой, смогли в них разобраться. Однако даже если вы являетесь представителем технической сферы, тесты в формате BDD могут значительно облегчить поиск проблемы.
Например, чтобы протестировать следующую функцию:
function helloWorld() {
return 'Hello world!';
}
Нужно написать тест jasmine spec:
describe('Hello world', () => { ①
it('says hello', () => { ②
expect(helloWorld())③.toEqual('Hello world!'); ④
});
});
① Функция describe(string, function) определяет тестовый набор — набор индивидуальных спецификаций теста.
② Функция it(string, function) определяет отдельную спецификацию теста, которая содержит одно или несколько ожиданий теста.
③ Выражение expect(actual) — это фактическое значение в тесте. В сочетании с Matcher он описывает ожидаемый фрагмент поведения в приложении.
④ Выражение matcher(expected) — это Matcher. Он выполняет логическое сравнение ожидаемого значения с фактическим, переданным функции expect. Если они имеют ложное значение, спецификация не выполняется.
Setup и teardown
В некоторых случаях при тестировании функции необходимо выполнить настройку. Например, создать несколько тестовых объектов. Кроме того, после завершения теста может потребоваться очистка. Например, удаление файлов с жесткого диска.
Эти действия называются setup и teardown (для очистки). В Jasmine есть несколько функций для упрощения этого процесса:
beforeAll
вызывается один раз перед запуском всех спецификаций в тестовом наборе.
afterAll
вызывается один раз после завершения всех спецификаций в тестовом наборе.
beforeEach
вызывается перед каждой спецификацией теста, если функция запущена.
afterEach
вызывается после выполнения каждой спецификации теста.
Использование в Node
В проекте Node файлы модульного теста определяются в папке test
в одной директории с папкой src
:
node_prj
src/
one.js
two.js
test/
one.spec.js
two.spec.js
package.json
Тест содержит файлы спецификации, которые являются модульными тестами для файлов в папке src. В package.json
test
находится в разделе script
.
{
...,
"script": {
"test": "jest" // or "jasmine"
}
}
Если в командной строке запущен npm run test
, тестовый фреймворк jest запустит все файлы спецификации в папке test
и отобразит результат в командной строке.
Переходим к созданию собственного тестового фреймворка, который будет работать на Node.
В нашем тестовом фреймворке будет CLI-часть, с помощью которой он будет запускаться из командной строки. Вторая часть — исходный код тестового фреймворка, который будет находиться в папке lib.
Начнем с создания проекта Node:
mkdir kwuo
cd kwuo
npm init -y
Устанавливаем зависимость chalk, с помощью которой мы будем раскрашивать результаты тестов: npm i chalk
.
Создаем папку lib, в которой будут находиться файлы:
mkdir lib
Создаем папку bin, поскольку фреймворк будет использоваться в качестве инструмента CLI Node:
mkdir bin
Начнем с создания файла CLI.
Создаем файл kwuo в папке bin и добавляем следующее:
#!/usr/bin/env nodeprocess.title = 'kwuo'require('../lib/cli/cli')
Устанавливаем шебанг с указанием на /usr/bin/env, чтобы запускать этот файл без команды node. Заголовок процесса устанавливаем на «kwuo» и запрашиваем файл «lib/cli/cli». Эти действия вызывают файл cli.js, который запускает процесс тестирования.
Переходим к установке и заполнению lib/cli/cli.js.
mkdir lib/cli
touch lib/cli/cli.js
Этот файл находит папку test, получает все файлы из нее и запускает их.
Прежде чем реализовывать «lib/cli/cli.js», нужно установить глобальные переменные. Функции describe, it, expect, afterEach, beforeEach, afterAll и beforeAll используются в тестовых файлах:
describe('Hello world', () => {
it('says hello', () => {
expect(helloWorld()).toEqual('Hello world!');
});
});
Однако ни одна из них не определена в этих файлах. Каким образом файлы и функции работают без ReferenceError? Причина в том, что тестовый фреймворк реализовывает функции и устанавливает их как global
перед запуском тестовых файлов.
Создаем файл index.js
в папке lib:
touch lib/index.js
Здесь мы устанавливаем глобальные переменные и реализуем функции describe
, it
, expect
, afterEach
, beforeEach
, afterAll
и beforeAll
:
// lib/index.js
const chalk = require('chalk')
const log = console.log
var beforeEachs = []
var afterEachs = []
var afterAlls = []
var beforeAlls = []
var Totaltests = 0
var passedTests = 0
var failedTests = 0
var stats = []
var currDesc = {
it: []
}
var currIt = {}
function beforeEach(fn) {
beforeEachs.push(fn)
}
function afterEach(fn) {
afterEachs.push(fn)
}
function beforeAll(fn) {
beforeAlls.push(fn)
}
function afterAll(fn) {
afterAlls.push(fn)
}
function expect(value) {
return {
// Сопоставляем и подтверждаем, что ожидаемые и фактические объекты одинаковы.
toBe: function(expected) {
if (value === expected) {
currIt.expects.push({ name: `expect ${value} toBe ${expected}`, status: true })
passedTests++
} else {
currIt.expects.push({ name: `expect ${value} toBe ${expected}`, status: false })
failedTests++
}
},
// Сопоставляем ожидаемый и фактический результат теста.
toEqual: function(expected) {
if (value == expected) {
currIt.expects.push({ name: `expect ${value} toEqual ${expected}`, status: true })
passedTests++
} else {
currIt.expects.push({ name: `expect ${value} toEqual ${expected}`, status: false })
failedTests++
}
}
}
}
function it(desc, fn) {
Totaltests++
if (beforeEachs) {
for (var index = 0; index < beforeEachs.length; index++) {
beforeEachs[index].apply(this)
}
}
//var f = stats[stats.length - 1]
currIt = {
name: desc,
expects: []
}
//f.push(desc)
fn.apply(this)
for (var index = 0; index < afterEachs.length; index++) {
afterEachs[index].apply(this)
}
currDesc.it.push(currIt)
}
function describe(desc, fn) {
currDesc = {
it: []
}
for (var index = 0; index < beforeAlls.length; index++) {
beforeAlls[index].apply(this)
}
currDesc.name = desc
fn.apply(this)
for (var index = 0; index < afterAlls.length; index++) {
afterAlls[index].apply(this)
}
stats.push(currDesc)
}
exports.showTestsResults = function showTestsResults() {
console.log(`Total Test: ${Totaltests}
Test Suites: passed, total
Tests: ${passedTests} passed, ${Totaltests} total
`)
const logTitle = failedTests > 0 ? chalk.bgRed : chalk.bgGreen
log(logTitle('Test Suites'))
for (var index = 0; index < stats.length; index++) {
var e = stats[index];
const descName = e.name
const its = e.it
log(descName)
for (var i = 0; i < its.length; i++) {
var _e = its[i];
log(` ${_e.name}`)
for (var ii = 0; ii < _e.expects.length; ii++) {
const expect = _e.expects[ii]
log(` ${expect.status === true ? chalk.green('√') : chalk.red('X') } ${expect.name}`)
}
}
log()
}
}
global.describe = describe
global.it = it
global.expect = expect
global.afterEach = afterEach
global.beforeEach = beforeEach
global.beforeAll = beforeAll
global.afterAll = afterAll
В начале работы мы добавили библиотеку chalk, чтобы обозначить неудачные тесты красным цветом, а пройденные — зеленым. Сокращаем console.log до log.
Затем устанавливаем массивы beforeEachs, afterEachs, afterAlls и beforeAlls. beforeEachs
содержит функции, которые вызываются в начале функции it
, к которой он прикреплен. afterEachs
вызывается в конце it
. beforeEachs
и afterEachs
вызываются в начале и конце describe
.
Устанавливаем Totaltests
, которая будет содержать количество запущенных тестов. passTests
содержит количество пройденных тестов, а failTests
— количество неудачных тестов.
stats
собирает статистику каждой функции describe, а curDesc
обозначает текущую функцию describe, запущенную для сбора данных тестирования. currIt содержит запущенную в данный момент функцию it, выполняющую сбор тестовых данных.
Устанавливаем функции beforeEach, afterEach, beforeAll и afterAll, которые передают аргумент функции в соответствующие массивы: afterAll — в массив afterAlls, beforeEach — в beforeEachs и т. д.
Также у нас есть функция expect. Она выполняет тестирование:
expect(56).toBe(56) // Ожидается, что 56 будет равно 56.
expect(func()).toEqual("nnamdi") // Ожидается, что func возвратит строку, которая будет равна "nnamdi".
Функция expect
принимает аргумент для тестирования и возвращает объект, который содержит функции matcher. В данном случае она возвращает объект с функциями toBe
и toEqual
с аргументом, который они используют для сопоставления с аргументом значения, предоставляемым функцией expect. toBe использует ===
для сопоставления аргумента значения с ожидаемым аргументом. toEqual использует ==
для проверки фактического значения с ожидаемым. Функции увеличивают переменные passedTests
и failedTests
в зависимости от результатов теста, а также записывают статистику в переменную currIt. Мы используем только две функции matcher, однако их гораздо больше:
- toThrow
- toBeNull
- toBeFalsy
- и т. д.
Вы также можете реализовать их.
Переходим к функции it
. Аргумент desc содержит имя описания теста, а fn
— функцию. Сначала он обрабатывает beforeEachs, устанавливает статистику и вызывает функцию fn
и afterEachs.
Функция describe
выполняет те же действия, что и it
, но вызывает beforeAlls
и afterAlls
в начале и в конце.
Функция showTestsResults
анализирует массив stats
и печатает пройденные и неудачные тесты на терминале.
Таким образом, мы реализовали и установили все функции в объект global
, чтобы тестовые файлы могли вызывать их без ошибок.
Вернемся к «lib/cli/cli.js»:
// lib/cli/cli.js
const path = require('path')
const fs = require('fs')
const { showTestsResults } = require('./../')
Сначала он импортирует функцию showTestsResult
из «lib/index.js», которая отобразит результат запуска тестовых файлов в терминале. Кроме того, импорт этого файла установит глобальные переменные.
Продолжим:
// lib/cli/cli.js
const path = require('path')
const fs = require('fs')
const { showTestsResults } = require('./../')
/**
* search for test folder
*/
function searchTestFolder() {
if (!fs.existsSync('test/')) {
return false
}
return true
}
/**
* get all test files in the test/ folder
*/
function getTestFiles() {
let f = null
if (f = fs.readdirSync('test/')) {
return f.length == 0 ? null : f
}
}
/**
* run the test files
* @param {*} f
*/
function runTestFiles(f = []) {
f.forEach((g) => {
require(fs.realpathSync(`test/${g}`))
})
showTestsResults()
}
function run() {
if (searchTestFolder()) {
let files;
if (files = getTestFiles()) {
runTestFiles(files)
} else {
console.error('No test files found.')
}
} else {
console.error(`'test/' folder doesn't exist`)
}
}
run()
Функция run
является главной и запускает весь процесс. Она выполняет поиск папки test
с помощью searchTestFolder
, получает тестовые файлы в массиве — getTestFiles
, а затем просматривает массив тестовых файлов и запускает их с помощью runTestFiles
.
searchTestFolder
использует метод fs#existSync
, чтобы проверить, существует ли папка «test/» в проекте.
Функция getTestFiles
читает содержимое папки «test» с помощью метода fs#readdirSync
и возвращает его.
runTestFiles
принимает файлы в массиве, просматривает их с помощью метода forEach и использует метод require
для запуска каждого файла.
Структура папки kwuo выглядит следующим образом:
Тестирование фреймворка
Попробуем протестировать наш фреймворк с реальным проектом Node.
Создаем проект:
mkdir examples
mkdir examples/math
cd examples/math
npm init -y
Создаем папку src и добавляем add.js и sub.js:
mkdir src
touch src/add.js src/sub.js
Содержимое add.js и sub.js:
// src/add.js
function add(a, b) {
return a+b
}
module.exports = add
// src/sub.js
function sub(a, b) {
return a-b
}
module.exports = sub
Создаем папку test и тестовые файлы:
mkdir test
touch test/add.spec.js test/sub.spec.js
Файлы спецификации будут проверять функции add и sub в add.js и sub.js:
// test/sub.spec.js
const sub = require('../src/sub')
describe("Subtract numbers", () => {
it("should subtract 1 from 2", () => {
expect(sub(2, 1)).toEqual(1)
})
it("should subtract 2 from 3", () => {
expect(sub(3, 2)).toEqual(1)
})
})
// test/add.spec.js
const add = require('../src/add')
describe("Add numbers", () => {
it("should add 1 to 2", () => {
expect(add(1, 2)).toEqual(3)
})
it("should add 2 to 3", () => {
expect(add(2, 3)).toEqual(5)
})
})
describe('Concat Strings', () => {
let expected;
beforeEach(() => {
expected = "Hello";
});
afterEach(() => {
expected = "";
});
it('add Hello + World', () => {
expect(add("Hello", "World"))
.toEqual(expected);
});
});
Теперь воспользуемся «test» в разделе «scripts» в package.json
для запуска тестового фреймворка:
{
"name": "math",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "kwuo"
},
"keywords": [],
"author": "Chidume Nnamdi <[email protected]>",
"license": "ISC"
}
Запускаем npm run test
в командной строке:
$ npm run test
Результат теста будет следующим:
В статистике указано количество пройденных тестов в общей сложности и список тестовых наборов с маркировкой пройден/неудачный. «add Hello + World» возвращает «HelloWorld», однако ожидалось «Hello». После исправления и повторного запуска, все тесты будут пройдены.
// test/add.spec.js
...
describe('Concat Strings', () => {
let expected;
beforeEach(() => {
expected = "Hello";
});
afterEach(() => {
expected = "";
});
it('add Hello + World', () => {
expect(add("Hello", ""))
.toEqual(expected);
});
});
Как видите, созданный тестовый фреймворк работает, как Jest и Jasmine.
Код на Github
Здесь можно найти полную версию кода.
Фреймворк можно использовать из NPM:
cd IN_YOUR_NODE_PROJECT
npm install kwuo -D
Измените «test» в package.json на следующее:
{
...
"scripts": {
"test": "kwuo"
...
}
}
Читайте также:
- Представляем объект JS Window - видимость и дочерние элементы
- Лучшие практики JavaScript — производительность
- Лучшие практики JavaScript: переменные
Перевод статьи Chidume Nnamdi ????: Build Your Own JavaScript Testing Framework