Компания
596,56
рейтинг
10 ноября 2014 в 16:47

Разработка → Как в Яндексе используют PyTest и другие фреймворки для функционального тестирования

Всем привет! Меня зовут Сергей, и в Яндексе я работаю в команде автоматизации тестирования сервисов монетизации. Перед каждой командой, которая занимается задачами автоматизации тестирования, встает вопрос: «Какой [фреймворк|инструмент] выбрать для написания своих тестов?» В этом посте я хочу помочь вам на него ответить. Если быть конкретнее, речь пойдет об инструментах тестирования на языке Python, но многие из идей и выводов можно распространить на другие языки программирования, поскольку подходы часто не зависят от конкретной технологии.



В Python существует множество инструментов для написания тестов и выбор между ними неочевиден. Я опишу интересные варианты использования PyTest и расскажу о его [плюсах|минусах|неявных возможностях]. В статье вы найдёте развёрнутый пример использования Allure, который служит для создания простых и понятных отчётов автотестов. Также в примерах будет применяться фреймворк для написания матчеров — Hamcrest для Python. Надеюсь, что в итоге, те, кто сейчас в поиске инструментов для тестирования, смогут на основе изложенных примеров быстро внедрить функциональное тестирование в своем окружении. Те же, кто уже использует какой-то инструмент, смогут узнать новые подходы, варианты использования и концепции.

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

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

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

В своё время была возможность поэкспериментировать, и выбор пал на перспективный фреймворк PyTest. Тогда он ещё не был таким популярным, и его мало кто использовал. Нам понравилась концепция использования фикстур и написание тестов в виде обычного Python-модуля без использования API. В итоге PyTest выстрелил, и теперь мы имеем очень гибкое решение с множеством фич, например:

  • фикстуры в виде аргументов тестовых функций, которые позволяют отделить вспомогательную функциональность от самого теста;
  • встроенный assert, который отображает ошибку в удобном виде;
  • pytest.mark.parametrize для запуска тестов на разных наборах данных без дублирования кода;
  • возможность ставить метки на тесты, чтобы помечать падающие тесты или выделять долгоиграющие и запускать их отдельно;
  • поддержка JUnit отчетов с помощью аргумента --junit-xml, кроме этого способность генерировать отчеты в другом формате.

Теперь более подробно поговорим о том, как в PyTest работают фикстуры, параметризация, маркировка. Как с помощью PyHamcrest фреймворка писать свои матчеры и создавать результирующие отчеты, используя Allure.

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


Фикстуры


В общепринятом смысле, фикстура — это фиксированное состояние стенда, на котором выполняются тесты. Это так же относится к действию, приводящему систему в определенное состояние.

В pytest фикстурой называют функцию, обёрнутую в декоратор @pytest.fixture. Сама функция выполняется в тот момент, когда она нужна (перед тестовым классом, модулем или функцией) и когда возвращенное ей значение доступно в самом тесте. При этом фикстуры могут использовать другие фикстуры, кроме того можно определять время существования конкретной фикстуры: в текущей сессии, модуле, классе или функции. Они помогают нам содержать тесты в модульном виде. А при тестировании интеграции повторно использовать их из соседних тестовых библиотек. Гибкость и удобство их использования были одними из основных критериев выбора именно pytest. Чтобы воспользоваться фикстурой, нужно указать её имя в качестве параметра к тесту.

Фикстуры приходят на помощь, когда нужно:
  • сформировать тестируемые данные;
  • подготовить тестируемый стенд;
  • поменять поведение стенда;
  • написать setUp/tearDown;
  • собирать логи сервисов или crashdump;
  • использовать эмуляторы систем или заглушки;
  • и многое другое.

Тестируемый сервер


Далее в примерах будут описаны тесты, которые проверяют функционал веб-сервера на Flask, ожидающий соединения на 8081 порту и принимающий GET запросы. Сервер берет строчку из параметра text и в ответе меняет каждое слово на его слово-перевертыш. Отдаётся json, если клиент умеет его принимать:

import json
from flask import Flask, request, make_response as response

app = Flask(__name__)


@app.route("/")
def index():
    text = request.args.get('text')
    json_type = 'application/json'
    json_accepted = json_type in request.headers.get('Accept', '')
    if text:
        words = text.split()
        reversed_words = [word[::-1] for word in words]
        if json_accepted:
            res = response(json.dumps({'text': reversed_words}), 200)
        else:
            res = response(' '.join(reversed_words), 200)
    else:
        res = response('text not found', 501)
    res.headers['Content-Type'] = json_type if json_accepted else 'text/plain'
    return res

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=8081)


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

import pytest
import socket as s


@pytest.fixture
def socket(request):
    _socket = s.socket(s.AF_INET, s.SOCK_STREAM)
    def socket_teardown():
        _socket.close()
    request.addfinalizer(socket_teardown)
    return _socket


def test_server_connect(socket):
    socket.connect(('localhost', 8081))
    assert socket


Но лучше воспользоваться новым декоратором yield_fixture, который представляет фикстуру в виде контекст-менеджера, реализующего setUP/tearDown и возвращающего объект.

@pytest.yield_fixture
def socket():
    _socket = s.socket(s.AF_INET, s.SOCK_STREAM)
    yield _socket
    _socket.close()


Использование yield_fixture выглядит лаконичнее и понятнее. Надо отметить, что фикстуры по умолчанию имеют время существования scope=function. Это означает, что каждый запуск теста со своими параметрами вызывает новый экземпляр фикстуры.

Напишем для нашего теста фикстуру Server, описывающую, где находится тестируемый веб-сервер. Так как она возвращает объект, который хранит в себе статичную информацию, и нам не нужно генерировать это каждый раз, то поставим ей scope=module. Результат, который эта фикстура сгенерирует, закэшируется и будет существовать все время запуска текущего модуля:

@pytest.fixture(scope='module')
def Server():
    class Dummy:
        host_port = 'localhost', 8081
        uri = 'http://%s:%s/' % host_port
    return Dummy


def test_server_connect(socket, Server):
    socket.connect(Server.host_port)
    assert socket


Также есть scope=session и scope=class — время существования фикстуры. И нельзя использовать внутри фикстуры с высоким уровнем фикстуры с более низким значением scope=.

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

@pytest.yield_fixture(scope='function', autouse=True)
def collect_logs(request):
    if 'Server' in request.fixturenames:
        with some_logfile_collector(SERVER_LOCATION):
            yield
    else:
        yield


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

@pytest.yield_fixture
def reset_shifted_time(Service):
    yield
    Service.set_time(datetime.datetime.now())


@pytest.mark.usefixtures("reset_shifted_time")
class TestWithShiftedTime():
    def test_shift_milesecond(self, Service):
        Service.set_time()
        assert ...
    def test_shift_time_far_far_away(self, Service):
        Service.set_time()
        assert ...


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

Матчеры


Очевидно, что тесты без проверок никому не нужны. При использовании pytest можно делать проверки самым простым способом — с помощью такого матчера, как assert. Assert — это стандартная инструкция в Python, которая проверяет утверждение, описанное в нем. Мы придерживаемся правила «В одном тесте — один assert». Оно позволяет тестировать определенную функциональность, не затрагивая шаги подготовки данных или приведение сервиса в нужное состояние. Если же в тесте используются шаги подготовки данных, которые могут вызвать ошибку, то лучше для них написать отдельный тест. Используя данную структуру, мы описываем ожидаемое поведение системы.

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

Например, следующий тест:
def test_dict():
    assert dict(foo='bar', baz=None).items() == list({'foo': 'bar'}.iteritems())

вернет развёрнутый ответ о том, где ошибка:
E       assert [('foo', 'bar...('baz', None)] == [('foo', 'bar')]
E         Left contains more items, first extra item: ('baz', None)


В тесте, проверяющем наш Тестируемый сервер на Flask, перепишем проверку внутри метода test_server_connect для более точного определения, что мы не ожидаем определенный exception. Для этого воспользуемся фреймворком PyHamcrest:
from hamcrest import *

SOCKET_ERROR = s.error

def test_server_connect(socket, Server):
    assert_that(calling(socket.connect).with_args(Server.host_port), is_not(raises(SOCKET_ERROR)))


PyHamcrest позволяет совмещать встроенные в него матчеры. Скомбинировав таким образом has_property и contains_string, получим удобные для использования простые матчеры:

def has_content(item):
    return has_property('text', item if isinstance(item, BaseMatcher) else contains_string(item))

def has_status(status):
    return has_property('status_code', equal_to(status))


Далее нам потребуется написать матчеры, которые модифицируют проверяемое значение и передают его следующему указанному матчеру. Для этого напишем класс BaseModifyMatcher, формирующий такой матчер на основе атрибутов класса: description — описание матчера, modify — функция-модификатор проверяемого значения, instance — тип класса, который ожидается в модификаторе:

from hamcrest.core.base_matcher import BaseMatcher


class BaseModifyMatcher(BaseMatcher):
    def __init__(self, item_matcher):
        self.item_matcher = item_matcher

    def _matches(self, item):
        if isinstance(item, self.instance) and item:
            self.new_item = self.modify(item)
            return self.item_matcher.matches(self.new_item)
        else:
            return False

    def describe_mismatch(self, item, mismatch_description):
        if isinstance(item, self.instance) and item:
            self.item_matcher.describe_mismatch(self.new_item, mismatch_description)
        else:
            mismatch_description.append_text('not %s, was: ' % self.instance) \
                                .append_text(repr(item))

    def describe_to(self, description):
        description.append_text(self.description) \
                   .append_text(' ') \
                   .append_description_of(self.item_matcher)


Мы знаем, что тестируемый сервер формирует ответ из перевернутых слов, переданных ему в параметре text. Используя BaseModifyMatcher, напишем матчер, который получит список из обычных слов и будет ожидать в ответе строчку из перевернутых слов:
rom hamcrest.core.helpers.wrap_matcher import wrap_matcher

reverse_words = lambda words: [word[::-1] for word in words]


def contains_reversed_words(item_match):
    """
    Example:
        >>> from hamcrest import *
        >>> contains_reversed_words(contains_inanyorder('oof', 'rab')).matches("foo bar")
        True
    """
    class IsStringOfReversedWords(BaseModifyMatcher):
        description = 'string of reversed words'
        modify = lambda _, item: reverse_words(item.split())
        instance = basestring

    return IsStringOfReversedWords(wrap_matcher(item_match))


Следующий матчер, использующий BaseModifyMatcher, будет проверять наличие строчки содержащей json:

import json as j

def is_json(item_match):
    """
    Example:
        >>> from hamcrest import *
        >>> is_json(has_entries('foo', contains('bar'))).matches('{"foo": ["bar"]}')
        True
    """
    class AsJson(BaseModifyMatcher):
        description = 'json with'
        modify = lambda _, item: j.loads(item)
        instance = basestring

    return AsJson(wrap_matcher(item_match))


Дополним тест, проверяющий наш Тестируемый сервер на Flask, ещё двумя тестами, которые будут проверяют, что формирует сервер в ответе при разных запросах. Для этого воспользуемся описанными выше матчерами has_status, has_content и contains_reversed_words:

def test_server_response(Server):
    assert_that(requests.get(Server.uri), all_of(has_content('text not found'), has_status(501)))

def test_server_request(Server):
    text = 'Hello word!'
    assert_that(requests.get(Server.uri, params={'text': text}), all_of(
        has_content(contains_reversed_words(text.split())),
        has_status(200)
    ))

Про Hamcrest можно почитать на Хабре. Ещё стоит обратить внимание на should-dsl.

Параметризация



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

В PyTest параметризировать тесты нужно с помощью специального декоратора @pytest.mark.parametrize. Можно указать несколько параметров в одном parametrize. Если же параметры разбить на несколько parametrize, то они перемножаются.

Держать статические данные внутри теста — не очень хорошая практика. В примере теста который проверяет наш Тестируемый сервер на Flask, стоит параметризовать метод test_server_request, описав варианты параметра text:

@pytest.mark.parametrize('text', ['Hello word!', ' 440 005 ', 'one_word'])
def test_server_request(text, Server):
    assert_that(requests.get(Server.uri, params={'text': text}), all_of(
        has_content(contains_reversed_words(text.split())),
        has_status(200)
    ))

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


class DefaultCase:
    def __init__(self, text):
        self.req = dict(
            params={'text': text},
            headers={},
        )
        self.match_string_of_reversed_words = all_of(
            has_content(contains_reversed_words(text.split())),
            has_status(200),
        )

class JSONCase(DefaultCase):
    def __init__(self, text):
        DefaultCase.__init__(self, text)
        self.req['headers'].update({'Accept': 'application/json'})

        self.match_string_of_reversed_words = all_of(
            has_content(is_json(has_entries('text', contains(*reverse_words(text.split()))))),
            has_status(200),
        )


@pytest.mark.parametrize('case', [testclazz(text)
                                  for text in 'Hello word!', ' 440 005 ', 'one_word'
                                  for testclazz in JSONCase, DefaultCase])
def test_server_request(case, Server):
    assert_that(requests.get(Server.uri, **case.req), case.match_string_of_reversed_words)



Если запустить такой параметризованный тест с помощью команды py.test -v test_server.py, получим отчет:

$ py.test -v test_server.py
============================= test session starts =============================
platform linux2 -- Python 2.7.3 -- py-1.4.20 -- pytest-2.5.2 -- /usr/bin/python
plugins: timeout, allure-adaptor
collected 8 items

test_server.py:26: test_server_connect PASSED
test_server.py:89: test_server_response PASSED
test_server.py:109: test_server_request[case0] PASSED
test_server.py:109: test_server_request[case1] PASSED
test_server.py:109: test_server_request[case2] PASSED
test_server.py:109: test_server_request[case3] PASSED
test_server.py:109: test_server_request[case4] PASSED
test_server.py:109: test_server_request[case5] PASSED

========================== 8 passed in 0.11 seconds ===========================


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

Чтобы вывод был более понятным, нужно реализовать метод __repr__ для класса Case и написать вспомогательный декоратор idparametrize, в котором воспользуемся дополнительным параметром ids= декоратора pytest.mark.parametrize:

def idparametrize(name, values, fixture=False):
    return pytest.mark.parametrize(name, values, ids=map(repr, values), indirect=fixture)

class DefaultCase:
    def __init__(self, text):
        self.text = text
        self.req = dict(
            params={'text': self.text},
            headers={},
        )
        self.match_string_of_reversed_words = all_of(
            has_content(contains_reversed_words(self.text.split())),
            has_status(200),
        )

    def __repr__(self):
        return 'text="{text}", {cls}'.format(cls=self.__class__.__name__, text=self.text)

class JSONCase(DefaultCase):
    def __init__(self, text):
        DefaultCase.__init__(self, text)
        self.req['headers'].update({'Accept': 'application/json'})

        self.match_string_of_reversed_words = all_of(
            has_content(is_json(has_entries('text', contains(*reverse_words(text.split()))))),
            has_status(200),
        )

@idparametrize('case', [testclazz(text)
                        for text in 'Hello word!', ' 440 005 ', 'one_word'
                        for testclazz in JSONCase, DefaultCase])
def test_server_request(case, Server):
    assert_that(requests.get(Server.uri, **case.req), case.match_string_of_reversed_words)


$ py.test -v test_server.py
============================= test session starts =============================
platform linux2 -- Python 2.7.3 -- py-1.4.20 -- pytest-2.5.2 -- /usr/bin/python
plugins: ordering, timeout, allure-adaptor, qabs-yadt
collected 8 items

test_server.py:26: test_server_connect PASSED
test_server.py:89: test_server_response PASSED
test_server.py:117: test_server_request[text="Hello word!", JSONCase] PASSED
test_server.py:117: test_server_request[text="Hello word!", DefaultCase] PASSED
test_server.py:117: test_server_request[text=" 440 005 ", JSONCase] PASSED
test_server.py:117: test_server_request[text=" 440 005 ", DefaultCase] PASSED
test_server.py:117: test_server_request[text="one_word", JSONCase] PASSED
test_server.py:117: test_server_request[text="one_word", DefaultCase] PASSED

========================== 8 passed in 0.12 seconds ===========================


Если посмотреть на код декоратора idparametrize и обратить внимание на параметр fixture, то видно, что можно параметризировать и фикстуры. В следующем примере проверим, что сервер отвечает правильно, как на реальном ip, так и на локальном. Для этого нужно немного подправить фикстуру Server, чтобы она умела принимать параметры:

from collections import namedtuple

Srv = namedtuple('Server', 'host port')
REAL_IP = s.gethostbyname(s.gethostname())


@pytest.fixture
def Server(request):
    class Dummy:
        def __init__(self, srv):
            self.srv = srv

        @property
        def uri(self):
            return 'http://{host}:{port}/'.format(**self.srv._asdict())
    return Dummy(request.param)


@idparametrize('Server', [Srv('localhost', 8081), Srv(REAL_IP, 8081)], fixture=True)
@idparametrize('case', [Case('Hello word!'), Case('Hello word!', json=True)])
def test_server_request(case, Server):
    assert_that(requests.get(Server.uri, **case.req), case.match_string_of_reversed_words)


Маркировка



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

В pytest тесты и параметры тестов можно помечать с помощью специального декоратора @pytest.mark.MARK_NAME. Например, каждый тестпак может идти по несколько минут, а то и более. Поэтому хотелось бы прогнать сначала критичные тесты и потом уже остальные:

@pytest.mark.acceptance
def test_server_connect(socket, Server):
    assert_that(calling(socket.connect).with_args(Server.host_port), is_not(raises(SOCKET_ERROR)))

@pytest.mark.acceptance
def test_server_response(Server):
    assert_that(requests.get(Server.uri), all_of(has_content('text not found'), has_status(501)))

@pytest.mark.P1
def test_server_404(Server):
    assert_that(requests.get(Server.uri + 'not_found'), has_status(404))

@pytest.mark.P2
def test_server_simple_request(Server, SlowConnection):
    with SlowConnection(drop_packets=0.3):
        assert_that(requests.get(Server.uri + '?text=asdf'), has_content('fdsa'))


Тесты с такими маркировками можно использовать в CI. Например, в Jenkins можно создать multi-configuration project. Для этой задачи в разделе Configuration Matrix определяем User-defined Axis как TESTPACK, содержащую ['acceptance', 'P1', 'P2', 'other']. Эта задача запускает тесты по очереди, причем, первым будут запущены acceptance тесты, и их успешное выполнение будет условием для запуска других тестов:

#!/bin/bash
PYTEST="py.test $WORKSPACE/tests/functional/ $TEST_PARAMS --junitxml=report.xml --alluredir=reports"
if [ "$TESTPACK" = "other" ]
then
  $PYTEST -m "not acceptance and not P1 and not P2" || true
else
  $PYTEST -m $TESTPACK || true
fi

Другой тип маркировки — пометить тест как xfail. Кроме как пометить весь тест, можно помечать параметры тестов. Так в следующем примере при указании ipv6 адреса host='::1',, сервер не отвечает. Для решения этой проблемы нужно в коде сервера вместо 0.0.0.0 использовать ::. Мы пока не будем это исправлять, чтобы посмотреть, как наш тест реагирует на такую ситуацию. Дополнительно можно описать причину в опциональном параметре reason. Этот текст появится в отчете о запуске:

@pytest.yield_fixture
def Server(request):
    class Dummy:
        def __init__(self, srv):
            self.srv = srv
            self.conn = None

        @property
        def uri(self):
            return 'http://{host}:{port}/'.format(**self.srv._asdict())

        def connect(self):
            self.conn = s.create_connection((self.srv.host, self.srv.port))
            self.conn.sendall('HEAD /404 HTTP/1.0\r\n\r\n')
            self.conn.recv(1024)

        def close(self):
            if self.conn:
                self.conn.close()

    res = Dummy(request.param)
    yield res
    res.close()

SERVER_CASES = [
    pytest.mark.xfail(Srv('::1', 8081), reason='ipv6 desn`t work, use `::` instead of `0.0.0.0`'),
    Srv(REAL_IP, 8081),
]
@idparametrize('Server', SERVER_CASES, fixture=True)
def test_server(Server):
    assert_that(calling(Server.connect), is_not(raises(SOCKET_ERROR)))

Тесты и параметры можно пометить с помощью метки pytest.mark.skipif(). Она позволяет пропускать указанные тесты, используя определенное условие.

Выполнение и отладка


Запуск


Запустить тесты можно разными способами. Просто командой py.test, либо как модуль python -m pytest.

При запуске pytest
  • начинает сбор тестов, используя аргументы командной строки, которые указывают на директории или пути к файлам;
  • продолжает обзор рекурсивно внутри директорий, пока на наткнется на параметр norecursedirs;
  • все файлы, которые совпадают с test_*.py или *_test.py;
  • классы в имени, начинающиеся с Test, у которых нет метода __init__;
  • функции или методы классов, в именах которых стоит префикс test_;

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

[tox]
envlist=py27

[testenv]
deps=
    builders
    pytest
    pytest-allure-adaptor
    PyHamcrest
commands=
  py.test tests/functional/ \
    --junitxml=report.xml \
    --alluredir=reports \
    --verbose \
    {posargs}


И далее одной командой запускам тесты: tox. Он сделает свой virtualenv в папке .tox, подтянет туда нужные для запуска тестов зависимости и в итоге запустит pytest с указанными в конфиге параметрами параметрами.

Альтернативно, если оформить тесты в виде модуля для python, то можно запускать python setup.py test. Для этого нужно оформить ваш setup.py в соответствии с документацией.

Указывая docstring, как в примере выше про Allure, можно использовать pytest для проверки доктестов. Благо pytest имеет поддержку doctest, pep8, unittest и nose: py.test --pep8 --doctest-modules -v --junit-xml report.xml self_tests/ ft_lib/

Дополнительно хотелось бы отметить, что pytest умеет запускать UnitTest и nose тесты.

Отладка


pudb

Как и обычный код, тесты нуждаются в отладке. Обычно она используется, если по stacktrace все ещё непонятно, почему тест упал со статусом ERROR. В pytest для этого существуют несколько подходов:
  • плагин для PyCharm;
  • написав pytest.set_trace() в любом месте вашего теста, можно сразу вываливаться в pdb в указанном месте;
  • можно просто настроить в своем IDE запуск с отладкой;
  • воспользоваться параметром --pdb, который запустит отладчик при возникновении ошибки;
  • либо писать import pudb;pudb.set_trace() перед подозрительными местами (главное при этом нужно не забыть добавить параметр -s в строку запуска теста).


Параметры pytest, которые помогают отлаживать тесты:
  • -k когда вам нужно запустить какой-то отдельный тест. При этом надо учитывать, что если вы хотите запустить два теста или использовать дополнительные фильтры, то нужно соблюдать новый синтаксис этого параметра. py.test -k "prepare or http and proxy" tests/functional/;
  • -x когда вам нужно прекратить выполнение тестов при первом упавшем тесте или ошибке;
  • --collect-only когда вам нужно проверить правильность и количество сгенерированных параметров к тестам и сам список тестов, которые будут запущены (похоже на dry-run);
  • --no-magic как бы намекает нам, что тут есть магия :)


Анализ результатов


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

У pytest есть хороший генератор JUnit отчетов, который очень просто интегрируется в Jenkins-CI. Мы пользовались им, пока не появился замечательный фреймворк Allure, для формирования более красивых отчетов с дополнительными возможностями.

Список статусов запуска теста в pytest:
  • PASSED — зелененький, успех, тест пройден;
  • FAILED — красненький, тест упал, ошибку вызвала конструкция assert;
  • ERROR — ошибка в тесте, произошла ошибка в фикстуре либо в синтаксисе и др., обычно не связано с конкретным тест кейсом;
  • SKIPPED — игнор, тест помечен каким либо образов depends или pytest.mark.skip[if] и он не запускается;
  • xfail — тест помечен и ожидаемое падение произошло, assert сработал как и ожидалось;
  • XPASS — тест, помеченный как xfail не упал. Плохо это или хорошо, нужно проверять тестировщику, и либо убирать метку, либо чинить параметры.


Чтобы начать использовать Allure вместе с PyTest, вам потребуется Allure плагин для PyTest. В нем есть такие возможности, как:
  • использование Step'ов для выделения стадий при работе теста;
  • генерация description в заголовки каждого теста по его docstring;
  • создание attachment'ов, содержащих данные для последующего анализа;
  • и другое.


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

Attachment'ы — это простое хранилище информации внутри отчета, в которое можно записать практически любые данные. Например, логи веб-сервера, отправленные запросы и полученные данные. Они удобно группируются с помощью Step'ов. Отображение сохраненных данных в отчете можно поменять с помощью параметра type. На данный момент attachemnt'ы могут понимать следующие типы: txt, html, xml, png, jpg, json.

В следующем примере используется специальная фикстура error_if_wat, которая падает с ошибкой, если в параметрах есть ERROR_CONDITION. Она получает данные из соседней фикстуры Server. Тест разделен на два шага allure.step. Первый проверяет соединение с помощью socket. Второй проверяет ответ сервера с помощью requests. Внутри второго шага мы сохраняем полученные от сервера данные используя allure.attach. Описание тесткейса с помощью docstring, позволит нам понять в сгенерированном отчете, что же делает данный тест.

import allure

ERROR_CONDITION = None

@pytest.fixture
def error_if_wat(request):
    assert request.getfuncargvalue('Server').srv != ERROR_CONDITION

SERVER_CASES = [
    pytest.mark.xfail(Srv('::1', 8081), reason='ipv6 desn`t work, use `::` instead of `0.0.0.0`'),
    Srv('127.0.0.1', 8081),
    Srv('localhost', 80),
    ERROR_CONDITION,
]
@idparametrize('Server', SERVER_CASES, fixture=True)
def test_server(Server, error_if_wat):
    assert_that(calling(Server.connect), is_not(raises(SOCKET_ERROR)))
    """
    Step 1:
        Try connect to host, port,
        and check for not raises SOCKET_ERROR.

    Step 2:
        Check for server response 'text not found' message.
        Response status should be equal to 501.
    """
    with allure.step('Try connect'):
        assert_that(calling(Server.connect), is_not(raises(SOCKET_ERROR)))

    with allure.step('Check response'):
        response = requests.get(Server.uri)
        allure.attach('response_body', response.text, type='html')
        allure.attach('response_headers', j.dumps(dict(response.headers), indent=4), type='json')
        allure.attach('response_status', str(response.status_code))
        assert_that(response, all_of(has_content('text not found'), has_status(501)))


Запускается с помощью py.test --alluredir=/var/tmp/allure/ test_server.py.

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

sudo add-apt-repository ppa:yandex-qatools/allure-framework
sudo apt-get install yandex-allure-cli
allure generate -o /var/tmp/allure/output/ -- /var/tmp/allure/

В каталоге /var/tmp/allure/output будет сформирована структура файлов содержащая в себе детальный отчет. Для его отображения достаточно открыть файл index.html.

allure passed with headersallure passed with headers

Если вы открываете отчет локально и у вас в браузере только серое окно с иконками, то вам нужно поправить политику безопасности, так как allure использует javascript для отображения отчета.

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

  • PyTest-localserver незаменим, когда нужно эмулировать какой либо из сервисов.
  • PyTest-timeout используем для тесткейсов, которые должны проверять таймауты сервисов.
  • PyTest-xdist — musthave, если вы хотите распараллелить запуск тестов.
  • PyTest-capturelog удобен при тестировании кода, который использует модуль logging.
  • PyTester используем постоянно, когда нужно проверить матчеры или написать тест на фикстуру и др. Уже входит в состав pytest.
  • PyTest-httpretty перехватывает запросы на определенный uri, позволяя подставляет заранее заданный ответ. Вместо него используем pytest-localserver
  • PyTest-ordering — отказались от использования. Как-то потребовалось сконфигурировать запуск тестов в определенной последовательности.
  • PyTest-incremental удобно, если вы используете статичный сервер, на котором крутятся тесты, и хотите, чтобы тесты запускались после изменений в коде. Мы перешли на Jenkins.


Кроме этого, существует хорошая документация для PyTest. Также советую вам посмотреть несколько статей про его использование.
Автор: @serge0
Яндекс
рейтинг 596,56

Комментарии (10)

  • +2
    По поводу открывания отчета локально через CLI. В последних версиях клиента еще есть вот такая команда:
    $ allure report open
    

    Она умеет сама поднимать встроенный веб-сервер на случайном свободном порту, чтобы избежать данной проблемы с политикой безопасности в Chrome.
    • 0
      Класс, а то я для этого на удаленной машинке в папке output/ запускал:
      python -m SimpleHTTPServer
  • +1
    Ребята, спасибо за подробную статью. Это круто.
    Пользуясь случаем хочу спросить про досадный баг несовместимости аллюра и xdist. Есть какие-либо подвижки по нему или известны какие-нибудь воркэраунды?
    • 0
      Знаю только, что разработчик pytest-allure-adaptor обещает починить этот баг в ближайшее время.
      • 0
        По крайней мере, так в тикете сказано
  • +7
    Вот что мне нравится в Яндексе, что вы активно пользуетесь, внедряете и поддерживаете открытые решения в своей деятельности, вместо закрытых коммерческих, часто бешено дорогих, решений, а так же разрабатываете и делитесь с сообществом своими решениями, продвигая и развивая тем самым опен сорс и свободу!
    • +1
      Сомневаюсь, что в мире существует «бешено дорогой» фреймворк для тестирования на Python. Да вообще не уверен, что есть хоть один не оперсорс и бесплатный.
      • –1
        Я не конкретно про фреймворк для тестирования на питоне, я в целом имею ввиду :)
  • +1
    Я мне понравилась завлекающая картинка
  • 0
    Битая ссылка:
    Для этого нужно оформить ваш setup.py в соответствии с документацией

    Нужно поправить на эту:
    https://pytest.org/latest/goodpractices.html#integrating-with-setuptools-python-setup-py-test-pytest-runner

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

Самое читаемое Разработка