В этой статье я попыталась собрать несколько своих техник тестирования на Python. Не стоит воспринимать их как догму, поскольку, думаю, со временем я обновлю свои практики.

Немного терминологии

  • Цель (target) – то, что вы тестируете в настоящий момент. Возможно, это функция, метод или поведение, сформированное набором элементов.

  • Коллаборатор – любой объект, используемый целевым тестируемым объектом. Как правило, коллабораторы должны быть выходными данными для цели (либо аргументами функции, либо экземплярами класса), но иногда коллабораторы – это более глубокие объекты, такие как сокеты, например.

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

  • Системные тесты – это тесты, которые работают с настоящими внешними системами. Они отличаются от юнит-тестов, которые не должны «покидать» локальную машину.

Имена, функции и классы тестов

Старайтесь следовать рекомендациям pytest. Тесты должны называться в соответствии с модулем, который они тестируют. Например, transport.py должен тестироваться test_transport.py.

Основное правило гласит, что имя теста должно соответствовать имени тестируемой цели.

def refresh(...):
    ...

def test_refresh():
    ...

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

def test_refresh_failure():
    ...

def test_refresh_with_timeout():
    ...

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

class Thing(object):
   ...

class TestThing(object):
    def test_something(self):
       ...

Тесты создания экземпляров класса должны именоваться как test_constructor, также полезно иметь тесты, которые зовутся test_default_state

def test_default_state(self):
    credentials = self.make_credentials()
    # No access token, so these shouldn't be valid.
    assert not credentials.valid
    # Expiration hasn't been set yet
    assert not credentials.expired
    # Scopes aren't required for these credentials
    assert not credentials.requires_scopes

Используйте инструкции assert для результатов и выходных данных, а не для шагов, которые нужно выполнить

К тесту стоит относиться, как к проверке одной механики за раз. С тестами нужно следовать тому же эмпирическому правилу, что и с функциями: они должны делать всего одну вещь (в случае с тестами) и должны делать ее хорошо. Не зацикливайтесь на вызове одной функции за раз или на единственном ее вызове в принципе. Иногда тестирование выходных данных подразумевает множественные вызовы. Ваши ассерты должны проверять, что состояние системы совпадает с тем выходом, который вы ждете. Вам не нужно лишний раз задумываться над тем, какой путь при этом преодолеет код.

Например, этому тесту известно слишком много о том, как получается вывод, и он не устойчив к изменениям в реализации:

test_payload = {'test': 'value'}
encoded = jwt.encode(signer, test_payload)
expected_header = {'typ': 'JWT', 'alg': 'RS256', 'kid': signer.key_id}
expected_call = json.dumps(expected_header) + '.' + json.dumps(test_payload)
signer.sign.assert_called_once_with(expected_call)

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

test_payload = {'test': 'value'}
encoded = jwt.encode(signer, test_payload)
header, payload, _, _ = jwt._unverified_decode(encoded)
assert payload == test_payload
assert header == {'typ': 'JWT', 'alg': 'RS256', 'kid': signer.key_id}

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

Используйте реальные объекты в качестве коллабораторов, когда это возможно

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

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

signer = mock.create_autospec(crypt.Signer, instance=True)
signer.key_id = 1

test_payload = {'test': 'value'}
encoded = jwt.encode(signer, test_payload)

expected_header = {'typ': 'JWT', 'alg': 'RS256', 'kid': signer.key_id}
expected_call = json.dumps(expected_header) + '.' + json.dumps(test_payload)
signer.sign.assert_called_once_with(expected_call)

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

signer = crypt.RSASigner.from_string(PRIVATE_KEY_BYTES, '1')
test_payload = {'test': 'value'}
encoded = jwt.encode(signer, test_payload)
header, payload, _, _ = jwt._unverified_decode(encoded)
assert payload == test_payload
assert header == {'typ': 'JWT', 'alg': 'RS256', 'kid': signer.key_id}

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

У моков всегда должны быть спецификации 

Когда вам нужно использовать мок для коллаборатора, избегайте применения Mock-объекта напрямую. Вместо этого используйте mock.create_autospec() (https://docs.python.org/3/library/unittest.mock.html#unittest.mock.create_autospec) или mock.patch(autospec=True) (https://docs.python.org/3/library/unittest.mock.html#autospeccing), если это представляется возможным. Автоматическая настройка спецификаций реального коллаборатора чревата тем, что, если интерфейс коллаборатора меняется, тест падает. Получение спецификаций вручную или не получение их вообще означает, что интерфейс коллаборатора не сломает тесты, которые его используют, то есть у вас, может быть, стопроцентное покрытие тестами и при этом ваша библиотека упадет при использовании!

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

signer = mock.Mock()

encoded = jwt.encode(signer, test_payload)
...
signer.sign.assert_called_once_with(expected_call)

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

signer = mock.Mock(spec=['sign', 'key_id'])

encoded = jwt.encode(signer, test_payload)
...
signer.sign.assert_called_once_with(expected_call)

Самый правильный способ – это использовать mock.create_autospec() или mock.patch(..., autospec=True). Так вы гарантируете, что между вашим моком и интерфейсом коллаборатора будет связь. Если вы измените интерфейс коллаборатора таким образом, что нарушится целостность нижестоящих целевых объектов, то тесты по праву упадут:

signer = mock.create_autospec(crypt.Signer, instance=True)

encoded = jwt.encode(signer, test_payload)
...
signer.sign.assert_called_once_with(expected_call)

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

Используйте заглушки или фейки

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

Здесь представлена простая заглушка, которая реализует абстрактный класс в целях тестирования:

class CredentialsStub(google.auth.credentials.Credentials):
    def __init__(self, token='token'):
        super(CredentialsStub, self).__init__()
        self.token = token

    def apply(self, headers, token=None):
        headers['authorization'] = self.token

    def before_request(self, request, method, url, headers):
        self.apply(headers)

    def refresh(self, request):
        self.token += '1'

А вот простой фейк для клиента Memcache:

class MemcacheFake(object):
    def __init__(self):
        self._data = {}

    def set(self, key, value):
        self._data[key] = value

    def get(self, key):
        return self._data.get(key)

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

Помните про «шпиона»

Если вы вдруг обнаружили, что используете мок только для того, чтобы сделать ассерты для вызовов, подумайте об использовании «шпиона». Шпион – это объект, который перенаправляет и записывает взаимодействия. У Mock есть встроенная поддержка шпионажа, а конкретно именованный параметра wraps:

credentials = mock.Mock(wraps=CredentialsStub())
...
assert credentials.refresh.called

Не давайте мокам/заглушкам/фейкам специальные имена

Моки должны называться так, как мог бы называться реальный коллаборатор. Не используйте mock_x, x_mock, mocked_x, fake_x и т.д, просто x. Основная причина здесь кроется в том, что так вы думаете о моках, как о реальных коллабораторах, и назначение теста остается более понятным. Например, вот так делать нет необходимости:

mock_signer = mock.create_autospec(crypt.Signer, instance=True)

Просто signer:

signer = mock.create_autospec(crypt.Signer, instance=True)

С patch аналогично, не делайте так:

@mock.patch('google.auth._helpers.utcnow')
def test_refresh_success(mock_utcnow):
    mock_utcnow.return_value = datetime.datetime.min
    ...

Назовите просто utcnow:

@mock.patch('google.auth._helpers.utcnow')
def test_refresh_success(utcnow):
    utcnow.return_value = datetime.datetime.min
    ...

Если вы используете patch в качестве менеджера контекста, давать имя x_patch вполне нормально: 

utcnow_patch = mock.patch('google.auth._helpers.utcnow')
with utcnow_patch as utcnow:
    utcnow.return_value = datetime.datetime.min
    ...

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

Кроме того, если вы используете декоратор patch и не используете мок, можно назвать его unused_x:

@mock.patch('google.auth._helpers.utcnow')
def test_refresh_success(unused_utcnow):
    ...

Используйте фабрики и helper-ы для создания сложных коллабораторов

Иногда в тестах нужна комплексная настройка. В целом, отдавайте предпочтение helper-ам и фабрикам для создания коллабораторов. Например, эта фабрика создает моковый http-объект, который возвращает специальный ответ:

def make_http(data, status=http_client.OK, headers=None):
    response = mock.create_autospec(transport.Response, instance=True)
    response.status = status
    response.data = _helpers.to_bytes(data)
    response.headers = headers or {}

    http = mock.create_autospec(transport.Request)
    http.return_value = response

    return request

Его можно использовать из нескольких тестов для проверки поведения:

def test_refresh_success():
    http = make_http('OK')
    assert refresh(http)

def test_refresh_failure():
    http = make_http('Not Found', status=http_client.NOT_FOUND)
    with pytest.raises(exceptions.TransportError):
        refresh(http)

Пользуйтесь фикстурами экономно

Фикстуры pytest (https://docs.pytest.org/en/latest/fixture.html) – это отличный способ переиспользования логики настройки и прерывания. В целом, преимущество лучше отдавать helper-ам и фабрикам, поскольку так проще думать и передавать аргументы в методы helper. Фикстуры отлично подходят для аспектов, которые идентичны для всех тестов и требуют как логики настройки, так и логики прерывания. Например, вот фикстура, которая запускает веб-сервер в фоновом режиме для каждого теста и выключает его после окончания теста:

@pytest.fixture()
def server():
    server = WSGIServer(application=TEST_APP)
    server.start()
    yield server
    server.stop()

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

@pytest.fixture()
def database():
    db = database.Client()
    yield db
    db.delete(db.list_all())

Еще одна веская причина использовать фикстуры – это внедрение зависимостей, например, если вы тестируете несколько реализаций абстрактного класса. Опять же, это больше история про системные тесты. Например, следующая фикстура гарантирует, что все тесты выполняются через urllib3 и отправляют запросы к transport:

@pytest.fixture(params=['urllib3', 'requests'])
def http_request(request):
    if request.param == 'urllib3':
        yield google.auth.transport.urllib3.Request(URLLIB3_HTTP)
    elif request.param == 'requests':
        yield google.auth.transport.requests.Request(REQUESTS_SESSION)

В 2021 году уже никого не удивить приложениями в контейнерах, но многие наши коллеги все ещё предпочитают олдскульный способ для развертывания Django приложений. На вебинаре мы расскажем, как за всего лишь час с небольшим «упаковать» любое Django приложение в Docker, объясним почему это важно, и поделимся лайфаками о том, как в процессе не выстрелить себе в ногу. Регистрируйтесь на открытый урок.

Перевод подготовлен в рамках онлайн-курса "Python Developer. Professional"