Немного очевидностей
СкопированоПишите тесты для кода. При написании тестов вы глубже анализируете поведение приложения. Тест документирует поведение кода понятным для коллег-разработчиков языком. Приложение становится надёжным и гибким. Рефакторинг не причиняет боли. Тесты на 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
и проверяем, что класс добавлен. Как всегда, не надо думать, надо написать тест!
Запускам-проверяем. Тест проходит 🎉
Итак, мы научились писать простые и сложные тесты, мокать модули и функции, разобрались с окружениями и получили первое представление о том, чем отличается тестируемый код от нетестируемого. Что дальше?
Если в вашем проекте нет тестов, попробуйте добавить хотя бы один. Через некоторое время будете удивляться, как раньше работали без них 🤓 Если нет подходящего проекта, но хочется потренироваться, приносите тесты в платформу Доки.