Тестирование
Основная цель тестов — убедиться, что конечный продукт или какая-то его часть работают корректно. Помимо этого есть и другая польза:
- Тесты выступают как документация. Если на какой-то случай есть тест, значит поведение точно должно быть таким. Например, если у функции деления при делении на ноль есть два варианта работы: бросить исключение или вернуть бесконечность. Если теста нет, то непонятно текущее поведение задумывалось или его просто забыли учесть.
- Тесты упрощают рефакторинг — на текущий функционал уже написаны тесты, а значит можно изменять внутреннюю реализацию, не боясь что-то сломать.
- Во многих случаях тесты ускоряют (а не замедляют!) написание кода. Причина в том, что во время написания кода нет необходимости вручную проверять все возможные кейсы работы, чтобы убедиться, что всё работает корректно.
Unit-тесты
Unit-тесты - это тесты, которые тестируют какой-то один изолированный модуль — например, функцию или класс.
Наиболее легкотестируемыми являются чистые функции — функции, которые не имеют сайд-эффектов, получают всё необходимое в своих аргументах и возвращают результат каких-либо вычислений.
Основные преимущества в том, что такие функции не требуют для себя какого-либо cпецифичного окружения — например, браузера. А значит, что такие тесты проще запускать, и, так как внутри выполняются только вычисления, тест проходит быстро.
Сам юнит-тест представляет собой вызов функции и проверку на то, что функция вернула какое-то определенное значение. Иными словами, юнит-тест — это простое сравнение результата с каким-либо эталоном.
it('sums numbers', async () => {
expect(sum(2, 5)).toEqual(7)
})
Проверять необходимо пограничные случаи, желательное и нежелательное поведение.
Например, для функции сложения это будут варианты:
- сложение положительных чисел;
- сложение с нулем;
- сложение отрицательных чисел;
- сложение дробных чисел.
Не стоит стремиться написать тесты на все возможные случаи. Если вы не протестируете кейс, который воспроизводится в одном случае на миллион, то просто сделаете это, когда баг будет найдет. Конечно же, если вы пишите медицинскую или финансовую систему, то стоит постараться исключить и такие баги, но так или иначе все проблемные места вы предсказать не сможете.
Оформлене тестов
Очень важно, как вы пишите тесты. Всегда придерживайтесь правила AAA (или 3A): Arrange, Act, Assert. Каждый ваш тест должен быть визуально разделен на три этапа:
it('calculates intersection of two vectors', async () => {
// Arrange
const vect1 = [...]
const vect2 = [...]
// Act
const result = areVectorsIntersect(vect1, vect2)
// Assert
expect(result).toBeTrue()
})
Конечно, не всегда эти стадии стоит явно выделять в тесте. Если Arrange и Act являются крайне простыми, как в случае с тестом функции sum
, то можно все три стадии записать в одной строке, в ином же случае стоит добавлять пробельную строку между ними для визуального выделения стадий.
Не выполняйте разные проверки в одном тесте:
// плохо
it('sums two numbers', async () => {
expect(sum(10, 20)).toBe(30)
expect(sum(-10, -20)).toBe(-30)
expect(sum(-10, 20)).toBe(10)
expect(sum(10, 20)).not.toBe(42)
})
// хорошо
it('sums two positive numbers', async () => {
expect(sum(10, 20)).toBe(30)
})
it('sums two negative numbers', async () => {
expect(sum(-10, -20)).toBe(-30)
})
it('sums a positive and a negative number', async () => {
expect(sum(-10, 20)).toBe(10)
})
Если в одном из кейсов будет ошибка, то тест, проверяющий все сразу упадет, и не будет понятно, где именно случилась ошибка. Кроме того, бывают случаи, когда бизнес логика меняется и тесты необходимо менять/удалять. В случае с кучей проверок в одном тесте это может быть более проблематично, чем если бы мы делали это для отдельных тестов.
Старайтесь, чтобы описание вашего теста явно отражало, что именно вы проверяете, если ваша функция должна складывать числа, а вы проверяете что она корректно складывает отрицательное и положительное число, то напишите так: it('sums negative and positive numbers', () => { ... })
, - при этом функция it
должна начинать ваше описание так, чтобы получилась нормальная фраза на английском языке: it sums negative and positive numbers
.
Не используйте слово should в описании теста. Оно не описывает то, что делает ваша функция, оно повторяется из теста в тест, что делает его ненужным для описания теста.
// плохо
it('should sum two positive numbers', async () => {
expect(sum(10, 20)).toBe(30)
})
// хорошо
it('sums two positive numbers', async () => {
expect(sum(10, 20)).toBe(30)
})
Таким образом вы избавляетесь от повторяющегося и неважного слова should, оставляя только важную часть описания.
Используйте describe
не только для выделения того юнита, который вы тестируете, но и для выделения контекста тестирования. Допустим у нас есть некий компонент Block
, он может находиться в двух состояниях: видимый и невидимый. Когда наш компонент видим, то он ведет себя одним образом, когда невидим - другим. Мы хотим проверить этом, для этого напишем несколько тестов:
describe('Block', () => {
describe('when it is visible', () => {
it('shows something', () => {})
it('calculates own position', () => {})
})
describe('when it is invisible', () => {
it('hides something', () => {})
it('does not calculate own position', () => {})
})
})
Таким образом наши проверки будут выполняться в двух явных контекстах:
Block
when it is visible
shows something
calculates own position
when it is invisible
hides something
does not calculate own position
Это позволяет избавиться от дублирования в описаниях тестов. Без объединения в блок контекста мы бы написали так:
// плохо
describe('Block', () => {
it('when it visible it shows something', () => {})
it('when it visible it calculates own position', () => {})
it('when it invisible it hides something', () => {})
it('when it invisible it does not calculate own position', () => {})
})
Тесты с блоками контекста проще читаются, легче найти нужный тест, а так же понять какой тест и при каких условиях упал.
Подготовка данных
Важно понимать, что каждый тест должен быть независимым от других тестов, поэтому в каждом отдельном тесте мы заново выполняем этап Arrange, даже если он выполнялся в предыдущих тестах. Не выносите подготовку данных в отдельную переменную, функцию или beforeEach, если данные, которые нужно подготовить, довольно просты:
// плохо
describe('Verification', () => {
const data = {
name: 'test',
email: 'test',
}
describe('verifyName', () => {
it('returns false if name is not valid', () => {
expect(verifyName(data.name).toBeFalsy()
})
})
describe('verifyEmail', () => {
it('returns false if email is not valid', () => {
expect(verifyEmail(data.email).toBeFalsy()
})
})
})
// хорошо
describe('Verification', () => {
describe('verifyName', () => {
it('returns false if name is not valid', () => {
expect(verifyName('test').toBeFalsy()
})
})
describe('verifyEmail', () => {
it('returns false if email is not valid', () => {
expect(verifyEmail('test').toBeFalsy()
})
})
})
В первом случае, подготовка данных была вынесена в beforeEach
, если тестов будет очень много и блок beforeEach
не будет виден разработчику, то судя лишь по одному тесту он не сможет сказать, что проверяет этот тест. Во втором случае тесты полностью самодостаточны. Прочитав код теста можно однозначно сказать какие данные используются в тесте.
Помните, что читаемость теста важнее устранения дублирования, если это дублирование не мешает читаемости. Выносить Arrage в отдельную абстракцию стоит тогда, когда данные имеют сложную структуру и воспроизводить ее в каждом тесте становится неудобно, плюс когда эти данные раздувают тест, что делает его менее читаемым:
describe('Serializer', () => {
const data = {
name: 'test',
age: 20,
email: 'test',
skills: ['programming', 'writing songs'],
bestFriends: [
{ name: 'test1', ... },
{ name: 'test2', ... },
{ name: 'test3', ... },
],
}
describe('after serialization', () => {
it('converts name into serialized name' () => {
expect(serialize(data).name).toBe(...)
})
it('converts bestFriends into serialized bestFriends' () => {
expect(serialize(data).bestFriends).toBe(...)
})
})
})
Здесь мы вынесли сложную инициализацию данных, чтобы сделать тесты более читаемыми
Интеграционные тесты
Интеграционные тесты - это тесты, которые в отличие от unit-тестов тестируют взаимодействие нескольких юнитов, проверяя их совместную работу. Задастую таким образом тестируется некий логический слой, например, http API или работа конкретного компонента.
По сути все правила описанные для unit-теста справедливы и для интеграционного теста, но в случае с интеграционным тестом добавляется необходимость мокировать (от англ. mock) зависимости тестируемой функциональности.
Например, мы хотим убедиться, что функция в определенных условиях отправит данные на сервер, а иначе бросит ошибку, но мы не хотим на самом деле отправлять данные на сервер, здесь нам поможет spy:
describe('for valid data', () => {
it('sends data to a server', () => {
spyOn(api, 'sendData')
const expectedData = { /* то, что мы ожидаем получить в sendData */ }
// представим, что данные в этой функции каким-то образом сериализуются,
// а если данные невалидны, то сериалайзер выбрасывает ошибку
parseAndSendData(api, validData)
expect(api.sendData).toHaveBeenCalledWith(expectedData)
})
})
describe('for invalid data', () => {
it('throws an error and does not call "sendData"', () => {
spyOn(api, 'sendData')
expect(() => parseAndSendData(api, invalidData)).toThrowError('some error')
expect(api.sendData).not.toHaveBeenCalled()
})
})
Таким образом мы проверили, что наша функция отработает ожидаемым образом в необходимых для нас кейсах, но при этом не оправляли данные на сервер.
End to End тестирование (e2e)
Это тестирование непосредственно вашего приложения в том виде, в котором его видят ваши пользователи. В таком тестировании при проверке авторизации на сайте нам нужно будет выполнить именно авторизацию на сайте, кликая по кнопкам и заполняя формы, которые должен заполнять пользователь.
Это самый дорогой в плане реализации и поддержки вид тестов, но при этом он и самый эффективный. Такие тесты следует писать на наиболее важные части системы, либо же на устоявшиеся части системы. Если же вы напишите e2e-тест на постоянно меняющуюся часть, то вам придется тратить много времени на поддержку самого теста в актуальном состоянии. Кроме того, в этом случае у вас может быть много ложных срабатываний, что не отразится полжительно на скорости разработки проекта
Как начать писать тесты в legacy проекте
Часто бывает такое, что разработчики хотят писать тесты на своем проекте, понимают их пользу, но не пишут их по причине того, что проект старый и тестов там никогда не было.
Начните с интеграционных или e2e-тестов. Напишите тесты для наиболее важных частей вашего приложения. Даже если в вашем проекте очень высокая связанность между модулями, то вы все равно сможете без значительных изменений в коде написать высококоуровневые тесты.
Именно они позволят вам начать делать рефакторинг, выделяя тестируемые юниты, которые уже можно покрывать unit-тестами.
Заведите правило на проекте, что новый функционал и баги вы обязательно покрываете тестами.
Таким образом через некоторое время вы обнаружите, что ваш проект уже хорошо покрыт тестами.
Code Coverage
Покрытие кода — это метрика, которая показывает какое количество кода было протестировано. Покрытие может вычисляется по-разному:
- покрытие операторов — каждая ли строчка выполнилась;
- покрытие условий — каждая ли ветка функции была выполнена;
- покрытие функций — каждая ли функция вызывалась.
Не стремитесь к 100% покрытию кода. Где-то на 70-80% покрытия цена на увеличение покрытия становится неоправданно высокой. Вы начинаете тратить слишком много сил на увеличение значения покрытия тестами, но при этом эффективность этих проверок практически не возрастает.
Так же 100% покрытие кода не гарантирует того, что модуль работает корректно. Для покрытия линейной функции достаточно одного теста — все операторы выполняться в этом тесте.
TDD
TDD — техника разработки, в которой сначала пишутся тесты, а затем функция. Тесты пишутся только на какую-то небольшую часть функционала.
В целом процесс разработки выглядит так:
- написать тест для проверки одного кейса разрабатываемого функционала;
- запустить тесты и убедиться, что написанный тест не проходит;
- написать минимально достаточный код для прохождения теста;
- проверить, что проходят все тесты;
- вернуться к первому шагу.
Следовать этой методике довольно сложно и не всегда эффективно. Всегда взвешивайте сложность реализуемого вами функционала, если вы пишите что-то типа функции sum
, то ничего страшного не произойдет, если вы напишите сразу все тесты уже после реализации функции. Но если же вы делаете что-то сложное, то методика test first вам обязательно поможет.
Помните, что какую бы методику написания тестов вы не выбрали, главное - пишите тесты.