Начало работы с тестированием в Python

Оглавление

Этот учебник предназначен для тех, кто написал фантастическое приложение на Python, но еще не написал ни одного теста.

Тестирование на Python - это огромная тема, которая может быть очень сложной, но это не обязательно должно быть трудно. Вы можете начать создавать простые тесты для своего приложения в несколько простых шагов, а затем развивать их дальше.

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

Тестирование вашего кода

Существует множество способов протестировать свой код. В этом уроке вы узнаете о самых простых способах и перейдете к продвинутым методам.

Автоматизированное тестирование против ручного

Хорошая новость заключается в том, что вы, скорее всего, уже создали тест, даже не подозревая об этом. Помните, как вы запустили свое приложение и использовали его в первый раз? Вы проверяли функции и экспериментировали с ними? Это известно как эксплораторное тестирование и является одной из форм ручного тестирования.

Эксплораторное тестирование - это форма тестирования, которая проводится без плана. В исследовательском тестировании вы просто изучаете приложение.

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

Это не похоже на веселье, не так ли?

Именно здесь на помощь приходит автоматизированное тестирование. Автоматизированное тестирование - это выполнение вашего плана тестирования (части приложения, которые вы хотите протестировать, порядок их тестирования и ожидаемые ответы) скриптом вместо человека. Python уже содержит набор инструментов и библиотек, которые помогут вам создать автоматические тесты для вашего приложения. Мы рассмотрим эти инструменты и библиотеки в этом уроке.

Тесты юнитов против интеграционных тестов

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

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

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

Основная проблема интеграционного тестирования - когда интеграционный тест не дает нужного результата. Очень сложно диагностировать проблему, не имея возможности определить, какая часть системы дала сбой. Если свет не включается, то, возможно, сломались лампочки. Может быть, разрядился аккумулятор? А как насчет генератора? Может, компьютер автомобиля вышел из строя?

Если у вас модная современная машина, она сообщит вам о том, что у вас перегорели лампочки. Для этого используется форма юнит-теста.

Юнит-тест - это более мелкий тест, который проверяет правильность работы одного компонента. Юнит-тест помогает вам определить, что именно сломалось в вашем приложении, и быстрее исправить это.

Вы только что познакомились с двумя типами тестов:

  1. Интеграционный тест проверяет, что компоненты вашего приложения работают друг с другом.
  2. Юнит-тест проверяет небольшой компонент в вашем приложении.

На Python можно писать как интеграционные, так и модульные тесты. Чтобы написать модульный тест для встроенной функции sum(), нужно проверить вывод sum() на соответствие известному выводу.

Например, вот как проверить, что sum() из чисел (1, 2, 3) равно 6:

>>> assert sum([1, 2, 3]) == 6, "Should be 6"

Это ничего не выведет в REPL, потому что значения верны.

Если результат из sum() неверен, это приведет к неудаче с ошибкой AssertionError и сообщением "Should be 6". Попробуйте снова выполнить утверждение с неверными значениями, чтобы увидеть AssertionError:

>>> assert sum([1, 1, 1]) == 6, "Should be 6"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError: Should be 6

В REPL вы видите поднятый AssertionError, потому что результат sum() не совпадает с 6.

Вместо того, чтобы тестировать на REPL, вы захотите поместить это в новый Python-файл с именем test_sum.py и выполнить его снова:

def test_sum():
    assert sum([1, 2, 3]) == 6, "Should be 6"

if __name__ == "__main__":
    test_sum()
    print("Everything passed")

Теперь вы написали тестовый пример, утверждение и точку входа (командную строку). Теперь вы можете выполнить это в командной строке:

$ python test_sum.py
Everything passed

Вы видите успешный результат, Everything passed.

В Python sum() принимает в качестве первого аргумента любую итерируемую переменную. Вы протестировали список. Теперь протестируйте и кортеж. Создайте новый файл с именем test_sum_2.py со следующим кодом:

def test_sum():
    assert sum([1, 2, 3]) == 6, "Should be 6"

def test_sum_tuple():
    assert sum((1, 2, 2)) == 6, "Should be 6"

if __name__ == "__main__":
    test_sum()
    test_sum_tuple()
    print("Everything passed")

При выполнении test_sum_2.py скрипт выдаст ошибку, поскольку sum() из (1, 2, 2) является 5, а не 6. В результате выполнения сценария вы получите сообщение об ошибке, строку кода и обратную трассировку:

$ python test_sum_2.py
Traceback (most recent call last):
  File "test_sum_2.py", line 9, in <module>
    test_sum_tuple()
  File "test_sum_2.py", line 5, in test_sum_tuple
    assert sum((1, 2, 2)) == 6, "Should be 6"
AssertionError: Should be 6

Здесь вы можете увидеть, как ошибка в вашем коде выдает ошибку на консоль с некоторой информацией о том, где была допущена ошибка и каков был ожидаемый результат.

Примечание: С помощью doctest можно одновременно документировать и тестировать код, обеспечивая при этом синхронизацию кода и его документации. Посмотрите на doctest в Python: Document and Test Your Code at Once, чтобы узнать больше.

Написание тестов таким образом подходит для простой проверки, но что делать, если несколько тестов не работают? Здесь на помощь приходят прогонщики тестов. Прогонщик тестов - это специальное приложение, предназначенное для запуска тестов, проверки результатов и предоставления инструментов для отладки и диагностики тестов и приложений.

Выбор программы запуска тестов

Для Python существует множество программ для запуска тестов. Один из них, встроенный в стандартную библиотеку Python, называется unittest. В этом уроке вы будете использовать тестовые случаи unittest и прогонщик unittest. Принципы unittest легко переносятся на другие фреймворки. Три наиболее популярных бегуна для тестирования:

  • unittest
  • nose или nose2
  • pytest

Выбор оптимальной программы тестирования в соответствии с вашими требованиями и уровнем опыта очень важен.

unittest

unittest был встроен в стандартную библиотеку Python начиная с версии 2.1. Вы, вероятно, увидите его в коммерческих приложениях Python и проектах с открытым исходным кодом.

unittest содержит как фреймворк для тестирования, так и программу для запуска тестов. unittest имеет некоторые важные требования для написания и выполнения тестов.

unittest требует, чтобы:

  • Вы помещаете свои тесты в классы в виде методов
  • Вы используете серию специальных методов утверждения в классе unittest.TestCase вместо встроенного оператора assert

Чтобы преобразовать предыдущий пример в тестовый пример unittest, вам нужно:

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

Выполните эти шаги, создав новый файл test_sum_unittest.py со следующим кодом:

import unittest


class TestSum(unittest.TestCase):

    def test_sum(self):
        self.assertEqual(sum([1, 2, 3]), 6, "Should be 6")

    def test_sum_tuple(self):
        self.assertEqual(sum((1, 2, 2)), 6, "Should be 6")

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

Если вы выполните это в командной строке, то увидите один успех (обозначенный .) и один провал (обозначенный F):

$ python test_sum_unittest.py
.F
======================================================================
FAIL: test_sum_tuple (__main__.TestSum)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_sum_unittest.py", line 9, in test_sum_tuple
    self.assertEqual(sum((1, 2, 2)), 6, "Should be 6")
AssertionError: Should be 6

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)

Вы только что выполнили два теста с помощью программы unittest test runner.

Примечание: Будьте осторожны, если вы пишете тестовые примеры, которые должны выполняться как в Python 2, так и в Python 3. В Python 2.7 и ниже, unittest называется unittest2. Если вы просто импортируете из unittest, вы получите разные версии с разными возможностями между Python 2 и 3.

Для получения дополнительной информации о unittest вы можете изучить unittest Documentation.

nose

Вы можете обнаружить, что со временем, по мере написания сотен или даже тысяч тестов для вашего приложения, становится все труднее понимать и использовать результаты unittest.

nose совместим с любыми тестами, написанными с использованием фреймворка unittest, и может быть использован в качестве полноценной замены тестового прогонщика unittest. Разработка nose как приложения с открытым исходным кодом отстала, и был создан форк под названием nose2. Если вы начинаете с нуля, рекомендуется использовать nose2 вместо nose.

Чтобы начать работу с nose2, установите nose2 из PyPI и запустите его в командной строке. nose2 попытается обнаружить все тестовые сценарии с именем test*.py и тестовые случаи, наследующие от unittest.TestCase, в вашей текущей директории:

$ pip install nose2
$ python -m nose2
.F
======================================================================
FAIL: test_sum_tuple (__main__.TestSum)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_sum_unittest.py", line 9, in test_sum_tuple
    self.assertEqual(sum((1, 2, 2)), 6, "Should be 6")
AssertionError: Should be 6

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)

Вы только что выполнили тест, созданный в test_sum_unittest.py, из программы nose2 для запуска тестов. nose2 предлагает множество флагов командной строки для фильтрации выполняемых тестов. Для получения дополнительной информации вы можете изучить документацию Nose 2.

pytest

pytest поддерживает выполнение unittest тестовых случаев. Настоящее преимущество pytest достигается при написании pytest тестовых примеров. Тестовые примеры pytest представляют собой серию функций в файле Python, начинающемся с имени test_.

pytest имеет и другие замечательные возможности:

  • Поддержка встроенного оператора assert вместо использования специальных методов self.assert*()
  • Поддержка фильтрации тестовых случаев
  • Возможность повторного запуска с последнего неудачного теста
  • Экосистема из сотен плагинов для расширения функциональности

Написание примера тестового случая TestSum для pytest будет выглядеть следующим образом:

def test_sum():
    assert sum([1, 2, 3]) == 6, "Should be 6"

def test_sum_tuple():
    assert sum((1, 2, 2)) == 6, "Should be 6"

Вы отказались от TestCase, любого использования классов и точки входа в командную строку.

Дополнительную информацию можно найти на сайте Pytest Documentation Website.

Написание первого теста

Давайте соберем воедино все, что вы узнали до сих пор, и вместо тестирования встроенной функции sum() протестируем простую реализацию того же требования.

Создайте новую папку проекта, а внутри нее - новую папку my_sum. Внутри my_sum создайте пустой файл __init__.py. Создание файла __init__.py означает, что папка my_sum может быть импортирована как модуль из родительского каталога.

Папка вашего проекта должна выглядеть следующим образом:

project/
│
└── my_sum/
    └── __init__.py

Откройте my_sum/__init__.py и создайте новую функцию sum(), которая принимает итерабельную переменную (список, кортеж или набор) и складывает их значения:

def sum(arg):
    total = 0
    for val in arg:
        total += val
    return total

В этом примере кода создается переменная total, перебираются все значения в arg и добавляются в total. Затем он возвращает результат, когда итерируемая переменная будет исчерпана.

Где писать тест

Чтобы начать писать тесты, вы можете просто создать файл с именем test.py, который будет содержать ваш первый тестовый пример. Поскольку файл должен иметь возможность импортировать ваше приложение, чтобы его можно было протестировать, вы хотите поместить test.py выше папки package, так что ваше дерево каталогов будет выглядеть примерно так:

project/
│
├── my_sum/
│   └── __init__.py
|
└── test.py

Вы обнаружите, что по мере добавления все большего количества тестов ваш единственный файл станет беспорядочным и сложным для обслуживания, поэтому вы можете создать папку tests/ и разделить тесты на несколько файлов. Условно каждый файл должен начинаться с test_, чтобы все прогонщики тестов считали, что файл Python содержит тесты, которые нужно выполнить. Некоторые очень крупные проекты разделяют тесты на большее количество подкаталогов в зависимости от их назначения или использования.

Примечание: Что делать, если ваше приложение представляет собой один сценарий?

Вы можете импортировать любые атрибуты сценария, такие как классы, функции и переменные, используя встроенную функцию __import__(). Вместо from my_sum import sum можно написать следующее:

target = __import__("my_sum.py")
sum = target.sum

Преимущество использования __import__() в том, что вам не нужно превращать папку проекта в пакет, и вы можете указать имя файла. Это также полезно, если имя вашего файла пересекается с какими-либо пакетами стандартной библиотеки. Например, math.py столкнется с модулем math.

Как построить простой тест

Прежде чем приступить к написанию тестов, вам нужно принять несколько решений:

  1. Что вы хотите протестировать?
  2. Вы пишете модульный или интеграционный тест?

Тогда структура теста должна в общих чертах соответствовать следующей схеме работы:

  1. Создайте входные данные
  2. Выполните тестируемый код, фиксируя выходные данные
  3. Сравните полученный результат с ожидаемым результатом

Для этого приложения вы тестируете sum(). В sum() есть много вариантов поведения, которые можно проверить, например:

  • Может ли он суммировать список целых чисел (целых)?
  • Может ли он суммировать кортеж или множество?
  • Может ли он суммировать список плавающих чисел?
  • Что произойдет, если вы предоставите ему плохое значение, например, одно целое число или строку?
  • Что произойдет, если одно из значений будет отрицательным?

Самым простым тестом будет список целых чисел. Создайте файл test.py со следующим кодом на языке Python:

import unittest

from my_sum import sum


class TestSum(unittest.TestCase):
    def test_list_int(self):
        """
        Test that it can sum a list of integers
        """
        data = [1, 2, 3]
        result = sum(data)
        self.assertEqual(result, 6)

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

Этот пример кода:

  1. Импортирует sum() из созданного вами my_sum пакета

  2. Определяет новый класс тестовых примеров TestSum, который наследуется от unittest.TestCase

  3. Определяет тестовый метод .test_list_int() для проверки списка целых чисел. Метод .test_list_int() будет:

    • Объявляет переменную data со списком чисел (1, 2, 3)
    • Присвоить результат my_sum.sum(data) переменной result
    • Убедитесь, что значение result равно 6, используя метод .assertEqual() на классе unittest.TestCase
  4. Определяет точку входа командной строки, которая запускает unittest тестовый прогонщик .main()

Если вы не знаете, что такое self или как определяется .assertEqual(), вы можете подтянуть свои знания по объектно-ориентированному программированию с помощью Python 3 Object-Oriented Programming.

Как писать утверждения

Последним шагом в написании теста является проверка вывода на соответствие известному ответу. Это известно как утверждение. Существуют некоторые общие рекомендации по написанию утверждений:

  • Убедитесь, что тесты повторяемы, и проведите тест несколько раз, чтобы убедиться, что он каждый раз дает один и тот же результат
  • Попробуйте утверждать результаты, которые относятся к вашим входным данным, например, проверьте, что результат является фактической суммой значений в примере sum()

unittest поставляется с большим количеством методов для утверждения значений, типов и существования переменных. Вот некоторые из наиболее часто используемых методов:

Method Equivalent to
.assertEqual(a, b) a == b
.assertTrue(x) bool(x) is True
.assertFalse(x) bool(x) is False
.assertIs(a, b) a is b
.assertIsNone(x) x is None
.assertIn(a, b) a in b
.assertIsInstance(a, b) isinstance(a, b)

.assertIs(), .assertIsNone(), .assertIn() и .assertIsInstance() имеют противоположные методы, называемые .assertIsNot(), и так далее.

Побочные эффекты

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

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

Выполнение первого теста

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

Выполнение тестовых прогонов

Приложение на Python, которое выполняет ваш тестовый код, проверяет утверждения и выводит результаты тестирования в консоль, называется test runner.

Внизу test.py вы добавили этот небольшой фрагмент кода:

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

Это точка входа в командную строку. Это означает, что если вы выполните сценарий в одиночку, запустив python test.py в командной строке, он вызовет unittest.main(). Это запустит программу запуска тестов, обнаружив все классы в этом файле, которые наследуют от unittest.TestCase.

Это один из многих способов выполнения программы unittest тестирования. Если у вас есть один тестовый файл с именем test.py, вызов python test.py - отличный способ начать работу.

Другой способ - использование командной строки unittest. Попробуйте сделать следующее:

$ python -m unittest test

Это приведет к выполнению того же тестового модуля (с именем test) через командную строку.

Вы можете указать дополнительные параметры для изменения вывода. Один из них - -v для многословного вывода. Попробуйте сделать это следующим образом:

$ python -m unittest -v test
test_list_int (test.TestSum) ... ok

----------------------------------------------------------------------
Ran 1 tests in 0.000s

Это выполнит один тест внутри test.py и выведет результаты на консоль. В режиме Verbose перечислялись имена тестов, которые выполнялись первыми, а также результат каждого теста.

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

$ python -m unittest discover

Это выполнит поиск в текущем каталоге любых файлов с именем test*.py и попытается их проверить.

Если у вас есть несколько тестовых файлов, то, следуя шаблону именования test*.py, вы можете указать имя каталога, используя флаг -s и имя каталога:

$ python -m unittest discover -s tests

unittest выполнит все тесты в одном плане тестирования и выдаст вам результаты.

И последнее, если ваш исходный код находится не в корневом каталоге, а в подкаталоге, например, в папке src/, вы можете указать unittest, где выполнять тесты, чтобы он мог правильно импортировать модули с помощью флага -t:

$ python -m unittest discover -s tests -t src

unittest перейдет в каталог src/, просканирует все test*.py файлы в каталоге tests и выполнит их.

Понимание результатов тестирования

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

sum() должен иметь возможность принимать другие списки числовых типов, например, дробей.

В верхней части файла test.py добавьте оператор import, чтобы импортировать тип Fraction из модуля fractions в стандартной библиотеке:

from fractions import Fraction

Теперь добавьте тест с утверждением, ожидающим неверное значение, в данном случае ожидающим, что сумма 1/4, 1/4 и 2/5 будет равна 1:

import unittest

from my_sum import sum


class TestSum(unittest.TestCase):
    def test_list_int(self):
        """
        Test that it can sum a list of integers
        """
        data = [1, 2, 3]
        result = sum(data)
        self.assertEqual(result, 6)

    def test_list_fraction(self):
        """
        Test that it can sum a list of fractions
        """
        data = [Fraction(1, 4), Fraction(1, 4), Fraction(2, 5)]
        result = sum(data)
        self.assertEqual(result, 1)

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

Если вы снова выполните тесты с python -m unittest test, вы увидите следующий результат:

$ python -m unittest test
F.
======================================================================
FAIL: test_list_fraction (test.TestSum)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test.py", line 21, in test_list_fraction
    self.assertEqual(result, 1)
AssertionError: Fraction(9, 10) != 1

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)

В выводе вы увидите следующую информацию:

  1. В первой строке показаны результаты выполнения всех тестов, один неудачный (F) и один пройденный (.).

  2. Запись FAIL показывает некоторые подробности о неудачном тесте:

    • Имя метода тестирования (test_list_fraction)
    • Тестовый модуль (test) и тестовый пример (TestSum)
    • Обратный путь к неудачной строке
    • Детали утверждения с ожидаемым результатом (1) и фактическим результатом (Fraction(9, 10))

Помните, что вы можете добавить дополнительную информацию в тестовый вывод, добавив флаг -v к команде python -m unittest.

Запуск тестов из PyCharm

Если вы используете IDE PyCharm, вы можете запустить unittest или pytest, выполнив следующие шаги:

  1. В окне инструмента Project выберите каталог tests.
  2. В контекстном меню выберите команду запуска для unittest. Например, выберите Run 'Unittests in my Tests...'.

Это выполнит unittest в тестовом окне и предоставит вам результаты в PyCharm:

PyCharm Testing

Дополнительная информация доступна на сайте PyCharm.

Запуск тестов из кода Visual Studio

Если вы используете IDE Microsoft Visual Studio Code, поддержка выполнения unittest, nose и pytest встроена в плагин Python.

If you have the Python plugin installed, you can set up the configuration of your tests by opening the Command Palette with Ctrl+Shift+P and typing “Python test”. You will see a range of options:

Visual Studio Code Step 1

Выберите Debug All Unit Tests, после чего VSCode выдаст запрос на настройку тестового фреймворка. Щелкните на шестеренке, чтобы выбрать программу запуска тестов (unittest) и домашний каталог (.).

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

Visual Studio Code Step 2

Это показывает, что тесты выполняются, но некоторые из них не работают.

Тестирование для веб-фреймворков, таких как Django и Flask

Если вы пишете тесты для веб-приложения, используя один из популярных фреймворков, таких как Django или Flask, есть несколько важных различий в том, как вы пишете и запускаете тесты.

Почему они отличаются от других приложений

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

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

Django и Flask облегчают эту задачу, предоставляя фреймворк для тестирования на основе unittest. Вы можете продолжать писать тесты так же, как учились, но выполнять их немного по-другому.

Как использовать Django Test Runner

Шаблон Django startapp создаст файл tests.py в каталоге вашего приложения. Если у вас его еще нет, вы можете создать его со следующим содержимым:

from django.test import TestCase

class MyTestCase(TestCase):
    # Your test methods

Основное различие с примерами до сих пор заключается в том, что вам нужно наследоваться от django.test.TestCase, а не от unittest.TestCase. Эти классы имеют одинаковый API, но класс Django TestCase устанавливает все необходимые состояния для тестирования.

Чтобы выполнить набор тестов, вместо unittest в командной строке используйте manage.py test:

$ python manage.py test

Если вам нужно несколько тестовых файлов, замените tests.py на папку tests, вставьте в нее пустой файл __init__.py и создайте свои файлы test_*.py. Django обнаружит и выполнит их.

Дополнительная информация доступна на сайте Django Documentation Website.

Как использовать unittest и Flask

Flask требует, чтобы приложение было импортировано, а затем переведено в тестовый режим. Вы можете создать тестового клиента и использовать его для выполнения запросов к любым маршрутам в вашем приложении.

Все инстанцирование тестового клиента выполняется в методе setUp вашего тестового случая. В следующем примере my_app - это имя приложения. Не волнуйтесь, если вы не знаете, что делает setUp. Вы узнаете об этом в разделе "Более сложные сценарии тестирования".

Код в вашем тестовом файле должен выглядеть следующим образом:

import my_app
import unittest


class MyTestCase(unittest.TestCase):

    def setUp(self):
        my_app.app.testing = True
        self.app = my_app.app.test_client()

    def test_home(self):
        result = self.app.get('/')
        # Make your assertions

Затем вы можете выполнить тестовые примеры с помощью команды python -m unittest discover.

Дополнительная информация доступна на сайте Flask Documentation Website.

Дополнительные сценарии тестирования

Прежде чем приступить к созданию тестов для вашего приложения, запомните три основных этапа каждого теста:

  1. Создайте входные данные
  2. Выполните код, фиксируя выходные данные
  3. Сравните полученный результат с ожидаемым результатом

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

Данные, которые вы создаете в качестве входных, называются fixture. Обычно принято создавать фикстуры и использовать их повторно.

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

Обработка ожидаемых отказов

Ранее, когда вы составляли список сценариев для тестирования sum(), возник вопрос: Что произойдет, если предоставить ему плохое значение, например, одно целое число или строку?

В этом случае вы ожидаете, что sum() выдаст ошибку. Когда он выдает ошибку, это приводит к провалу теста.

Существует специальный способ обработки ожидаемых ошибок. Вы можете использовать .assertRaises() в качестве менеджера контекста, а затем внутри блока with выполнить шаги теста:

import unittest

from my_sum import sum


class TestSum(unittest.TestCase):
    def test_list_int(self):
        """
        Test that it can sum a list of integers
        """
        data = [1, 2, 3]
        result = sum(data)
        self.assertEqual(result, 6)

    def test_list_fraction(self):
        """
        Test that it can sum a list of fractions
        """
        data = [Fraction(1, 4), Fraction(1, 4), Fraction(2, 5)]
        result = sum(data)
        self.assertEqual(result, 1)

    def test_bad_type(self):
        data = "banana"
        with self.assertRaises(TypeError):
            result = sum(data)

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

Теперь этот тест будет пройден, только если sum(data) вызывает TypeError. Вы можете заменить TypeError на любой тип исключения, который вы выберете.

Изолирование поведения в вашем приложении

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

Testing Side Effects

Есть несколько простых приемов, которые можно использовать для тестирования частей приложения, имеющих множество побочных эффектов:

  • Рефакторинг кода в соответствии с принципом единой ответственности
  • Заманивание любых вызовов методов или функций для устранения побочных эффектов
  • Использование интеграционного тестирования вместо модульного тестирования для этой части приложения

Если вы не знакомы с mocking, посмотрите Python CLI Testing, где приведено несколько отличных примеров.

Написание интеграционных тестов

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

Интеграционное тестирование - это тестирование нескольких компонентов приложения с целью проверки их совместной работы. Интеграционное тестирование может потребовать действовать как потребитель или пользователь приложения путем:

  • Вызов HTTP REST API
  • Вызов Python API
  • Вызов веб-сервиса
  • Запуск командной строки

Каждый из этих типов интеграционных тестов может быть написан так же, как и модульный тест, по шаблону Input, Execute и Assert. Наиболее существенное различие заключается в том, что интеграционные тесты проверяют сразу несколько компонентов и поэтому будут иметь больше побочных эффектов, чем юнит-тесты. Кроме того, интеграционные тесты требуют наличия большего количества приспособлений, таких как база данных, сетевой сокет или файл конфигурации.

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

Простой способ разделить модульные и интеграционные тесты - просто поместить их в разные папки:

project/
│
├── my_app/
│   └── __init__.py
│
└── tests/
    |
    ├── unit/
    |   ├── __init__.py
    |   └── test_sum.py
    |
    └── integration/
        ├── __init__.py
        └── test_integration.py

Существует множество способов выполнить только избранную группу тестов. Флаг указания исходного каталога, -s, может быть добавлен к unittest discover с путем, содержащим тесты:

$ python -m unittest discover -s tests/integration

unittest предоставит вам результаты всех тестов в каталоге tests/integration.

Тестирование приложений, управляемых данными

Многие интеграционные тесты требуют наличия определенных значений в бэкэнд-данных, например в базе данных. Например, вам может понадобиться тест, проверяющий корректность отображения приложения при наличии более 100 клиентов в базе данных, или страница заказа работает, даже если названия товаров отображаются на японском языке.

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

Хорошей техникой является хранение тестовых данных в папке внутри папки интеграционного тестирования под названием fixtures, чтобы указать, что она содержит тестовые данные. Затем в своих тестах вы можете загрузить эти данные и запустить тест.

Вот пример такой структуры, если бы данные состояли из JSON-файлов:

project/
│
├── my_app/
│   └── __init__.py
│
└── tests/
    |
    └── unit/
    |   ├── __init__.py
    |   └── test_sum.py
    |
    └── integration/
        |
        ├── fixtures/
        |   ├── test_basic.json
        |   └── test_complex.json
        |
        ├── __init__.py
        └── test_integration.py

В рамках вашего тестового случая вы можете использовать метод .setUp() для загрузки тестовых данных из файла фикстуры по известному пути и выполнения множества тестов с этими тестовыми данными. Помните, что в одном файле Python может быть несколько тестовых примеров, и открытие unittest выполнит оба. Для каждого набора тестовых данных можно иметь один тестовый пример:

import unittest


class TestBasic(unittest.TestCase):
    def setUp(self):
        # Load test data
        self.app = App(database='fixtures/test_basic.json')

    def test_customer_count(self):
        self.assertEqual(len(self.app.customers), 100)

    def test_existence_of_customer(self):
        customer = self.app.get_customer(id=10)
        self.assertEqual(customer.name, "Org XYZ")
        self.assertEqual(customer.address, "10 Red Road, Reading")


class TestComplexData(unittest.TestCase):
    def setUp(self):
        # load test data
        self.app = App(database='fixtures/test_complex.json')

    def test_customer_count(self):
        self.assertEqual(len(self.app.customers), 10000)

    def test_existence_of_customer(self):
        customer = self.app.get_customer(id=9999)
        self.assertEqual(customer.name, u"バナナ")
        self.assertEqual(customer.address, "10 Red Road, Akihabara, Tokyo")

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

Если ваше приложение зависит от данных из удаленного места, например от удаленного API, вам необходимо обеспечить повторяемость тестов. Если ваши тесты не сработают из-за того, что API находится в автономном режиме или возникли проблемы с подключением, это может замедлить разработку. В таких ситуациях лучше всего хранить удаленные исправления локально, чтобы их можно было вызвать и отправить в приложение.

В библиотеке requests есть дополнительный пакет responses, который позволяет создавать фикстуры отклика и сохранять их в папках с тестами. Узнайте больше на их странице GitHub Page.

Тестирование в нескольких средах

До сих пор вы тестировали на одной версии Python, используя виртуальную среду с определенным набором зависимостей. Возможно, вам захочется проверить, работает ли ваше приложение на нескольких версиях Python или на нескольких версиях пакета. Tox - это приложение, автоматизирующее тестирование в нескольких средах.

Установка Tox

Tox доступен на PyPI в виде пакета для установки через pip:

$ pip install tox

Теперь, когда вы установили Tox, его нужно настроить.

Настройка Tox для ваших зависимостей

Tox настраивается через конфигурационный файл в каталоге вашего проекта. Конфигурационный файл Tox содержит следующее:

  • Команда для выполнения тестов
  • Дополнительные пакеты, необходимые перед выполнением
  • Целевые версии Python для тестирования

Вместо того чтобы изучать синтаксис конфигурации Tox, вы можете начать с запуска приложения быстрого запуска:

$ tox-quickstart

Инструмент настройки Tox задаст вам эти вопросы и создаст файл, подобный следующему в tox.ini:

[tox]
envlist = py27, py36

[testenv]
deps =

commands =
    python -m unittest discover

Прежде чем запустить Tox, необходимо, чтобы в папке приложения находился файл setup.py, содержащий шаги по установке вашего пакета. Если у вас его нет, вы можете следовать этому руководству о том, как создать setup.py, прежде чем продолжить.

Альтернативный вариант, если ваш проект не предназначен для распространения на PyPI, вы можете пропустить это требование, добавив следующую строку в файл tox.ini под заголовком [tox]:

[tox]
envlist = py27, py36
skipsdist=True

Если вы не создаете setup.py, а ваше приложение имеет некоторые зависимости от PyPI, вам нужно будет указать их в нескольких строках в разделе [testenv]. Например, для Django потребуется следующее:

[testenv]
deps = django

После завершения этого этапа вы готовы к запуску тестов.

Теперь вы можете запустить Tox, и он создаст две виртуальные среды: одну для Python 2.7 и одну для Python 3.6. Каталог Tox называется .tox/. Внутри каталога .tox/ Tox выполнит команду python -m unittest discover для каждой виртуальной среды.

Вы можете запустить этот процесс, вызвав Tox в командной строке:

$ tox

Tox выведет результаты ваших тестов для каждого окружения. При первом запуске Tox потратит немного времени на создание виртуальных сред, но после этого второе выполнение будет намного быстрее.

Выполнение Tox

Вывод Tox довольно прост. Он создает окружение для каждой версии, устанавливает ваши зависимости, а затем запускает тестовые команды.

Есть несколько дополнительных опций командной строки, которые полезно запомнить.

Запустите только одно окружение, например Python 3.6:

$ tox -e py36

Пересоздайте виртуальные среды, в случае если ваши зависимости изменились или site-packages повреждены:

$ tox -r

Запустить Tox с менее подробным выводом:

$ tox -q

Запуск Tox с более подробным выводом:

$ tox -v

Дополнительную информацию о Токсе можно найти на сайте Tox Documentation Website.

Автоматизация выполнения тестов

До сих пор вы выполняли тесты вручную, запуская команду. Существуют инструменты для автоматического выполнения тестов, когда вы вносите изменения и фиксируете их в репозитории контроля исходного кода, например Git. Инструменты для автоматического тестирования часто называют CI/CD-инструментами, что расшифровывается как "Continuous Integration/Continuous Deployment". Они могут запускать ваши тесты, компилировать и публиковать любые приложения и даже внедрять их в производство.

Travis CI - один из многих доступных сервисов CI (Continuous Integration).

Travis CI отлично работает с Python, и теперь, когда вы создали все эти тесты, вы можете автоматизировать их выполнение в облаке! Travis CI бесплатен для любых проектов с открытым исходным кодом на GitHub и GitLab и доступен за плату для частных проектов.

Чтобы начать работу, войдите на сайт и авторизуйтесь с помощью учетных данных GitHub или GitLab. Затем создайте файл под названием .travis.yml со следующим содержимым:

language: python
python:
  - "2.7"
  - "3.7"
install:
  - pip install -r requirements.txt
script:
  - python -m unittest discover

Эта конфигурация предписывает Travis CI:

  1. Протестируйте на Python 2.7 и 3.7 (Вы можете заменить эти версии на любую другую по вашему выбору.)
  2. Установите все пакеты, перечисленные в requirements.txt (Вы должны удалить этот раздел, если у вас нет никаких зависимостей.)
  3. Запустите python -m unittest discover для запуска тестов

После фиксации и отправки этого файла Travis CI будет выполнять эти команды каждый раз при отправке в удаленный Git-репозиторий. Вы можете посмотреть результаты на их сайте.

Что дальше

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

Ввод линз в ваше приложение

В Tox и Travis CI есть настройка для команды тестирования. Команда тестирования, которую вы использовали на протяжении всего этого руководства, - это python -m unittest discover.

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

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

Для получения дополнительной информации о линерах прочитайте учебник Python Code Quality tutorial.

Пассивный линтинг с flake8

Популярным линтером, который комментирует стиль вашего кода относительно спецификации PEP 8, является flake8.

Вы можете установить flake8, используя pip:

$ pip install flake8

Затем вы можете выполнить flake8 над одним файлом, папкой или шаблоном:

$ flake8 test.py
test.py:6:1: E302 expected 2 blank lines, found 1
test.py:23:1: E305 expected 2 blank lines after class or function definition, found 1
test.py:24:20: W292 no newline at end of file

Вы увидите список ошибок и предупреждений для вашего кода, который flake8 был найден.

flake8 настраивается в командной строке или в конфигурационном файле вашего проекта. Если вы хотите игнорировать определенные правила, как E305, показанные выше, вы можете задать их в конфигурации. flake8 будет проверять .flake8 файл в папке проекта или setup.cfg файл. Если вы решили использовать Tox, вы можете поместить секцию конфигурации flake8 внутри tox.ini.

В этом примере игнорируются каталоги .git и __pycache__, а также правило E305. Кроме того, он устанавливает максимальную длину строки 90 вместо 80 символов. Скорее всего, вы обнаружите, что ограничение по умолчанию в 79 символов для ширины строки очень ограничивает тесты, поскольку они содержат длинные имена методов, строковые литералы с тестовыми значениями и другие части данных, которые могут быть длиннее. Обычно длина строки для тестов может достигать 120 символов:

[flake8]
ignore = E305
exclude = .git,__pycache__
max-line-length = 90

В качестве альтернативы вы можете указать эти параметры в командной строке:

$ flake8 --ignore E305 --exclude .git,__pycache__ --max-line-length=90

Полный список опций конфигурации доступен на Веб-сайте документации .

Теперь вы можете добавить flake8 в конфигурацию CI. Для Travis CI это будет выглядеть следующим образом:

matrix:
  include:
    - python: "2.7"
      script: "flake8"

Travis прочитает конфигурацию в .flake8 и не выполнит сборку, если возникнут ошибки линтинга. Обязательно добавьте зависимость flake8 в ваш requirements.txt файл.

Агрессивный линтинг с помощью форматера кода

flake8 - это пассивный линтер: он рекомендует изменения, но вы должны пойти и изменить код. Более агрессивный подход - форматтер кода. Форматировщики кода автоматически изменяют ваш код в соответствии с набором практик по стилю и верстке.

black - очень неумолимый форматтер. У него нет никаких опций настройки, и он имеет очень специфический стиль. Это делает его отличным инструментом для использования в тестовом конвейере.

Примечание: black требует Python 3.6+.

Вы можете установить black через pip:

$ pip install black

Затем, чтобы запустить black в командной строке, укажите файл или каталог, который вы хотите отформатировать:

$ black test.py

Keeping Your Test Code Clean

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

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

При написании тестов старайтесь следовать принципу DRY: Dне Rпереигрывайте Yсебя.

Тестовые приспособления и функции - это отличный способ создавать тестовый код, который легче поддерживать. Кроме того, читабельность имеет значение. Рассмотрите возможность развертывания такого инструмента линтинга, как flake8, над вашим тестовым кодом:

$ flake8 --max-line-length=120 tests/

Тестирование на снижение производительности между изменениями

Существует множество способов проверки кода на Python. В стандартной библиотеке есть модуль timeit, который может выполнять функции определенное количество раз и выдавать вам распределение. Этот пример выполнит test() 100 раз и print() выдаст на выходе:

def test():
    # ... your code

if __name__ == '__main__':
    import timeit
    print(timeit.timeit("test()", setup="from __main__ import test", number=100))

Еще один вариант, если вы решили использовать pytest в качестве прогонщика тестов, - это плагин pytest-benchmark. Он предоставляет приспособление pytest под названием benchmark. Вы можете передать benchmark() любую вызываемую переменную, и она будет регистрировать время выполнения этой переменной в результатах pytest.

Вы можете установить pytest-benchmark из PyPI, используя pip:

$ pip install pytest-benchmark

Затем вы можете добавить тест, который использует приспособление и передает вызываемую переменную для выполнения:

def test_my_function(benchmark):
    result = benchmark(test)

Выполнение команды pytest приведет к получению эталонных результатов:

Pytest benchmark screenshot

Дополнительная информация доступна на Веб-сайте документации .

Тестирование недостатков безопасности в вашем приложении

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

Вы можете установить bandit из PyPI, используя pip:

$ pip install bandit

Затем вы можете передать имя вашего модуля приложения с флагом -r, и он выдаст вам сводку:

$ bandit -r my_sum
[main]  INFO    profile include tests: None
[main]  INFO    profile exclude tests: None
[main]  INFO    cli include tests: None
[main]  INFO    cli exclude tests: None
[main]  INFO    running on Python 3.5.2
Run started:2018-10-08 00:35:02.669550

Test results:
        No issues identified.

Code scanned:
        Total lines of code: 5
        Total lines skipped (#nosec): 0

Run metrics:
        Total issues (by severity):
                Undefined: 0.0
                Low: 0.0
                Medium: 0.0
                High: 0.0
        Total issues (by confidence):
                Undefined: 0.0
                Low: 0.0
                Medium: 0.0
                High: 0.0
Files skipped (0):

Как и в случае с flake8, правила, которые устанавливает bandit, являются настраиваемыми, и если вы хотите игнорировать какие-либо из них, вы можете добавить в файл setup.cfg следующую секцию с параметрами:

[bandit]
exclude: /test
tests: B101,B102,B301

Более подробную информацию можно найти на сайте GitHub.

Заключение

Python сделал тестирование доступным благодаря встроенным командам и библиотекам, необходимым для проверки того, что ваши приложения работают так, как задумано. Начать тестирование в Python несложно: вы можете использовать unittest и писать небольшие, удобные для сопровождения методы для проверки вашего кода.

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

Спасибо, что прочитали. Я надеюсь, что у вас будет свободное от ошибок будущее с Python!

Вернуться на верх