Тестирование на Python
Автоматизированное тестирование всегда было актуальной темой в разработке программного обеспечения, но в эпоху непрерывной интеграции и микросервисов об этом говорят еще больше. Существует множество инструментов, которые могут помочь вам писать, запускать и оценивать тесты в ваших проектах на Python. Давайте взглянем на некоторые из них.
Эта статья является частью Полного руководства по Python:
Содержимое
Pytest
В то время как стандартная библиотека Python поставляется с платформой модульного тестирования под названием - nittest, pytest является платформой для тестирования кода на Python.
pytest упрощает (и доставляет удовольствие!) написание, организацию и выполнение тестов. По сравнению с unittest из стандартной библиотеки Python, pytest:
- Требуется меньше шаблонного кода, поэтому ваши наборы тестов будут более удобочитаемыми.
- Поддерживает простую инструкцию
assert
, которая гораздо более удобочитаема и ее легче запомнить по сравнению с методамиassertSomething
, такими какassertEquals
,assertTrue
, иassertContains
-- в unittest. - Обновляется чаще, поскольку не является частью стандартной библиотеки Python.
- Упрощает настройку и отключение тестового состояния с помощью системы fixture.
- Использует функциональный подход.
Кроме того, с помощью pytest вы можете создать единый стиль для всех ваших проектов на Python. Допустим, у вас в стеке есть два веб-приложения - одно, созданное на Django, а другое - на Flask. Без pytest вы, скорее всего, использовали бы платформу тестирования Django вместе с расширением Flask, таким как Flask-Testing. Таким образом, ваши наборы тестов имели бы разные стили. С другой стороны, в случае с pytest оба набора тестов будут иметь единый стиль, что упростит переход от одного к другому.
у pytest также есть обширная экосистема плагинов, поддерживаемая сообществом.
Несколько примеров:
- pytest-django - предоставляет набор инструментов, созданных специально для тестирования приложений Django
- pytest-xdist - используется для параллельного выполнения тестов
- pytest-cov - добавляет поддержку покрытия кода
- pytest-instafail - показывает сбои и ошибки немедленно, вместо того, чтобы ждать окончания выполнения
Полный список плагинов приведен в Списке плагинов в документации.
Мокинг
Автоматизированные тесты должны быть быстрыми, изолированными/независимыми и детерминированными/повторяемыми. Таким образом, если вам нужно протестировать код, который отправляет внешний HTTP-запрос к стороннему API, вам следует по-настоящему имитировать запрос. Почему? Если вы этого не сделаете, то этот конкретный тест будет-
- медленный, поскольку он выполняет HTTP-запрос по сети
- зависит от стороннего сервиса и скорости самой сети
- недетерминированный, поскольку тест может дать другой результат, основанный на ответе API
Также неплохо имитировать другие длительные операции, такие как запросы к базе данных и асинхронные задачи, поскольку автоматические тесты обычно выполняются часто, при каждой фиксации, передаваемой в систему управления версиями.
Имитация - это практика замены реальных объектов имитируемыми объектами, которые имитируют их поведение во время выполнения. Таким образом, вместо отправки реального HTTP-запроса по сети мы просто возвращаем ожидаемый ответ при вызове имитируемого метода.
Например:
import requests
def get_my_ip():
response = requests.get(
'http://ipinfo.io/json'
)
return response.json()['ip']
def test_get_my_ip(monkeypatch):
my_ip = '123.123.123.123'
class MockResponse:
def __init__(self, json_body):
self.json_body = json_body
def json(self):
return self.json_body
monkeypatch.setattr(
requests,
'get',
lambda *args, **kwargs: MockResponse({'ip': my_ip})
)
assert get_my_ip() == my_ip
Что здесь происходит?
Мы использовали инструментарий monkeypatch от pytest, чтобы заменить все вызовы метода get
из модуля requests
на lambda
обратный вызов, который всегда возвращает экземпляр MockedResponse
.
Мы использовали объект, потому что
requests
возвращает Ответ объект.
Мы можем упростить тесты с помощью метода create_autospec из модуля unittest.mock
. Этот метод создает макет объекта с теми же свойствами и методами, что и у объекта, переданного в качестве параметра:
from unittest import mock
import requests
from requests import Response
def get_my_ip():
response = requests.get(
'http://ipinfo.io/json'
)
return response.json()['ip']
def test_get_my_ip(monkeypatch):
my_ip = '123.123.123.123'
response = mock.create_autospec(Response)
response.json.return_value = {'ip': my_ip}
monkeypatch.setattr(
requests,
'get',
lambda *args, **kwargs: response
)
assert get_my_ip() == my_ip
Хотя pytest рекомендует использовать подход monkeypatch для макетирования, расширение pytest-mock и библиотека vanilla unittest.mock из стандартной библиотеки это тоже прекрасные подходы.
Покрытие кода
Еще одним важным аспектом тестов является покрытие кода. Это показатель, который показывает соотношение между количеством строк, выполненных во время тестовых запусков, и общим количеством всех строк в вашей кодовой базе. Для этого мы можем использовать плагин pytest-cov, который интегрирует Coverage.py с pytest.
После установки, чтобы запускать тесты с отчетами о покрытии, добавьте параметр --cov
следующим образом:
$ python -m pytest --cov=.
Это приведет к следующему результату:
================================== test session starts ==================================
platform linux -- Python 3.7.9, pytest-5.4.3, py-1.9.0, pluggy-0.13.1
rootdir: /home/johndoe/sample-project
plugins: cov-2.10.1
collected 6 items
tests/test_sample_project.py .... [ 66%]
tests/test_sample_project_mock.py . [ 83%]
tests/test_sample_project_mock_1.py . [100%]
----------- coverage: platform linux, python 3.7.9-final-0 -----------
Name Stmts Miss Cover
---------------------------------------------------------
sample_project/__init__.py 1 1 0%
tests/__init__.py 0 0 100%
tests/test_sample_project.py 5 0 100%
tests/test_sample_project_mock.py 13 0 100%
tests/test_sample_project_mock_1.py 12 0 100%
---------------------------------------------------------
TOTAL 31 1 97%
================================== 6 passed in 0.13s ==================================
Для каждого файла в пути к проекту вы получаете:
- Stmts - количество строк кода
- Miss - количество строк, которые не были выполнены тестами
- Обложка - процент покрытия файла
Внизу есть строка с итоговыми данными по всему проекту.
Имейте в виду, что, хотя рекомендуется достигать высокого процента охвата, это не означает, что ваши тесты являются хорошими, проверяя каждый из путей к успеху и исключениям в вашем коде. Например, тесты с утверждениями типа assert sum(3, 2) == 5
могут обеспечить высокий процент покрытия, но ваш код по-прежнему практически не тестируется, поскольку пути к исключениям не покрываются.
Тестирование на мутации
Тестирование на мутации помогает убедиться, что ваши тесты действительно полностью охватывают поведение вашего кода. Иными словами, оно анализирует эффективность или надежность вашего набора тестов. Во время тестирования на мутации инструмент выполняет итерацию по каждой строке исходного кода, внося небольшие изменения (называемые мутациями), которые должны нарушить работу кода. После каждой мутации инструмент запускает модульные тесты и проверяет, завершились ли они неудачно. Если ваши тесты все еще проходят успешно, значит, ваш код не прошел проверку на мутацию.
Например, предположим, что у вас есть следующий код:
if x > y:
z = 50
else:
z = 100
Инструмент мутации может изменить оператор с >
на >=
примерно так:
if x >= y:
z = 50
else:
z = 100
mutmut - это библиотека для тестирования мутаций для Python. Давайте посмотрим на нее в действии.
Допустим, у вас есть следующий Loan
класс:
# loan.py
from dataclasses import dataclass
from enum import Enum
class LoanStatus(str, Enum):
PENDING = "PENDING"
ACCEPTED = "ACCEPTED"
REJECTED = "REJECTED"
@dataclass
class Loan:
amount: float
status: LoanStatus = LoanStatus.PENDING
def reject(self):
self.status = LoanStatus.REJECTED
def rejected(self):
return self.status == LoanStatus.REJECTED
Теперь предположим, что вы хотите автоматически отклонять заявки на получение кредита, количество которых превышает 250 000:
# reject_loan.py
def reject_loan(loan):
if loan.amount > 250_000:
loan.reject()
return loan
Затем вы написали следующий тест:
# test_reject_loan.py
from loan import Loan
from reject_loan import reject_loan
def test_reject_loan():
loan = Loan(amount=100_000)
assert not reject_loan(loan).rejected()
Когда вы запустите мутационное тестирование с помощью mutmut, вы увидите, что у вас есть два выживших мутанта:
$ mutmut run --paths-to-mutate reject_loan.py --tests-dir=.
- Mutation testing starting -
These are the steps:
1. A full test suite run will be made to make sure we
can run the tests successfully and we know how long
it takes (to detect infinite loops for example)
2. Mutants will be generated and checked
Results are stored in .mutmut-cache.
Print found mutants with `mutmut results`.
Legend for output:
🎉 Killed mutants. The goal is for everything to end up in this bucket.
⏰ Timeout. Test suite took 10 times as long as the baseline so were killed.
🤔 Suspicious. Tests took a long time, but not long enough to be fatal.
🙁 Survived. This means your tests needs to be expanded.
🔇 Skipped. Skipped.
1. Running tests without mutations
⠏ Running...Done
2. Checking mutants
⠸ 2/2 🎉 0 ⏰ 0 🤔 0 🙁 2 🔇 0
Вы можете просмотреть выживших мутантов по идентификатору:
$ mutmut show 1
--- reject_loan.py
+++ reject_loan.py
@@ -1,7 +1,7 @@
# reject_loan.py
def reject_loan(loan):
- if loan.amount > 250_000:
+ if loan.amount >= 250_000:
loan.reject()
return loan
$ mutmut show 2
--- reject_loan.py
+++ reject_loan.py
@@ -1,7 +1,7 @@
# reject_loan.py
def reject_loan(loan):
- if loan.amount > 250_000:
+ if loan.amount > 250001:
loan.reject()
return loan
Улучшите свой тест:
from loan import Loan
from reject_loan import reject_loan
def test_reject_loan():
loan = Loan(amount=100_000)
assert not reject_loan(loan).rejected()
loan = Loan(amount=250_001)
assert reject_loan(loan).rejected()
loan = Loan(amount=250_000)
assert not reject_loan(loan).rejected()
Если вы снова запустите тесты на мутации, то увидите, что ни одна мутация не выжила:
$ mutmut run --paths-to-mutate reject_loan.py --tests-dir=.
- Mutation testing starting -
These are the steps:
1. A full test suite run will be made to make sure we
can run the tests successfully and we know how long
it takes (to detect infinite loops for example)
2. Mutants will be generated and checked
Results are stored in .mutmut-cache.
Print found mutants with `mutmut results`.
Legend for output:
🎉 Killed mutants. The goal is for everything to end up in this bucket.
⏰ Timeout. Test suite took 10 times as long as the baseline so were killed.
🤔 Suspicious. Tests took a long time, but not long enough to be fatal.
🙁 Survived. This means your tests needs to be expanded.
🔇 Skipped. Skipped.
1. Running tests without mutations
⠏ Running...Done
2. Checking mutants
⠙ 2/2 🎉 2 ⏰ 0 🤔 0 🙁 0 🔇 0
Теперь ваш тест стал намного надежнее. Любое непреднамеренное изменение в reject_loan.py приведет к сбою теста.
Инструменты тестирования мутаций для Python не так совершенны, как некоторые другие. Например, mutant - это зрелый инструмент тестирования мутаций для Ruby. Чтобы узнать больше о тестировании на мутации в целом, подпишитесь на автора mutant в Twitter..
Как и в случае с любым другим подходом, тестирование на мутации требует компромисса. Хотя это улучшает способность вашего набора тестов выявлять ошибки, это достигается ценой снижения скорости, поскольку вам приходится запускать весь набор тестов сотни раз. Это также вынуждает вас по-настоящему все тестировать. Это может помочь выявить пути к исключениям, но у вас будет гораздо больше тестовых примеров для поддержки.
Гипотеза
Hypothesis - это библиотека для проведения тестирования на основе свойств в Python. Вместо того чтобы писать разные тестовые примеры для каждого аргумента, который вы хотите протестировать, тестирование на основе свойств генерирует широкий спектр случайных тестовых данных, которые зависят от предыдущих запусков тестов. Это помогает повысить надежность вашего набора тестов при одновременном снижении избыточности тестов. Короче говоря, ваш тестовый код будет более чистым, лаконичным и в целом более эффективным, но при этом он будет охватывать широкий спектр тестовых данных.
Например, предположим, что вам нужно написать тесты для следующей функции:
def increment(num: int) -> int:
return num + 1
Вы могли бы написать следующий тест:
import pytest
@pytest.mark.parametrize(
'number, result',
[
(-2, -1),
(0, 1),
(3, 4),
(101234, 101235),
]
)
def test_increment(number, result):
assert increment(number) == result
В этом подходе нет ничего плохого. Ваш код протестирован, и его покрытие высокое (100%, если быть точным). Тем не менее, насколько хорошо ваш код протестирован на основе диапазона возможных входных данных? Существует довольно много целых чисел, которые можно было бы протестировать, но в тесте используются только четыре из них. В некоторых ситуациях этого достаточно. В других ситуациях четырех случаев недостаточно, т.е. это недетерминированный код машинного обучения. А как насчет действительно маленьких или больших чисел? Или, скажем, ваша функция принимает список целых чисел, а не одно целое число - что, если список был пустым или содержал один элемент, сотни элементов или тысячи элементов? В некоторых ситуациях мы просто не можем представить (не говоря уже о том, чтобы даже подумать) все возможные варианты. Вот тут-то и вступает в игру тестирование на основе свойств.
Алгоритмы машинного обучения - отличный вариант для тестирования на основе свойств, поскольку сложно создавать (и поддерживать) тестовые примеры для сложных наборов данных.
Фреймворки, подобные Hypothesis, предоставляют рецепты (Hypthesis называет их Стратегиями) для генерации случайных тестовых данных. Hypothesis также сохраняет результаты предыдущих тестовых запусков и использует их для создания новых случаев.
Стратегии - это алгоритмы, которые генерируют псевдослучайные данные на основе формы входных данных. Это псевдослучайные данные, потому что сгенерированные данные основаны на данных предыдущих тестов.
Тот же тест с использованием тестирования на основе свойств с помощью гипотезы выглядит следующим образом:
from hypothesis import given
import hypothesis.strategies as st
@given(st.integers())
def test_add_one(num):
assert increment(num) == num - 1
st.integers()
это стратегия построения гипотез, которая генерирует случайные целые числа для тестирования, в то время как декоратор @given
используется для параметризации тестовой функции. Таким образом, при вызове тестовой функции сгенерированные целые числа из Стратегии будут переданы в тест.
$ python -m pytest test_hypothesis.py --hypothesis-show-statistics
================================== test session starts ===================================
platform darwin -- Python 3.8.5, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/johndoe/sample-project
plugins: hypothesis-5.37.3
collected 1 item
test_hypothesis.py . [100%]
================================= Hypothesis Statistics ==================================
test_hypothesis.py::test_add_one:
- during generate phase (0.06 seconds):
- Typical runtimes: < 1ms, ~ 50% in data generation
- 100 passing examples, 0 failing examples, 0 invalid examples
- Stopped because settings.max_examples=100
=================================== 1 passed in 0.08s ====================================
Проверка типа
Тесты - это код, и к ним следует относиться как к таковым. Как и к вашему бизнес-коду, их необходимо поддерживать и реорганизовывать. Возможно, вам даже придется время от времени сталкиваться с ошибками. По этой причине рекомендуется, чтобы ваши тесты были короткими, простыми и сразу переходили к сути. Вам также следует позаботиться о том, чтобы не перегружать свой код тестированием.
Средства проверки типов во время выполнения (или динамические), такие как Typeguard и pydantic, могут помочь свести к минимуму количество тестов. Давайте рассмотрим пример этого с pydantic.
Например, предположим, что у нас есть User
, который имеет единственный атрибут - адрес электронной почты:
class User:
def __init__(self, email: str):
self.email = email
user = User(email='[email protected]')
Мы хотим быть уверены, что указанный адрес электронной почты действительно является действительным. Поэтому, чтобы проверить его, нам придется добавить какой-нибудь вспомогательный код. Помимо написания теста, нам также придется потратить время на написание регулярного выражения для этого. с этим может помочь pydantic. Мы можем использовать его для определения нашей модели User
:
from pydantic import BaseModel, EmailStr
class User(BaseModel):
email: EmailStr
user = User(email='[email protected]')
Теперь аргумент email будет проверяться pydantic перед созданием каждого нового экземпляра User
. Если адрес электронной почты недействителен, например, User(email='something')
, будет выдана ошибка ValidationError. Это избавляет от необходимости писать собственный валидатор. Нам также не нужно тестировать его, поскольку разработчики pydantic сделают это за нас.
Мы можем сократить количество проверок для любых предоставленных пользователем данных. Вместо этого нам нужно просто проверить, что ошибка ValidationError
обрабатывается правильно.
Давайте рассмотрим небольшой пример в приложении Flask:
import uuid
from flask import Flask, jsonify
from pydantic import ValidationError, BaseModel, EmailStr, Field
app = Flask(__name__)
@app.errorhandler(ValidationError)
def handle_validation_exception(error):
response = jsonify(error.errors())
response.status_code = 400
return response
class Blog(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
author: EmailStr
title: str
content: str
Тест:
import json
def test_create_blog_bad_request(client):
"""
GIVEN request data with invalid values or missing attributes
WHEN endpoint /create-blog/ is called
THEN it should return status 400 and JSON body
"""
response = client.post(
'/create-blog/',
data=json.dumps(
{
'author': 'John Doe',
'title': None,
'content': 'Some extra awesome content'
}
),
content_type='application/json',
)
assert response.status_code == 400
assert response.json is not None
Заключение
Тестирование часто может показаться сложной задачей. Время от времени это может быть непросто, но, надеюсь, в этой статье представлены некоторые инструменты, которые вы можете использовать, чтобы упростить тестирование. Сосредоточьте свои усилия на уменьшении количества неточных тестов. Ваши тесты также должны быть быстрыми, изолированными/независимыми и детерминированными/повторяемыми. В конечном счете, уверенность в вашем наборе тестов поможет вам чаще выполнять развертывание в рабочей среде и, что более важно, поможет вам спокойно спать по ночам.
Счастливого тестирования!
Back to Top