Немного очевидностей
СкопированоПишите тесты для кода. При написании тестов вы глубже анализируете поведение приложения. Тест документирует поведение кода понятным для коллег-разработчиков языком. Приложение становится надёжным и гибким. Рефакторинг не причиняет боли. Тесты на CI позволяют всей команде спать спокойно. Тесты на git pre не дают запушить сломанный код в репозиторий. Зелёные галочки успокаивают.
Как начать писать тесты?
СкопированоСначала нужно понять какие именно тесты вы хотите написать и выбрать подходящий для них фреймворк. Разобраться в тестах и фреймворках помогут эти статьи:
Если вы не любите читать, но любите смотреть, предлагаем три коротких видео:
В них показано всё, что будем делать.
Напишем несколько тестов для разных кусочков платформы Доки.
Для тестов будем использовать Jest.
Настраиваем Jest
СкопированоУ фреймворка Jest отличная документация, в которой можно найти всю необходимую информацию по настройке.
Чтобы правильно настроить Jest на платформе Доки, нужно научить его выполнять тесты для двух разных окружений:
- для браузера, чтобы тестировать странички Доки;
- для Node.js, чтобы тестировать сборку платформы Доки.
Хорошие новости: Jest может поддерживать различные окружения. Кроме этого нам понадобится специальный трансформер — babel-jest, который поможет удобно использовать как нативные ES модули, так и старый-добрый CommonJS.
Итоговый файл конфигурации будет выглядеть так:
module.exports = { testEnvironment: 'jest-environment-node', setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], transform: { '\\.[jt]sx?$': 'babel-jest', },}
module.exports = {
testEnvironment: 'jest-environment-node',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
transform: {
'\\.[jt]sx?$': 'babel-jest',
},
}
Его нужно положить в корень проекта и назвать jest.config.js.
Запускаем тесты, которых пока нет
СкопированоЧтобы запустить тесты, создадим отдельную команду в файле package.json нашей платформы:
{ "scripts": { "test": "jest" }}
{
"scripts": {
"test": "jest"
}
}
В реальных приложениях конфигурация тестов более затейливая. Может понадобиться несколько команд для запуска разных тестов или придётся запускать их с разными параметрами.
Пишем первый тест
СкопированоПротестируем функцию форматирования заголовков. Код функции выглядит так:
function titleFormatter(segments) { return segments.filter(Boolean).join(' — ')}
function titleFormatter(segments) {
return segments.filter(Boolean).join(' — ')
}
Нужно убедиться что эта функция… форматирует заголовки 😁 Для этого не нужно думать, нужно просто написать тест.
Создадим папку tests где-нибудь поближе к файлу с функцией форматирования заголовков и добавим в неё первый тест.
// src/libs/__tests__/title-formatter.jsimport { titleFormatter } from '../title-formatter/title-formatter'describe('titleFormatter', () => { it('форматирует заголовки', () => { const formattedTitle = titleFormatter(['test', 'test2']) expect(formattedTitle).toEqual('test — test2') })})
// src/libs/__tests__/title-formatter.js
import { titleFormatter } from '../title-formatter/title-formatter'
describe('titleFormatter', () => {
it('форматирует заголовки', () => {
const formattedTitle = titleFormatter(['test', 'test2'])
expect(formattedTitle).toEqual('test — test2')
})
})
Запускаем:
npm run test
npm run test
Весёлые зелёные галочки сообщают, что все получилось.

Если вы хотите перезапускать тесты по мере изменения кода, используйте флаг -:
npm run test -- --watch
npm run test -- --watch
Возможно вы задаётесь вопросом: зачем писать тест для такой простой функции? Или думаете «Хм, написать семь строчек кода чтобы проверить однострочную функцию это не продуктивно». Представьте себе что кто-то решил изменить функцию и добавить к ней ещё один параметр, например вот так:
function titleFormatter(separator = ' — ', segments) { return segments.filter(Boolean).join(separator)}
function titleFormatter(separator = ' — ', segments) {
return segments.filter(Boolean).join(separator)
}
Тесты сразу же начнут падать. Это заставит ваших коллег проверить везде ли используется правильная сигнатура этой функции. Семь строк кода защитят от ошибки Uncaught TypeError в приложении.
Попробуем что-то посложнее
СкопированоДля второго упражнения попробуем потестировать функционал поиска. Он живёт в файле src/scripts/core/search-api-client.js платформы доки. Будет тестировать функцию search.
Посмотрим, что делает функция.
search(query, filters = []) { let url = new URL(this.url) let params = new URLSearchParams(url.search) params.append('search', query.replaceAll('+', '%2B').replaceAll('-', '%2D')) filters.forEach((f) => { params.append(f.key, f.val) }) return fetch(url.toString() + '?' + params.toString(), { method: 'POST', headers: { Accept: 'application/json', Origin: 'https://doka.guide', }, }).then((response) => response.json())}
search(query, filters = []) {
let url = new URL(this.url)
let params = new URLSearchParams(url.search)
params.append('search', query.replaceAll('+', '%2B').replaceAll('-', '%2D'))
filters.forEach((f) => {
params.append(f.key, f.val)
})
return fetch(url.toString() + '?' + params.toString(), {
method: 'POST',
headers: {
Accept: 'application/json',
Origin: 'https://doka.guide',
},
}).then((response) => response.json())
}
Метод search использует асинхронную функцию fetch. Это нужно будет учесть в тесте. Первые шаги уже понятны: создаём папку tests, закидываем в неё search-api-client.js. Так как поиск асинхронный, тест тоже будет асинхронный.
import searchClient from '../core/search-api-client.js'describe('searchClient', () => { it('должен что-то искать', async () => { const searchResult = await searchClient.search('test') const expected = { title: 'Как и зачем писать тесты', link: '/tools/how-to-test-and-why/', category: 'tools', } expect(searchResult).toEqual(expected); })})
import searchClient from '../core/search-api-client.js'
describe('searchClient', () => {
it('должен что-то искать', async () => {
const searchResult = await searchClient.search('test')
const expected = {
title: 'Как и зачем писать тесты',
link: '/tools/how-to-test-and-why/',
category: 'tools',
}
expect(searchResult).toEqual(expected);
})
})
Запустим тест. Он упадёт. Пока это ожидаемое поведение.

Похоже, тестирующая функция ничего не знает о существовании функции fetch. Есть несколько способов решить эту проблему. Например, можно добавить в тестовое окружение полифил для функции fetch и делать реальные запросы к API Доки. При этом мы не сможем запускать наши тесты в оффлайн-режиме и будем привязаны к конкретной реализации API. Для некоторых систем это абсолютно нормально, но для нашего простого случая поступим иначе – определим функцию fetch прямо внутри теста.
import searchClient from '../core/search-api-client.js'describe('searchClient', () => { it('должен что-то искать', async () => { global.fetch = jest.fn(() => Promise.resolve(42)) const searchResult = await searchClient.search('test') const expected = { title: 'Как и зачем писать тесты', link: '/tools/how-to-test-and-why/', category: 'tools', } expect(searchResult).toEqual(expected) })})
import searchClient from '../core/search-api-client.js'
describe('searchClient', () => {
it('должен что-то искать', async () => {
global.fetch = jest.fn(() => Promise.resolve(42))
const searchResult = await searchClient.search('test')
const expected = {
title: 'Как и зачем писать тесты',
link: '/tools/how-to-test-and-why/',
category: 'tools',
}
expect(searchResult).toEqual(expected)
})
})
Наша заглушка для fetch всегда возвращает Promise, который резолвится числом 42. Тест по-прежнему не проходит.

На этот раз Jest не доволен значением, c которым резолвится промис. В Доке есть статья, которая подскажет, что же должен возвращать fetch. Прочтём её и уверенно поправим тест:
describe('searchClient', () => { it('должен что-то искать', async () => { const expectedResult = { title: 'Как и зачем писать тесты', link: '/tools/how-to-test-and-why/', category: 'tools', } const json = jest.fn(() => Promise.resolve(expectedResult)) global.fetch = jest.fn(() => Promise.resolve({ json, }) ) const searchResult = await searchClient.search('test') expect(searchResult).toEqual(expectedResult) })})
describe('searchClient', () => {
it('должен что-то искать', async () => {
const expectedResult = {
title: 'Как и зачем писать тесты',
link: '/tools/how-to-test-and-why/',
category: 'tools',
}
const json = jest.fn(() => Promise.resolve(expectedResult))
global.fetch = jest.fn(() =>
Promise.resolve({
json,
})
)
const searchResult = await searchClient.search('test')
expect(searchResult).toEqual(expectedResult)
})
})
Запускаем тест и видим, что он проходит.

Осталось разобраться с двумя непонятностями:
- Что вообще мы тестируем?
- Зачем нужен этот странный
jest?. fn ( )
Полезное упражнение попробовать пересказать тест словами. Сейчас мы проверяем, что функция search возвращает ожидаемое значение при условии, что глобальная функция fetch работает так, как это определили. В текущей реализации поиск всегда будет возвращать одно и то же значение для любых запросов. Это не то, как работает поиск на самом деле.
Давайте добавим дополнительную проверку, чтобы убедиться, что используется правильный URL для поиска. Заодно разберёмся c jest. Эта функция позволяет заменить (замокать) реализацию модулей или функций. Она следит за тем, сколько раз и с какими параметрами была вызвана функция и предоставляет удобный доступ к этой информации. Например, можем проверить, что вызвали fetch только один раз expect. Или посмотреть что параметр запроса передаётся так как нужно. Получился вот такой тест:
describe('searchClient', () => { it('должен что-то искать', async () => { const expectedResult = { title: 'Как и зачем писать тесты', link: '/tools/how-to-test-and-why/', category: 'tools', } const json = jest.fn(() => Promise.resolve(expectedResult)) global.fetch = jest.fn(() => Promise.resolve({ json, }) ) const searchResult = await searchClient.search('test') expect(searchResult).toEqual(expectedResult) expect(global.fetch.mock.calls[0][0]).toContain('search=test') })})
describe('searchClient', () => {
it('должен что-то искать', async () => {
const expectedResult = {
title: 'Как и зачем писать тесты',
link: '/tools/how-to-test-and-why/',
category: 'tools',
}
const json = jest.fn(() => Promise.resolve(expectedResult))
global.fetch = jest.fn(() =>
Promise.resolve({
json,
})
)
const searchResult = await searchClient.search('test')
expect(searchResult).toEqual(expectedResult)
expect(global.fetch.mock.calls[0][0]).toContain('search=test')
})
})
И он проходит 🥳

И ещё один маленький тест
СкопированоТеперь потренируемся писать тесты для функций работы с DOM (Document Object Model). Будем тестировать функцию init в файле article-aside.js репозитория платформы. Внутри эта функция использует объект header, который является чем-то вроде EventEmitter. Навешиваем на header два обработчика событий: fixed и unfixed. Меняем класс нашего компонента в момент когда одно из этих событий происходит.
Мы чуть-чуть изменили изначальный файл. Добавили в него ключевое слово export перед функцией init, чтобы её можно было тестировать.
Если приходится изменять код под тесты, обычно это значит, что делаете что-то не то или что код написан не совсем правильно. Нам пришлось дописать export. Это значит, что:
- функцию
initтестировать не нужно;( ) - забыли экспортировать функцию
init.( )
Давайте предположим, что верно второе утверждение. Так выглядит файл, который будем тестировать:
// article-aside.jsimport headerComponent from './header.js'export function init() { const articleAside = document.querySelector('.article__aside') if (!(articleAside && headerComponent)) { return } const activeClass = 'article__aside--offset' headerComponent.on('fixed', () => { articleAside.classList.add(activeClass) }) headerComponent.on('unfixed', () => { articleAside.classList.remove(activeClass) })}
// article-aside.js
import headerComponent from './header.js'
export function init() {
const articleAside = document.querySelector('.article__aside')
if (!(articleAside && headerComponent)) {
return
}
const activeClass = 'article__aside--offset'
headerComponent.on('fixed', () => {
articleAside.classList.add(activeClass)
})
headerComponent.on('unfixed', () => {
articleAside.classList.remove(activeClass)
})
}
Напишем первую версию теста:
import { init } from './article-aside.js'describe('article-aside', () => { it('должен работать', () => { expect(init).toBeDefined() })})
import { init } from './article-aside.js'
describe('article-aside', () => {
it('должен работать', () => {
expect(init).toBeDefined()
})
})
Казалось бы, этот тест точно должен проходить, однако получаем ошибку.

Тест ругается на то, что переменная document не определена. Но подождите… у нас же нет никакого документа в файле, который мы тестируем. Мы даже не выполнили функцию init.
Мы столкнулись с эффектом при импорте. При первом импорте модуля, JS-движок выполняет код этого модуля. В нашем случае article-aside.js импортирует что-то из модуля header.js. Похоже, код в модуле header.js трогает DOM (обращается к переменной document).
Код с эффектами очень сложно тестировать. Более того, ваши коллеги могут даже не подозревать о том, что при импорте какой-то функции из модуля она поменяет DOM. Хорошая практика — избегать чрезмерного использования эффектов в модулях и функциях. Если есть возможность, старайтесь писать чистые, безэффектные функции и модули.
Но вернёмся к тесту. Нужно как-то добавить DOM, чтобы он не падал. Для этого нужно поменять тестовое окружение. Это можно сделать в настройках тестов jest.config.js или использовать специальный doc-комментарий в начале файла с тестом.
/** * @jest-environment jsdom */
/**
* @jest-environment jsdom
*/
Подробнее о разных тестовых окружениях можно почитать в документации Jest про окружения test.
Окружение jsdom позволяет вам эмулировать браузерный контекст в Node.js. Вам становится доступна переменная document, вы можете использовать многие DOM API. Если элемент присутствует в HTML, переданном в jsdom, можете работать с ним точно так же как в браузере.
После добавления нужного комментария тест начнёт проходить. Теперь нужно убедиться, что функция init сработала как нужно. Для этого проверяем, что для элемента с классом article добавился класс article, когда произошло событие fixed. Но как вызвать событие fixed? 🤔
Заглянем в header.js и увидим аж 250 строчек кода. Мы не очень-то хотим разбираться, что делает этот код. Давайте просто заменим настоящий header.js заглушкой (моком). Для этого пригодится магия jest.
jest.mock('../header', () => { const fixed = [] return { on: (eventName, callback) => { if (eventName === 'fixed') { fixed.push(callback) } }, callFixed: () => { fixed.forEach((callback) => callback()) }, }})
jest.mock('../header', () => {
const fixed = []
return {
on: (eventName, callback) => {
if (eventName === 'fixed') {
fixed.push(callback)
}
},
callFixed: () => {
fixed.forEach((callback) => callback())
},
}
})
В качестве первого аргумента передаём путь до модуля, который хотим замокать, а в качестве второго — реализацию этого модуля. Здесь мы эмулируем очень простой EventEmitter, который собирает колбэки в массив и вызывает их как только срабатывает нужное событие. Чтобы событие fixed сработало, нужно вызвать функцию call.
Вместе с моком получится вот такой тест:
/** * @jest-environment jsdom */import { init } from '../article-aside'jest.mock('../header', () => { const fixed = [] return { on: (eventName, callback) => { if (eventName === 'fixed') { fixed.push(callback) } }, callFixed: () => { fixed.forEach((callback) => callback()) }, }})import { callFixed } from '../header'describe('articleAside', () => { it('должен работать', () => { const testDiv = document.createElement('div') testDiv.className = 'article__aside' const classToCheck = `article__aside--offset`; document.body.appendChild(testDiv) init() expect(testDiv.classList.contains(classToCheck)).toBe(false) callFixed() expect(testDiv.classList.contains(classToCheck)).toBe(true) })})
/**
* @jest-environment jsdom
*/
import { init } from '../article-aside'
jest.mock('../header', () => {
const fixed = []
return {
on: (eventName, callback) => {
if (eventName === 'fixed') {
fixed.push(callback)
}
},
callFixed: () => {
fixed.forEach((callback) => callback())
},
}
})
import { callFixed } from '../header'
describe('articleAside', () => {
it('должен работать', () => {
const testDiv = document.createElement('div')
testDiv.className = 'article__aside'
const classToCheck = `article__aside--offset`;
document.body.appendChild(testDiv)
init()
expect(testDiv.classList.contains(classToCheck)).toBe(false)
callFixed()
expect(testDiv.classList.contains(classToCheck)).toBe(true)
})
})
Сначала проверяем, что класс article не добавлен к элементу, потом вызываем call и проверяем, что класс добавлен. Как всегда, не надо думать, надо написать тест!
Запускам-проверяем. Тест проходит 🎉

Итак, мы научились писать простые и сложные тесты, мокать модули и функции, разобрались с окружениями и получили первое представление о том, чем отличается тестируемый код от нетестируемого. Что дальше?
Если в вашем проекте нет тестов, попробуйте добавить хотя бы один. Через некоторое время будете удивляться, как раньше работали без них 🤓 Если нет подходящего проекта, но хочется потренироваться, приносите тесты в платформу Доки.