Погружаемся в основы и нюансы тестирования Python-кода

Пишете код на Python? Будет полезно знать о принципах тестирования Python-кода ваших приложений. Изучайте статью и применяйте навыки в работе.

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

Как всё устроено

Сразу к делу. Вот как будет проходить проверка функции sum() (1,2,3) равна шести:

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

Тест не выведет ничего на REPL, так как значения верны. Но если результат sum() неверен, это приведет к ошибке AssertionError и сообщению “Should be 6”.

>>> 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, потому что результат не соответствует 6. Переместите код в новый файл, названный 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

sum() принимает любое повторяющееся значение в качестве первого аргумента. Вы проверили список, теперь проверьте так же и tuple. Создайте новый файл 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) не равна 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

Для более масштабных вещей используют running tests. Это специальные приложения для запуска тестов, проверки вывода и предоставления инструментов для отладки и диагностики тестов и приложений.

Выбор Test Runner

Unittest

Unittest содержит как структуру тестирования Python, так и test runners. У него есть несколько требований:

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

Для преобразования в unittest:

  • Импортируйте его из стандартной библиотеки.
  • Создайте класс TestSum, который наследуется от класса TestCase.
  • Преобразуйте тестовые функции в методы путем добавления self в качестве первого аргумента.
  • Изменить утверждение на использование метода self.assertEqual() в классе TestCase.
  • Изменить точку входа в командной строке для вызова 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()
$ 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)

Nose

Совместим с любыми тестами, написанными с использованием unittest. Чтобы начать тестирование Python-кода, установите его из PyPl и выполните в командной строке. Он попытается обнаружить все скрипты с именем test*.py, наследующие от unittest.

$ 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)

Pytest

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

Кроме того, он отличается:

  • Поддержкой встроенного утверждения 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"

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

Если вы только начали изучать Python с нуля, обязательно затроньте и темы дебага/тестирования. Понимание принципов тестирования Python включает в себя принципы написания собственных тестов. Создайте новую папку проекта и внутри нее, под названием my_sum, еще одну. Внутри my_sum создайте пустой файл с именем __init__.py:

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, который будет содержать ваш первый тест:

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

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

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

  • Что вы хотите проверить?
  • Вы пишете unit test или integration test?

После убедитесь, что структура теста соответствует следующему порядку:

  • Создание структуры ввода.
  • Выполнение кода и определение вывода.
  • Сравнивание полученного с ожидаемым результатом.

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

  • Может ли функция суммировать целые числа?
  • Может ли она использовать set или tuple?
  • Что происходит, когда вы вводите неверное значение, например, переменную или целую строчку?
  • Что происходит, когда значение отрицательно?

Начнем с суммы целых чисел.

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()

Код импортирует sum() из папки my_sum, затем определяет новый класс теста TestSum, наследуемый от unittest, а TestCase определяет тестовый метод .test_list_int() для проверки списка целых чисел.

Метод .test_list_int() будет:

  • Описывать переменные списка чисел.
  • Назначать результат my_sum.sum(data) для результирующей переменной.
  • Проверять, что значение равно шести, используя метод .assertEqual() в классе unittestTestCase.
  • Определять точку ввода в командную строку, где выполняется unittest test–runner .main().

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

Последним этапом теста является проверка вывода на основе известного ответа. Это называется утверждением − assertion. Есть несколько общих принципов их написания:

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

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

Проверка Test Runners

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

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

$ python -m unittest test

Вы можете предоставить дополнительные опции для изменения вывода. Один из них – “–v”:

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

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

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

$ python -m unittest discover

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

$ python -m unittest discover -s tests

Если исходный код отсутствует в корне каталога и содержится в подкаталоге, можно сообщить Unittest, где выполнить тесты, чтобы он правильно импортировал модули с –t flag:

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

Результаты тестирования

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

В верхней части файла test.py добавьте оператор импорта:

from fractions import Fraction

Добавьте тест с утверждением, ожидающим неправильное значение. В этом случае ожидание sum() от (¼, ¼ и ⅖) будет равно 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)

Выполнение тестов в PyCharm

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

  • В окне инструментов проекта выберите каталог тестов
  • В контекстном меню выберите команду запуск для Unittest.

PyCharm Testing

Выполнение тестов из кода Visual Studio

Если у вас установлен плагин Python, вы можете настроить конфигурацию своих тестов, открыв командную палитру с помощью Ctrl+Shift+P и набрав «Python test»:Visual Studio Code Step 1

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

Visual Studio Code Step 2

Тестирование для Django и Flask

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

Шаблон startapp в Django создаст файл test.py внутри каталога приложений. Если его нет, создайте:

from django.test import TestCase

class MyTestCase(TestCase):
    # Ваш метод

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

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

$ python manage.py test

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

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

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

Все экземпляры тестового клиента выполняются в методе setUp. В следующем примере my_app − имя приложения.

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

Сложные сценарии тестирования

Сбои

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

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

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. Позже условие можно будет изменить.

Структура

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

Основы тестирования Python

Спасительные методы:

  • Реструктурирование кода.
  • Использование способа mocking для методов функции.
  • Использование integration test вместо unit test.

Написание integration tests

До этого времени мы занимались в основном unit testing. Двигаемся дальше.

Integration testing – тестирование нескольких компонентов приложения для проверки их совместной работоспособности. Integration testing может требовать разные сценарии работы:

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

Каждый из этих типов integration tests может быть записан так же, как и unit test. Существенное отличие состоит в том, что Integration tests проверяют сразу несколько компонентов. Можно разделить тесты на integration и unit − разбить их по папкам:

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

Можно указать путь к тестам:

$ python -m unittest discover -s tests/integration

Тестирование data-driven приложений

Многие integration tests требуют базовые данные, содержащие определенные значения. Например, может потребоваться тест, который проверяет правильность отображения приложения с более чем 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 discovery будет выполнять их все. Для каждого набора тестовых данных может быть один тестовый пример:

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()

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

До сих пор вы работали только с одной версией Python, используя виртуальную среду с определенным набором зависимостей. Tox − приложение, которое автоматизирует процесс тестирования Python в нескольких средах.

Установка Tox

$ pip install tox

Настройка Tox для ваших нужд

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

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

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

$ tox-quickstart

Средство конфигурации Tox создаст файл, похожий на следующий в tox.ini:

[tox]
envlist = py27, py36

[testenv]
deps =

commands =
    python -m unittest discover

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

Вместо этого, можно добавить строку в файл tox.ini в заголовке [tox]:

[tox]
envlist = py27, py36
skipsdist=True

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

[testenv]
deps = django

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

Этот процесс также можно запустить, вызвав Tox в командной строке. На этом заканчиваем рассказ о принципах тестирования Python-кода.

Заключение

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

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

Понравился материал об основах тестирования Python-кода? Возможно, вас заинтересует следующее:

Лучшие книги по Python:

Источник: Основы тестирования Python on Realpython

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Продуктовый аналитик в поддержку
по итогам собеседования
Аналитик данных
Екатеринбург, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ