Пользователь
0,0
рейтинг
29 октября 2015 в 12:15

Разработка → PyTest из песочницы

Предисловие


По историческому призванию я SQL-щик. Однако судьба занесла меня на BigData и после этого понесла кривая — я освоил и Java, и Python, и функциональное программирование (изучение Scala стоит в списке). Собственно на одном из кусков проекта встала необходимость тестирования кода на Python. Ребята из QA посоветовали для этих целей PyTest, но даже они затруднились толком ответить чем этот зверь хорош. К сожалению, в русскоязычном сегменте информации по данному вопросу не так уж и много: как это используют в Yandex да и все по-хорошему. При этом описанное в этой статье выглядит достаточно сложно для человека начинающего путешествие по этой стезе. Не говоря уже об официальной документации — она приобрела для меня смысл лишь после того, как я разобрался с самим модулем по другим источникам. Не спорю, там написаны интересные вещи, но, к сожалению, совсем не для старта.

Юнит-тестирование Python


Что это и для чего рассказывать смысла не вижу — Википедия все равно знает больше. По поводу существующих модулей для Python хорошо описано на Хабре.

Вводная по необходимым знаниям


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

Преимущества и недостатки PyTest


1) Независимость от API (no boilerplate). Как код выглядит в том же unittest:

Код
import unittest

class TestUtilDate(unittest.TestCase):
    def setUp(self):
        #init_something()
        pass
        
    def tearDown(self):
        #teardown_something()
        pass
        
    def test_upper(self):
        self.assertEqual('foo'.upper(), 'FOO')
        
    def test_isupper(self):
        self.assertTrue('FOO'.isupper())
        
    def test_failed_upper(self):
        self.assertEqual('foo'.upper(), 'FOo')
        
if __name__ == '__main__':
    suite = unittest.TestLoader().loadTestsFromTestCase(TestUtilDate)
    unittest.TextTestRunner(verbosity=2).run(suite)


То же самое в PyTest:

Код
import pytest

def setup_module(module):
    #init_something()
    pass

def teardown_module(module):
    #teardown_something()
    pass

def test_upper():
    assert 'foo'.upper() == 'FOO'
    
def test_isupper():
    assert 'FOO'.isupper()
    
def test_failed_upper():
    assert 'foo'.upper() == 'FOo'


2) Подробный отчет. В том числе выгрузка в JUnitXML (для интеграции с Jenkins). Сам вид отчета может изменяться (включая цвета) дополнительными модулями (о них будет позднее отдельно). Ну и вообще цветной отчет в консоли выглядит удобнее — красные FAILED видны сразу.

image

3) Удобный assert (стандартный из Python). Не приходится держать в голове всю кучу различных assert'ов.

4) Динамические фикстуры всех уровней, которые могут вызываться как автоматически, так и для конкретных тестов.

5) Дополнительные возможности фикстур (возвращаемое значение, финализаторы, область видимости, объект request, автоиспользование, вложенные фикстуры)

6) Параметризация тестов, то есть запуск одного и того же теста с разными наборами параметров. Вообще это относится к пункту 5 «Дополнительные возможности фикстур», но возможность настолько хороша, что достойна отдельного пункта.

7) Метки (marks), позволяющие пропустить любой тест, пометить тест, как падающий (и это его ожидаемое поведение, что полезно при разработке) или просто именовать набор тестов, чтобы можно было запускать только его по имени.

8) Плагины. Данный модуль имеет достаточно большой список дополнительных модулей, которые можно установить отдельно.

9) Возможность запуска тестов написанных на unittest и nose, то есть полная обратная совместимость с ними.

Про недостатки, пусть их и не много, могу сказать следующее:

1) Отсутствие дополнительного уровня вложенности: Для модулей, классов, методов, функций в тестах есть соответствующий уровень. Но логика требует наличие дополнительного уровня testcase, когда та же одна функция может иметь несколько testcase'ов (например, проверка возращаемых значений и ошибок). Это частично компенсируется дополнительным модулем (плагином) pytest-describe, но там встает проблема отсутствия соответствующего уровня фикстуры (scope = “describe”). С этим конечно можно жить, но в некоторых ситуациях может нарушать главный принцип PyTest — «все для простоты и удобства».

2) Необходимость отдельной установки модуля, в том числе в продакшене. Все-таки unittest и doctest входят в базовый инструментарий Python и не требуют дополнительных телодвижений.

3) Для использования PyTest требуется немного больше знаний Python, чем для того же unittest (см. «Вводная по необходимым знаниям»).

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

Запуск тестов


Для unittest используется вызов функции main. Поэтому запуск имеет вид «python unittest_example.py». При этом для запуска набора тестов приходится отдельно объединять их в TestSuit и запускать через него. PyTest собирает все тесты сам по имени test_* (Test_* для имени классов) для всех файлов в папке (рекурсивно обходя вложенные папки) или же для указанного файла. То есть пример вызова будет иметь вид «py.test -v pytest_example.py»

Базовые фикстуры


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

import pytest

def setup():
    print ("basic setup into module")
 
def teardown():
    print ("basic teardown into module")

def setup_module(module):
    print ("module setup")
 
def teardown_module(module):
    print ("module teardown")
 
def setup_function(function):
    print ("function setup")
 
def teardown_function(function):
    print ("function teardown")
 
def test_numbers_3_4():
    print "test 3*4"
    assert 3*4 == 12 
 
def test_strings_a_3():
    print "test a*3"
    assert 'a'*3 == 'aaa' 
  
class TestUM:
    def setup(self):
        print ("basic setup into class")
 
    def teardown(self):
        print ("basic teardown into class")
 
    def setup_class(cls):
        print ("class setup")
 
    def teardown_class(cls):
        print ("class teardown")
 
    def setup_method(self, method):
        print ("method setup")
 
    def teardown_method(self, method):
        print ("method teardown")
 
    def test_numbers_5_6(self):
        print "test 5*6"
        assert 5*6 == 30 
 
    def test_strings_b_2(self):
        print "test b*2"
        assert 'b'*2 == 'bb'

Чтобы увидеть весь вывод выдаваемый командой print, необходимо запускать тест с флагом -s:

tmp>py.test -s basic_fixtures.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1
rootdir: tmp/, inifile:
collected 4 items

basic_fixtures.py module setup
function setup
basic setup into module
test 3*4
.basic teardown into module
function teardown
function setup
basic setup into module
test a*3
.basic teardown into module
function teardown
class setup
method setup
basic setup into class
test 5*6
.basic teardown into class
method teardown
method setup
basic setup into class
test b*2
.basic teardown into class
method teardown
class teardown
module teardown
========================== 4 passed in 0.03 seconds


Данный пример достаточно полно показывает иерархию и повторяемость каждого уровня фикстур (например, setup_function вызывается перед каждым вызовом функции, а setup_module – только один раз для всего модуля). Также можно видеть, что уровень фикстуры по умолчанию — функция/метод (фикстура setup и teardown).

Расширенные фикстуры


Вопрос что делать, если для части тестов нужно определенное окружение, а для других нет? Разносить по разным модулям или классам? Не выглядит очень удобно и красиво. На помощь приходят расширенные фикстуры PyTest.

Итак, для создания расширенной фикстуры в PyTest необходимо:

1) импортировать модуль pytest
2) использовать декоратор @pytest.fixture(), чтобы обозначить что данная функция является фикстурой
3) задать уровень фикстуры (scope). Возможные значения “function”, “cls”, “module”, “session”. Значение по умолчанию = “function”.
4) если необходим вызов teardown для этой фикстуры, то надо добавить в него финализатор (через метод addfinalizer объекта request передаваемого в фикстуру или же через использование конструкции yield)
5) добавить имя данной фикстуры в список параметров функции

import pytest
 
@pytest.fixture()
def resource_setup(request):
    print("resource_setup")
    def resource_teardown():
        print("resource_teardown")
    request.addfinalizer(resource_teardown)
    
def test_1_that_needs_resource(resource_setup):
    print("test_1_that_needs_resource")
 
def test_2_that_does_not():
    print("test_2_that_does_not")
 
def test_3_that_does_again(resource_setup):
    print("test_3_that_does_again")

Запускаем:

tmp>py.test -s extended_fixture.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1
rootdir: tmp/, inifile:
collected 3 items

extended_fixture.py resource_setup
test_1_that_needs_resource
.resource_teardown
test_2_that_does_not
.resource_setup
test_3_that_does_again
.resource_teardown
========================== 3 passed in 0.01 seconds 

Вызов расширенных фикстур


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

1) декорирование теста декоратором @pytest.mark.usefixtures()
2) использование флага autouse для фикстуры. Однако следует использовать данную возможность с осторожностью, так как в итоге вы можете получить неожиданное поведение тестов.
3) собственно описанный выше способ через параметры теста

Вот так будет выглядеть предыдущий пример:

import pytest
 
@pytest.fixture()
def resource_setup(request):
    print("resource_setup")
    def resource_teardown():
        print("resource_teardown")
    request.addfinalizer(resource_teardown)
    
@pytest.fixture(scope="function", autouse=True)
def another_resource_setup_with_autouse(request):
    print("another_resource_setup_with_autouse")
    def resource_teardown():
        print("another_resource_teardown_with_autouse")
    request.addfinalizer(resource_teardown)
    
def test_1_that_needs_resource(resource_setup):
    print("test_1_that_needs_resource")
 
def test_2_that_does_not():
    print("test_2_that_does_not")
 
@pytest.mark.usefixtures("resource_setup")
def test_3_that_does_again():
    print("test_3_that_does_again")

Запускаем:

tmp>py.test -s call_extended_fixtures.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1
rootdir: tmp/, inifile:
collected 3 items

call_extended_fixtures.py another_resource_setup_with_autouse
resource_setup
test_1_that_needs_resource
.resource_teardown
another_resource_teardown_with_autouse
another_resource_setup_with_autouse
test_2_that_does_not
.another_resource_teardown_with_autouse
another_resource_setup_with_autouse
resource_setup
test_3_that_does_again
.resource_teardown
another_resource_teardown_with_autouse
========================== 3 passed in 0.01 seconds

teardown расширенной фикстуры


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

1) добавив в фикстуру финализатор (через метод addfinalizer объекта request передаваемого в фикстуру
2) через использование конструкции yield (начиная с PyTest версии 2.4)

Первый способ мы рассмотрели в примере создания расширенной фикстуры. Теперь же просто продемонстрируем ту же самую функциональность через использование yield. Следует заметить, что для использования yield при декорировании функции, как фикстуры, необходимо использовать декоратор @pytest.yield_fixture(), а не @pytest.fixture():

import pytest
 
@pytest.yield_fixture()
def resource_setup():
    print("resource_setup")
    yield
    print("resource_teardown")
    
def test_1_that_needs_resource(resource_setup):
    print("test_1_that_needs_resource")
 
def test_2_that_does_not():
    print("test_2_that_does_not")
 
def test_3_that_does_again(resource_setup):
    print("test_3_that_does_again")

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

Возвращаемое фикстурой значение


Как возможность, фикстура в PyTest может возвращать что-нибудь в тест через return. Будь то какое-то значение состояние, так и объект (например, файл).

import pytest
 
@pytest.fixture(scope="module")
def resource_setup(request):
    print("\nconnect to db")
    db = {"Red":1,"Blue":2,"Green":3}
    def resource_teardown():
        print("\ndisconnect")
    request.addfinalizer(resource_teardown)
    return db
    
def test_db(resource_setup):
    for k in resource_setup.keys():
        print "color {0} has id {1}".format(k, resource_setup[k])
 
def test_red(resource_setup):
    assert resource_setup["Red"] == 1
 
def test_blue(resource_setup):
    assert resource_setup["Blue"] != 1

Запускаем:

tmp>py.test -v -s return_value.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp/, inifile:
collected 3 items

return_value.py::test_db
connect to db
color Blue has id 2
color Green has id 3
color Red has id 1
PASSED
return_value.py::test_red PASSED
return_value.py::test_blue PASSED
disconnect
========================== 3 passed in 0.02 seconds

Уровень фикстуры (scope)


Уровень фикстуры может принимать следующие возможные значения “function”, “cls”, “module”, “session”. Значение по умолчанию = “function”.\

function – фикстура запускается для каждого теста
cls – фикстура запускается для каждого класса
module – фикстура запускается для каждого модуля
session – фикстура запускается для каждой сессии (то есть фактически один раз)

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

import pytest
 
@pytest.fixture(scope="function")
def resource_setup(request):
    print("\nconnect to db")
    db = {"Red":1,"Blue":2,"Green":3}
    def resource_teardown():
        print("\ndisconnect")
    request.addfinalizer(resource_teardown)
    return db
    
def test_db(resource_setup):
    for k in resource_setup.keys():
        print "color {0} has id {1}".format(k, resource_setup[k])
 
def test_red(resource_setup):
    assert resource_setup["Red"] == 1
 
def test_blue(resource_setup):
    assert resource_setup["Blue"] != 1

tmp>py.test -v -s scope.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp/, inifile:
collected 3 items

scope.py::test_db
connect to db
color Blue has id 2
color Green has id 3
color Red has id 1
PASSED
disconnect

scope.py::test_red
connect to db
PASSED
disconnect

scope.py::test_blue
connect to db
PASSED
disconnect
========================== 3 passed in 0.02 seconds

Также фикстуры можно описывать в файле conftest.py, который автоматически импортируется PyTest. При этом фикстура может иметь любой уровень (только через описание в этом файле можно создать фикстуру с уровнем «сессия»).

Например, создадим отдельную папку session scope с файлом conftest.py и двумя файлами тестов (напомню, что чтобы PyTest автоматически импортировал модули их названия должны начинаться с test_. Хотя это поведение можно изменить.):

conftest.py:

import pytest

@pytest.fixture(scope="session", autouse=True)
def auto_session_resource(request):
    """ Auto session resource fixture
    """
    print("auto_session_resource_setup")
    def auto_session_resource_teardown():
        print("auto_session_resource_teardown")
    request.addfinalizer(auto_session_resource_teardown)
    
@pytest.fixture(scope="session")
def manually_session_resource(request):
    """ Manual set session resource fixture
    """
    print("manually_session_resource_setup")
    def manually_session_resource_teardown():
        print("manually_session_resource_teardown")
    request.addfinalizer(manually_session_resource_teardown)
    
@pytest.fixture(scope="function")
def function_resource(request):
    """ Function resource fixture
    """
    print("function_resource_setup")
    def function_resource_teardown():
        print("function_resource_teardown")
    request.addfinalizer(function_resource_teardown)

test_session_scope1.py

import pytest
 
def test_1_that_does_not_need_session_resource():
    print("test_1_that_does_not_need_session_resource")
 
def test_2_that_does(manually_session_resource):
    print("test_2_that_does")

test_session_scope2.py

import pytest
 
def test_3_that_uses_all_fixtures(manually_session_resource, function_resource):
    print("test_2_that_does_not")

Запускаем:

tmp\session scope>py.test -s -v
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\pro
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp\session scope, inifile:
collected 3 items

test_session_scope1.py::test_1_that_does_not_need_session_resource auto_session
resource_setup
test_1_that_does_not_need_session_resource
PASSED
test_session_scope1.py::test_2_that_does manually_session_resource_setup
test_2_that_does
PASSED
test_session_scope2.py::test_3_that_uses_all_fixtures function_resource_setup
test_2_that_does_not
PASSEDfunction_resource_teardown
manually_session_resource_teardown
auto_session_resource_teardown
========================== 3 passed in 0.02 seconds

Также интересно то, что PyTest поддерживает входной параметр --fixtures, при вызове с которым он возвращает все доступные фикструры, включая те, который были описаны в conftest.py (имели docstring).

tmp\session scope>py.test --fixtures
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1
rootdir: tmp\session scope, inifile:
collected 3 items
cache
    Return a cache object that can persist state between testing sessions.
….....
    path object.

----------------------- fixtures defined from conftest ------------------------
manually_session_resource
    Manual set session resource fixture
function_resource
    Function resource fixture
auto_session_resource
    Auto session resource fixture
==============================  in 0.07 seconds 

Объект request


В примере создания расширенной фикстуры мы передали в нее параметр request. Это было сделано чтобы через его метод addfinalizer добавить финализатор. Однако этот объект имеет также достаточно много атрибутов и других методов (полный список в официальном API).

import pytest
 
@pytest.fixture(scope="function")
def resource_setup(request):
    print request.fixturename
    print request.scope
    print request.function.__name__
    print request.cls
    print request.module.__name__
    print request.fspath
    
def test_1(resource_setup):
    assert True
 
class TestClass():
    def test_2(self, resource_setup):
        assert True

Запускаем:

tmp>py.test -v -s request_object.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp/, inifile:
collected 2 items

request_object.py::test_1 resource_setup
function
test_1
None
08
tmp\request_object.py
PASSED
request_object.py::TestClass::test_2 resource_setup
function
test_2
08.TestClass
08
tmp\request_object.py
PASSED
========================== 2 passed in 0.04 seconds 

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


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

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

1) Через значение параметра params фикстуры, в который нужно передать массив значений.
То есть фактически фикстура в данном случае представляет собой обертку, передающую параметры. А в сам тест они передаются через атрибут param объекта request, описанного выше.
2) Через декоратор (метку) @pytest.mark.parametrize, в который передается список названий переменных и массив их значений.

Итак первый способ.

import pytest
 
def strange_string_func(str):
    if len(str) > 5:
        return str + "?"
    elif len(str) < 5:
        return str + "!"
    else:
        return str + "."
 
@pytest.fixture(scope="function", params=[
("abcdefg", "abcdefg?"),
("abc", "abc!"),
("abcde", "abcde.")
])
def param_test(request):
    return request.param
    
def test_strange_string_func(param_test):
    (input, expected_output) = param_test
    result = strange_string_func(input)
    print "input: {0}, output: {1}, expected: {2}".format(input, result, expected_output)
    assert result == expected_output

Запускаем:

tmp>py.test -s -v parametrizing_base.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp/, inifile:
collected 3 items

parametrizing_base.py::test_strange_string_func[param_test0] input: abcdefg, output: abcdefg?, e
xpected: abcdefg?
PASSED
parametrizing_base.py::test_strange_string_func[param_test1] input: abc, output: abc!, expected:
 abc!
PASSED
parametrizing_base.py::test_strange_string_func[param_test2] input: abcde, output: abcde., expec
ted: abcde.
PASSED

========================== 3 passed in 0.03 seconds 

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

import pytest
 
def strange_string_func(str):
    if len(str) > 5:
        return str + "?"
    elif len(str) < 5:
        return str + "!"
    else:
        return str + "."
 
@pytest.fixture(scope="function", params=[
("abcdefg", "abcdefg?"),
("abc", "abc!"),
("abcde", "abcde.")],
ids=["len>5","len<5","len==5"]
)
def param_test(request):
    return request.param
    
def test_strange_string_func(param_test):
    (input, expected_output) = param_test
    result = strange_string_func(input)
    print "input: {0}, output: {1}, expected: {2}".format(input, result, expected_output)
    assert result == expected_output
    
def idfn(val):
    return "params: {0}".format(str(val))
    
@pytest.fixture(scope="function", params=[
("abcdefg", "abcdefg?"),
("abc", "abc!"),
("abcde", "abcde.")],
ids=idfn
)
def param_test_idfn(request):
    return request.param
    
def test_strange_string_func_with_ifdn(param_test_idfn):
    (input, expected_output) = param_test
    result = strange_string_func(input)
    print "input: {0}, output: {1}, expected: {2}".format(input, result, expected_output)
    assert result == expected_output

Запускаем:

tmp>py.test -s -v parametrizing_named.py --collect-only
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp/, inifile:
collected 6 items
<Module 'parametrizing_named.py'>
  <Function 'test_strange_string_func[len>5]'>
  <Function 'test_strange_string_func[len<5]'>
  <Function 'test_strange_string_func[len==5]'>
  <Function "test_strange_string_func_with_ifdn[params: ('abcdefg', 'abcdefg?')]
">
  <Function "test_strange_string_func_with_ifdn[params: ('abc', 'abc!')]">
  <Function "test_strange_string_func_with_ifdn[params: ('abcde', 'abcde.')]">
==============================  in 0.03 seconds

В данном случае я запустил PyTest с дополнительным параметром —collect-only, который позволяет собрать все тесты, порожденные параметризацией, без их запуска.

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

import pytest
 
@pytest.mark.parametrize("x", [1,2])
@pytest.mark.parametrize("y", [10,11])
def test_cross_params(x, y):
    print "x: {0}, y: {1}".format(x, y)
    assert True

Запускаем:

tmp>py.test -s -v parametrizing_combinations.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp/, inifile:
collected 4 items

parametrizing_combinations.py::test_cross_params[10-1] x: 1, y: 10
PASSED
parametrizing_combinations.py::test_cross_params[10-2] x: 2, y: 10
PASSED
parametrizing_combinations.py::test_cross_params[11-1] x: 1, y: 11
PASSED
parametrizing_combinations.py::test_cross_params[11-2] x: 2, y: 11
PASSED
========================== 4 passed in 0.02 seconds 

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

import pytest
 
def idfn_x(val):
    return "x=({0})".format(str(val))
    
def idfn_y(val):
    return "y=({0})".format(str(val))
 
@pytest.mark.parametrize("x", [-1,2], ids=idfn_x)
@pytest.mark.parametrize("y", [-10,11], ids=idfn_y)
def test_cross_params(x, y):
    print "x: {0}, y: {1}".format(x, y)
    assert True
    
@pytest.mark.parametrize("x", [-1,2], ids=["negative x","positive y"])
@pytest.mark.parametrize("y", [-10,11], ids=["negative y","positive y"])
def test_cross_params_2(x, y):
    print "x: {0}, y: {1}".format(x, y)
    assert True

Запускаем:

tmp>py.test -s -v parametrizing_combinations_named.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp/, inifile:
collected 8 items

parametrizing_combinations_named.py::test_cross_params[y=(-10)-x=(-1)] x: -1, y: -10
PASSED
parametrizing_combinations_named.py::test_cross_params[y=(-10)-x=(2)] x: 2, y: -10
PASSED
parametrizing_combinations_named.py::test_cross_params[y=(11)-x=(-1)] x: -1, y: 11
PASSED
parametrizing_combinations_named.py::test_cross_params[y=(11)-x=(2)] x: 2, y: 11
PASSED
parametrizing_combinations_named.py::test_cross_params_2[negative y-negative x] x: -1, y: -10
PASSED
parametrizing_combinations_named.py::test_cross_params_2[negative y-positive y] x: 2, y: -10
PASSED
parametrizing_combinations_named.py::test_cross_params_2[positive y-negative x] x: -1, y: 11
PASSED
parametrizing_combinations_named.py::test_cross_params_2[positive y-positive y] x: 2, y: 11
PASSED
========================== 8 passed in 0.04 seconds

Вызов нескольких фикстур и фикстуры, использующие фикстуры


PyTest не ограничивает список фикстур вызываемый для теста.

import pytest
 
@pytest.fixture()
def fixture1(request):
    print("fixture1")
    
@pytest.fixture()
def fixture2(request):
    print("fixture2")
    
@pytest.fixture()
def fixture3(request):
    print("fixture3")
    
def test_1(fixture1, fixture2):
    print("test_1")
 
def test_2(fixture1, fixture2, fixture3):
    print("test_2")

tmp>py.test -s -v multiply_fixtures.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp, inifile:
collected 2 items

multiply_fixtures.py::test_1 fixture1
fixture2
test_1
PASSED
multiply_fixtures.py::test_2 fixture1
fixture2
fixture3
test_2
PASSED
========================== 2 passed in 0.01 seconds

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

import pytest
 
@pytest.fixture()
def fixture1(request, fixture2):
    print("fixture1")
    
@pytest.fixture()
def fixture2(request, fixture3):
    print("fixture2")
    
@pytest.fixture()
def fixture3(request):
    print("fixture3")
    
def test_1(fixture1):
    print("test_1")
 
def test_2(fixture2):
    print("test_2")

tmp>py.test -s -v fixtures_use_fixtures.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp, inifile:
collected 2 items

fixtures_use_fixtures.py::test_1 fixture3
fixture2
fixture1
test_1
PASSED
fixtures_use_fixtures.py::test_2 fixture3
fixture2
test_2
PASSED
========================== 2 passed in 0.01 seconds 

Метки


PyTest поддерживает класс декораторов @pytest.mark называемый «метками» (marks). Базовый список включает в себя следующие метки:

1) @pytest.mark.parametrize — для параметризации тестов (было рассмотрено выше)
2) @pytest.mark.xfail – помечает, что тест должен не проходить и PyTest будет воспринимать это, как ожидаемое поведение (полезно, как временная метка для тестов на разрабатываемые функции). Также эта метка может принимать условие, при котором тест будет помечаться данной меткой.
3) @pytest.mark.skipif – позволяет задать условие при выполнении которогл тест будет пропущен
4) @pytest.mark.usefixtures – позволяет перечислить все фикстуры, вызываемые для теста

Вообще список шире и его можно получить выполнив команду «py.test --markers».

import pytest
import sys
 
@pytest.mark.xfail()
def test_failed():
    assert False
    
@pytest.mark.xfail(sys.platform != "win64", reason="requires windows 64bit")
def test_failed_for_not_win32_systems():
    assert False
    
@pytest.mark.skipif(sys.platform != "win64", reason="requires windows 64bit")
def test_skipped_for_not_win64_systems():
    assert False

Запускаем:

tmp>py.test -s -v basic_marks.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp, inifile:
collected 3 items

basic_marks.py::test_failed xfail
basic_marks.py::test_failed_for_not_win32_systems xfail
basic_marks.py::test_skipped_for_not_win64_systems SKIPPED
==================== 1 skipped, 2 xfailed in 0.02 seconds

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

import pytest
 
def test_1():
    print "test_1"
    
@pytest.mark.critital_tests
def test_2():
    print "test_2"
    
def test_3():
    print "test_3"

tmp>py.test -s -v -m "critital_tests" custom_marks.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp, inifile:
collected 3 items

custom_marks.py::test_2 test_2
PASSED
================= 2 tests deselected by "-m 'critital_tests'" =================
=================== 1 passed, 2 deselected in 0.01 seconds ====================

Метку можно описать и сделать доступной для всех модулей, через описание ее в модуле pytest.ini. При этом она появится в списке доступных меток, получаемых через «py.test --markers».

pytest.ini

# content of pytest.ini
[pytest]
markers =
    critical_test: mark test as critical. These tests must to be checked first.


tmp>py.test --markers
@pytest.mark.critical_test: mark test as critical. These tests must to be checked first.
…......

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

import pytest

pytestmark = pytest.mark.level1
 
def test_1():
    print "test_1"
    
@pytest.mark.level2
class TestClass:
    def test_2(self):
        print "test_2"
    @pytest.mark.level3
    def test_3(self):
        print "test_3"

tmp>py.test -s -v -m "level3" custom_marks_others.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp, inifile: pytest.ini
collected 3 items

custom_marks_others.py::TestClass::test_3 test_3
PASSED
===================== 2 tests deselected by "-m 'level3'" =====================
=================== 1 passed, 2 deselected in 0.07 seconds ====================

tmp>py.test -s -v -m "level2" custom_marks_others.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp, inifile: pytest.ini
collected 3 items

custom_marks_others.py::TestClass::test_2 test_2
PASSED
custom_marks_others.py::TestClass::test_3 test_3
PASSED
===================== 1 tests deselected by "-m 'level2'" =====================
=================== 2 passed, 1 deselected in 0.03 seconds ====================

tmp>py.test -s -v -m "level1" custom_marks_others.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp, inifile: pytest.ini
collected 3 items

custom_marks_others.py::test_1 test_1
PASSED
custom_marks_others.py::TestClass::test_2 test_2
PASSED
custom_marks_others.py::TestClass::test_3 test_3
PASSED
========================== 3 passed in 0.02 seconds ===========================

import pytest

@pytest.mark.parametrize(("x","expected"), [
(1,2),
pytest.mark.critical((2,3)),
(3,4)
])
def test_inc(x,expected):
    print x, "+ 1 = ", expected
    assert x + 1 == expected

tmp>py.test -s -v -m "critical" custom_marks_params.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp, inifile: pytest.ini
collected 3 items

custom_marks_params.py::test_inc[2-3] 2 + 1 =  3
PASSED
==================== 2 tests deselected by "-m 'critical'" ====================
=================== 1 passed, 2 deselected in 0.02 seconds ====================

Обработка исключений


Конечно же, как полноценный модуль для тестирования, PyTest также позволяет проверять корректность возвращаемых исключений при помощи «with pytest.raises()».

import pytest

def f():
    print 1/0

def test_exception():
    with pytest.raises(ZeroDivisionError):
        f()

tmp>py.test -s -v check_exception.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp, inifile: pytest.ini
collected 1 items

check_exception.py::test_exception PASSED
========================== 1 passed in 0.01 seconds 

Запуск тестов по имени или ID


Отдельные тесты из модулей можно запускать перечисляя полный путь к ним в виде module.py::class::method или. module.py::function. А также передавая с флагом -k часть их имени.

import pytest

def test_one():
    print "test_one"

def test_one_negative():
    print "test_one_negative"
    
def test_two():
    print "test_one_negative"

tmp>py.test -s -v call_by_name_and_id.py::test_two
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp, inifile: pytest.ini
collected 4 items

call_by_name_and_id.py::test_two test_one_negative
PASSED
========================== 1 passed in 0.04 seconds 

tmp>py.test -s -v -k "one" call_by_name_and_id.py
============================= test session starts =============================
platform win32 -- Python 2.7.8, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- c:\prog
ram files (x86)\python27\python.exe
cachedir: .cache
rootdir: tmp, inifile: pytest.ini
collected 3 items

call_by_name_and_id.py::test_one test_one
PASSED
call_by_name_and_id.py::test_one_negative test_one_negative
PASSED
======================== 1 tests deselected by '-kone' ========================
=================== 2 passed, 1 deselected in 0.01 seconds ====================

Интеграция с PyDev в Eclipse


Хотелось бы упомянуть, что PyTest интегрирован в компонент PyUnit модуля PyDev для Eclipse. Просто в настройках надо указать, что надо использовать именно его.

image

Дополнительные модули


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

pytest-describe – добавляет еще один уровень абстракции (модуль-описание-функция, как эквивалент модуль-функция-testcase).

pytest-instafail – изменяет базовое поведение модуля таким образом что все ошибки и падения показываются в процессе исполнения, а не окончанию работы всей сесссии.

pytest-marks – позволяет задавать несколько меток одновременно для теста:

@pytest.mark.red
@pytest.mark.green
@pytest.mark.blue
def some_test_method(self):
.....

pytest-ordering — позволяет задавать вручную порядок запуска тестов через метку Run.

import pytest

@pytest.mark.run(order=2)
def test_foo():
    assert True

@pytest.mark.run(order=1)
def test_bar():
    assert True

pytest-pep8 – позволяет проверять код тестов на соответствие соглашения pep-8.

pytest-smartcov — позволяет проверять покрытие кода тестам, как полное, так и частичное.

pytest-timeout — позволяет завершать тесты по таймауту, через параметр командной строки или специальной метки.

pytest-sugar — позволяет изменить внешний вид вывода PyTest'а, добавляя прогресс бар и процент выполнения. Выглядит красиво, пускай местами и не очень информативно.

Послесловие


В базовой документации PyTest описано много интересных примеров по его расширенному использованию. Но о них я хотел бы рассказать в следующий раз: управление базовым поведением PyTest (шаблоны по которым собираются тесты, добавление расширенных опций для командной строки), управление процессом сбора тестов при параметризации (объект metafunc) и многое другое.
Санько Алексей @AlexeySanko
карма
11,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

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

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

  • 0
    А можете поделиться своим опытом организации кода в своих проектах на Python-е по части тестов? Какая у Вас структура папок? Как организуете тест-пакеты? Оформляете ли тест-пакеты в виде полноценных пакетов, т.е. вместе с setup.py для каждого? И др. вопросы. Ведь каждый разработчик пробует разные подходы и какие-то отмирают, а какие-то выживают.
    • 0
      Я тесты пишу в файле tests.py в том же пакете, который тестируется. Если тестов много и их можно сгруппировать, то создаю пакет tests внутри тестируемого пакета, и уже в нём модули test_foo.py, test_bar.py и т.д. В общем я держу тесты ближе «к телу». Если где то можно обойтись маленьким doc-тестом внутри кода, то я так и делаю.
  • 0
    • 0
      А Вы пробовали читать статью? В предисловии же написано, что автор читал статью от Yandex.
  • 0
    Лучший обзор Пайтеста, что приходилось видеть. Спасибо.
  • 0
    Для unittest используется вызов функции main. Поэтому запуск имеет вид «python unittest_example.py». При этом для запуска набора тестов приходится отдельно объединять их в TestSuit и запускать через него.


    Нет, совсем не обязательно объединять.
    Пример
    [guest@localhost t]$ ls
    test_file1.py  test_file2.py
    [guest@localhost t]$ 
    [guest@localhost t]$ cat test_file1.py 
    #!/usr/bin/env python3
    
    import unittest
    
    class Test1(unittest.TestCase):
    
        def test_1(self):
            self.assertTrue(True)
    
    if __name__ == '__main__':
        unittest.main()
    [guest@localhost t]$ 
    [guest@localhost t]$ python3 -m unittest -v
    test_1 (test_file1.Test1) ... ok
    test_2 (test_file2.Test2) ... ok
    
    ----------------------------------------------------------------------
    Ran 2 tests in 0.000s
    
    OK
    [guest@localhost t]$


    Она не только файлы обнаруживает и тесты в них, но и предоставляет механизм раскрытия директорий.
  • 0
    Спасибо, весьма полезно.
  • 0
    Читаю в который раз статью, как ту что от Yandex, так и эту. Но так и не нахожу ответа на вопрос:

    Чем все-таки лучше pytest чем стандартный модуль unittest из стандартной библиотеки?

    Если кол-вом кода, то многое решается путем написания снипетов к Вашей среде разработки или любимому редактору. Если же удобство запуска тестов, то есть общеизвестный nosetests, который более чем хорошо справляется с задачей запуска тестов.
    • 0
      Лучше ответить поздно, чем никогда )
      Пайтест либче и легче в сопровождении. Фикстуры организуют код лучшим образом. Они разбивают огромный setUp() на независимые подсистемы. Это реально дает реюз кода в тестах.

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