Пишем первые тесты

Как добавить тесты в проект? Разберёмся, как настроить Jest для кода, который выполняется Node.js и в браузере.

Время чтения: 15 мин

Немного очевидностей

Скопировано

Пишите тесты для кода. При написании тестов вы глубже анализируете поведение приложения. Тест документирует поведение кода понятным для коллег-разработчиков языком. Приложение становится надёжным и гибким. Рефакторинг не причиняет боли. Тесты на CI позволяют всей команде спать спокойно. Тесты на git pre-commit hook не дают запушить сломанный код в репозиторий. Зелёные галочки успокаивают.

Как начать писать тесты?

Скопировано

Сначала нужно понять какие именно тесты вы хотите написать и выбрать подходящий для них фреймворк. Разобраться в тестах и фреймворках помогут эти статьи:

Если вы не любите читать, но любите смотреть, предлагаем три коротких видео:

В них показано всё, что будем делать.

Напишем несколько тестов для разных кусочков платформы Доки.

Для тестов будем использовать 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

        
        
          
        
      

Весёлые зелёные галочки сообщают, что все получилось.

Консольный вывод запуска теста для форматирование заголовков. Один из одного теста пройден за 1.28 секунд. Рядом с названием теста зелёная галочка, вверху консоли зелёная надпись «Пройден».

Если вы хотите перезапускать тесты по мере изменения кода, используйте флаг --watch:

        
          
          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: Cannot read properties of undefined (reading 'filter') в приложении.

Попробуем что-то посложнее

Скопировано

Для второго упражнения попробуем потестировать функционал поиска. Он живёт в файле 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);
  })
})

        
        
          
        
      

Запустим тест. Он упадёт. Пока это ожидаемое поведение.

Консольный вывод запуска теста для функции поиска. Тест не проходит, выводится ошибка «ReferenceError: функция .fetch() не определена», красная галочка указывает на 27 строчку кода. Вверху консоли красная надпись «Провален».

Похоже, тестирующая функция ничего не знает о существовании функции 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. Тест по-прежнему не проходит.

Консольный вывод запуска теста для функции поиска. Тест не проходит, выводится ошибка «TypeError: .json не является функцией».

На этот раз 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.fn(). Эта функция позволяет заменить (замокать) реализацию модулей или функций. Она следит за тем, сколько раз и с какими параметрами была вызвана функция и предоставляет удобный доступ к этой информации. Например, можем проверить, что вызвали fetch() только один раз expect(global.fetch).toHaveBeenCalledTimes(1). Или посмотреть что параметр запроса передаётся так как нужно. Получился вот такой тест:

        
          
          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 репозитория платформы. Внутри эта функция использует объект headerComponent, который является чем-то вроде EventEmitter. Навешиваем на headerComponent два обработчика событий: 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()
  })
})

        
        
          
        
      

Казалось бы, этот тест точно должен проходить, однако получаем ошибку.

Консольный вывод запуска теста для article-aside. Тест не проходит, выводится сообщение об ошибке: «Jest не может найти DOM». Красная галочка указывает на 258 строчку кода.

Тест ругается на то, что переменная 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 про окружения testEnvironment.

Окружение jsdom позволяет вам эмулировать браузерный контекст в Node.js. Вам становится доступна переменная document, вы можете использовать многие DOM API. Если элемент присутствует в HTML, переданном в jsdom, можете работать с ним точно так же как в браузере.

После добавления нужного комментария тест начнёт проходить. Теперь нужно убедиться, что функция init() сработала как нужно. Для этого проверяем, что для элемента с классом article__aside добавился класс article__aside--offset, когда произошло событие fixed. Но как вызвать событие fixed? 🤔

Заглянем в header.js и увидим аж 250 строчек кода. Мы не очень-то хотим разбираться, что делает этот код. Давайте просто заменим настоящий header.js заглушкой (моком). Для этого пригодится магия jest.mock().

        
          
          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 сработало, нужно вызвать функцию callFixed.

Вместе с моком получится вот такой тест:

        
          
          /** * @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__aside--offset не добавлен к элементу, потом вызываем callFixed и проверяем, что класс добавлен. Как всегда, не надо думать, надо написать тест!

Запускам-проверяем. Тест проходит 🎉

Консольный вывод запуска теста для article-aside. Пройден один тест из одного за 1.47 секунд.

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

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