Как легко и надежно реализовать модульные тесты на Python

Что такое тесты

Тестирование кода  —  простой и интуитивно понятный процесс. Он заключается в создании и запуске тестов параллельно с разработкой основного кода. Это позволяет убедиться в том, что написанный код работает должным образом. Каждый так или иначе тестирует код. Одни это делают лучше, другие  —  хуже.

Большинство разработчиков запускают быстрые тесты в терминале или используют в качестве тестировщика сочетание assert-операторов и print-инструкций. Однако есть более эффективные способы. Поговорим о них позже, а сначала обсудим, почему так важно делать тесты.

Зачем нужны тесты

Составление тестов одновременно с написанием кода побуждает серьезнее отнестись к разрабатываемому проекту. В ходе такого параллельного процесса можно тщательнее продумывать цели и задачи каждой части программы, все входные и выходные данные. Тестирование  —  сильнейший стимул к написанию более эффективного кода, от начала и до конца.

Кроме того, тесты помогают сэкономить много времени. С ними не придется тратить бесконечные часы на отладку. Неудивительно, что опытные разработчики относятся к написанию тестов как к инвестициям в свое время. Отдача от таких вложений не заставит себя ждать: как только что-то пойдет не так в процессе написания кода, тесты сразу укажут на проблему. Это особенно важно при работе с длинными и сложными функциями.

Большой плюс тестов заключается в их воспроизводимости. С ними можно поступать точно так же, как с функциями: копировать из одного проекта и вставлять в другой.

Можно даже написать тесты перед функциями! Такая концепция получила название “разработка через тестирование” (test-driven-development, TDD).

Как используются тесты

Модульное и интеграционное тестирование

Существуют два основных типа тестов.

  • Модульное тестирование  —  проверка каждого блока или компонента, чаще всего функции, по отдельности.
  • Интеграционное тестирование  —  проверка того, как все компоненты кода сочетаются друг с другом.

Следующая часть посвящена модульному тестированию. Начнем с базового примера. Возьмем функцию, представленную ниже:

def add(x,y):
return x + y

Тогда пример модульного теста для нее будет следующим:

assert add(2,4) == 6, "Should be 6"

Ключевое слово assert позволяет проверить, возвращает ли условие в коде значение True. Если нет, то программа выдаст AssertionError. Будьте осторожны со скобками, поскольку assert  —  это утверждение.

Если запустить этот тест в терминале Python, ничего не произойдет, так как 2+4 на самом деле равно 6. Попробуем снова, заменив 6 на 7, и, как и было обещано, получим AssertionError.

Изображение автора

Появилось сообщение об ошибке, которое последовало за утверждением assert.

Как видите, тестирование повышает эффективность и организованность работы.

Тестовые случаи, наборы и системы

Различают несколько типов тестов. Рассмотрим каждый из них.

Тестовый случай  —  это проверка конкретного ввода и/или вывода. Оператор assert является примером тестового случая. Мы проверяем, что в случае ввода 2+4 получим вывод 6.

Если сгруппировать вместе множество тестовых случаев, получится тестовый набор. Во многих ситуациях стоит использовать множество похожих тестовых случаев вместе.

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

Среди многочисленных систем выполнения тестов моим фаворитом является встроенный в Python тестировщик Unittest. Познакомимся с ним поближе.

Unittest

Хотя Unittest имеет ряд требований, он довольно прост, гибок и легок в использовании.

Приступая к работе с Unittest, поместите все тесты в классы в виде методов. Затем замените ключевое слово assert на специальные методы утверждения, унаследованные от класса unittest.TestCase.

В качестве примера можно взять рассматриваемую выше функцию. Создайте новый файл под названием tests.py  —  это стандартное соглашение. Сохраните функцию add в папке functions на том же уровне, что и test.py.

Изображение автора
import unittest

import functions

class TestAdd(unittest.TestCase):
     def test_add(self):
          self.assertEqual(functions.add(2, 4), 6)

if __name__ == '__main__':
    unittest.main()

Выполните следующие действия.

  1. Импортируйте unittest в качестве стандарта.
  2. Создайте класс TestAdd, который наследуется от класса TestCase.
  3. Измените тестовые функции на методы.
  4. Измените утверждения, чтобы использовать метод self.assertEqual() в классе TestCase. Полный список доступных методов приведен ниже.
  5. Измените точку входа командной строки на вызов unittest.main().
Полный список assert-методов Unittest

Теперь при запуске test.py в терминале появится следующее.

Изображение автора

Каждая точка над пунктирной линией представляет собой выполненный тест. Если в ходе тестирования обнаружится ошибка, то точка будет заменена на E или F.

К примеру, если заменить 6 на 7, получится следующее:

Изображение автора
Изображение автора

Конечно, здесь был запущен только один тест, и было известно, где произойдет сбой. Однако при наличии нескольких тестов будет так же легко определить, где код потерпел неудачу, поскольку система-тестировщик точно укажет место сбоя.

Как писать хорошие тесты

Выбирайте правильные имена

Этот тест не будет выполняться:

class TestAdd(unittest.TestCase):
     def add_test(self):
          self.assertEqual(functions.add(4, 2), 7)

Методы тестирования должны начинаться с test. test-add будет выполняться, а add test  —  нет. Если определить метод, который не начинается с test, он автоматически завершится. А, возможно, и вовсе не выполнится.

Однако гораздо хуже иметь тест, который вы ошибочно считаете прошедшим, чем тест, который не запустился. Это может сбить с толку при исправлении ошибок. Кроме того, не бойтесь длинного названия, будьте конкретны. Это значительно облегчит поиск ошибки.

Начинайте с простых интуитивных тестов, постепенно увеличивая их количество

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

Учитывайте нестандартные и пограничные случаи

Начиная писать код, я никогда не исключаю нестандартных ситуаций. Возьмем, например, работу с числами. Что произойдет, если ввести отрицательные значения? Или числа с плавающей запятой? А пограничное число  —  нуль? Нули частенько взламывают код, поэтому никогда не помешает протестировать числовой код.

Проверим несколько подобных примеров:

class TestAdd(unittest.TestCase):
     def test_add(self):
          self.assertEqual(functions.add(4, 2), 7)
          self.assertEqual(functions.add(-1, 1), 0)
          self.assertEqual(functions.add(-1, -1), -2)
          self.assertEqual(functions.add(0, -1), -1)

Похоже, с кодом все в порядке!

Делайте каждый тест независимым

Тесты никогда не должны зависеть друг от друга. Встроенная в Unittest функциональность не допускает этого. Методы setUp() и tearDown() позволяют определять инструкции, которые будут выполняться до и после каждого метода тестирования. Имейте в виду: Unittest не гарантирует того, что тесты будут выполняться в указанном вами порядке.

Избегайте Assert.IsTrue

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

AssertionError: 6 != 7

Этот код намного проще отладить, чем следующий.

Expected True, but the actual result was False

Вывод

Иногда тесты пропускают некоторые недоработки  —  это нормально. Просто вернитесь назад и добавьте новый тест, чтобы не пропустить ошибку в следующий раз.

Рассмотрим пример функции, которая округляет число и прибавляет десять. Ничего необычного.

def round_plus_ten(x):
     x = round(x) + 10

     return x

И этот набор тестов:

class TestRound(unittest.TestCase):
     def test_round(self):
          self.assertEqual(functions.round_plus_ten(4.3), 14)
          self.assertEqual(functions.round_plus_ten(4.7), 15)
          self.assertEqual(functions.round_plus_ten(4.5), 15)

Глядя на эту запись (если вы еще не знаете всех тонкостей метода round), можно предположить, что все тесты пройдут успешно. Но это не так.

Изображение автора
Изображение автора

Как видно из отчета системы выполнения тестов, округление 4,5 не равно пяти. Таким образом, результат сложения с 10 не будет равен 14. Метод round округляет вниз, а не вверх. Такие маленькие ошибки иногда могут вывести из строя всю программу, и, как уже говорилось выше, вы даже ничего не заподозрите.

Из этого примера следует извлечь два урока.

  1. Нельзя предусмотреть все проблемы, способные вызвать сбой программы, но тестирование повышает шанс их избежать.
  2. Если что-то упустили, вернитесь к тест-файлу и напишите новое утверждение для этого случая. Так вы не пропустите его снова в будущем (в том числе в других проектах, в которые будут скопированы функция и тест-файл).

Помните, что хороший класс теста также полезен для настоящих и будущих проектов, как и хорошая функция.

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи James Asher: How To Easily And Confidently Implement Unit Tests In Python

Предыдущая статьяТоп-5 новых функций JavaScript ES12, которые облегчат вам жизнь
Следующая статьяСовместное использование компонентов React с Webpack 5