Оглавление

Тестирование с помощью Assert

Покрытие тестами

Знакомство с Assert

Разделяем функцию и тесты

Как придумывать тесты

Резюме

Знакомство с Pytest и запуск в терминале

Начало работы и первый пример

Пишем тесты для знакомой функции

Объединяем тесты в классы

Обработка ошибок в Pytest

Контекстный менеджер pytest.raises

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

Чтобы запустить параметризацию, нужно:

Функции с несколькими аргументами

Повышаем читаемость

Тестирование функций

Разбор функции: площадь круга

Разбор функции вычисления балла

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

Разбор тестирования класса: класс круга

Фикстуры

Шаринг фикстур между тестами

Тестирование с помощью Assert

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

Надежность системы определяется тем, что всегда, при любых условиях, будут выполнены именно те действия, которые мы задумали. Но можно ли создать абсолютно надежный код? Простой ответ на этот вопрос — нет. Баги, ошибки и сбои встречаются всегда, а пользователи постоянно пытаются использовать систему каким-то неожиданным образом.

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

Покрытие тестами

Как мы будем ломать наши функции? Например, разберем задачу из начала курса. Вот функция, которая отдает стоимость билета в зависимости от переданного в качестве аргумента возраста. И эта программа написана с ошибкой. Как ее проверить и найти?


                    
def ticket_price(age):

	if 0 < age < 7:
		return "Бесплатно"
	elif 7 <=  age < 18:
		return "100 рублей"
	elif 18 <= age < 25:
		return "200 рублей"
	elif 25 <= age < 60:
		return  "300 рублей" 
	else:
		return "Ошибка"
                

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

Это занимает какое-то время. Если вы хотите проверить все граничные значения, отрицательные числа, буквы и просто вызов функции без аргумента, то количество тестов возрастает до 12–15 вызовов.

А что, если вы решите поменять код функции? При добавлении новых условий вам снова нужно выполнить 12–15 тестов, чтобы понять, всё ли хорошо. Чтобы не делать этого вручную, существуют так называемые автоматические тесты, или автотесты.

Знакомство с Assert

Познакомимся с новым ключевым понятием — оператор assert. Да, это примитивный стандартный инструмент, он не применяется в коммерческой разработке, зато уже встроен в Python и его довольно легко использовать с ходу, без дополнительных действий.


                    
assert ожидание == реальность, "Сообщение об ошибке"
                

Например, если выражение вернет True, то ничего не произойдет:


                    
assert 1 == 1     # Выполнится тихо и незаметно
assert 1+1 == 2   # Выполнится тихо и незаметно
assert True       # Выполнится тихо и незаметно
                

Если же выражение вернет False:


                    
assert 1==2, "Равенство неверное" # Вызовет ошибку
                

То будет вызвана AssertionError и выведен текст после запятой:


                    
Traceback (most recent call last):
  File "/Users/dev/skypro/samples/flask_sample/main.py", line 1, in <module>
    assert 1==2, "Равенство неверное" # Вызовет ошибку
AssertionError: Равенство неверное
                

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


                    
def ticket_price(age):

	if 0 < age < 7:
		return "Бесплатно"
	elif 7 <=  age < 18:
		return "100 рублей"
	elif 18 <= age < 25:
		return "200 рублей"
	elif 25 <= age < 60:
		return  "300 рублей" 
	else:
		return "Ошибка"
                

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

У нас есть ключевое слово assert , условие проверки из двух частей: вызов функции и сравнение с ожидаемым результатом. И на случай, если что-то пойдет не так, — сообщение об ошибке.

В данном случае что сделает Python? Он говорит: «Ага! Надо проверить, что ticket_price с аргументом 0 будет иметь результат Бесплатно . Если нет, тогда показать ошибку с текстом: "Ошибка для 0 лет"».

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


                    
assert ticket_price(0) == "Бесплатно", "Ошибка для 0 лет"
assert ticket_price(1) == "Бесплатно", "Ошибка для 1 лет"
assert ticket_price(7) == "100 рублей", "Ошибка для 7 лет"
assert ticket_price(18) == "200 рублей", "Ошибка для 18 лет"
assert ticket_price(25) == "300 рублей", "Ошибка для 25 лет"
assert ticket_price(60) == "Бесплатно", "Ошибка для 60 лет"
assert ticket_price(0.5) == "Бесплатно", "Ошибка для 0.5 лет"
assert ticket_price(-1) == "Ошибка", "Ошибка для -1 лет"
                

Разделяем функцию и тесты

Хорошо, мы получили +1 к надежности и −2 к удобству, ведь все эти assert ужасно отвлекают. Давайте вынесем тесты в отдельный файл test.py . Именно тесты мы не будем запускать, ведь нам нужно не выполнить функцию, а проверить, что она работает корректно.


                    
# main.py
# Снова используем неверный код из прошлого теста.


def ticket_price(age):

	if 0 < age < 7:
		return "Бесплатно"
	elif 7 <=  age < 18:
		return "100 рублей"
	elif 18 <= age < 25:
		return "200 рублей"
	elif 25 <= age < 60:
		return  "300 рублей" 
	else:
		return "Ошибка"
                

Теперь запустим test.py с помощью интерфейса PyCharm или через консоль:


                    
# test.py

from main import ticket_price


assert ticket_price(0) == "Бесплатно", "Ошибка для 0 лет"
assert ticket_price(1) == "Бесплатно", "Ошибка для 1 лет"
assert ticket_price(7) == "100 рублей", "Ошибка для 7 лет"
assert ticket_price(18) == "200 рублей", "Ошибка для 18 лет"
assert ticket_price(25) == "300 рублей", "Ошибка для 25 лет"
assert ticket_price(60) == "Бесплатно", "Ошибка для 60 лет"
assert ticket_price(0.5) == "Бесплатно", "Ошибка для 0.5 лет"
assert ticket_price(-1) == "Ошибка", "Ошибка для -1 лет"

print("Все тесты пройдены!") # Просто для веселья
                

                    
python test.py
                

В результате получится:


                    
Traceback (most recent call last):
...
AssertionError: Ошибка для 0 лет
                

Как придумывать тесты

Скажем сразу: тестирование ПО — отдельная профессия. Кстати, в Skypro такая профессия тоже есть. В этом эпизоде мы постараемся очень коротко познакомить вас с концептами тестирования ПО на примере одной задачи. Представим, что у нас есть функция, которая превращает строку из звездочек в соответствующую оценку, например, на вход мы подаем **** , а получаем все в порядке


                    
def get_rating(stars):
    if stars == "*":
      return "Ужасно"
    elif stars == "**":
      return "Очень плохо"
    elif stars == "***":
      return "Средненько"
    elif stars == "****":
      return "Все в поярдке"
    elif stars == "*****":
      return "Прекрасная поездка!"
    else:
      return "Ошибка"
                

Какие тесты нужно написать? Очевидно, 5 вариантов всех звездочек:


                    
assert get_rating("*") == "Ужасно", "Ошибка для 1 звезды"
assert get_rating("*"*2) == "Очень плохо", "Ошибка для 2 звезд"
assert get_rating("*"*3) == "Средненько", "Ошибка для 3 звезд"
assert get_rating("*"*4) == "Все в поярдке", "Ошибка для 4 звезд"
assert get_rating("*"*5) == "Прекрасная поездка!", "Ошибка для 5 звезд"
                

Хорошо, а какие еще данные может ввести пользователь? Другое количество звезд. Пустую строку. Число.


                    
assert get_rating("") == "Ошибка", "Пустая строка должна вернуть ошибку"
assert get_rating("*"*6) == "Ошибка", "6 звезд должны вернуть ошибку"
assert get_rating(27) == "Ошибка", "Число должно вернуть ошибку"
                

Резюме

Мы только что научились делать unit-тестирование или, по-другому, модульное тестирование — когда мы берем функцию и проверяем, как она работает. В следующих курсах мы будем писать всё более и более сложные тесты и наконец познакомимся со специальным модулем для написания тестов!

Знакомство с Pytest и запуск в терминале

Мы узнали о назначении тестирования, пирамиде тестирования, познакомились с assert . Теперь мы начнем работать с Pytest — де-факто стандартом в написании автоматических тестов. С ним можно писать юнит-тесты (с ними мы познакомимся в этой части урока) и интеграционные тесты (с ними мы познакомимся в следующей части).

Если вы пишете код на питоне, то будете использовать один из двух фреймворков — это Pytest и unittest.рy. Юнит-тест есть в стандартной коробке питона, но вообще этот инструмент пришел из другого языка, из Java, он очень мощный, менее «питонячий». Pytest нет в стандартной коробке. Однако при этом он более «питонячий», простой, понятный и используется почти везде. В следующих эпизодах мы познакомимся с Pytest. Большой плюс Pytest в том, что он не приносит своего синтаксиса, а расширяет возможности assert. То есть такой код будет поддерживаться и Pytest:


                    
assert 1+1 == 2, "Неверная сумма"
                

Начало работы и первый пример


                                        
Для начала давайте установим 
pytest в нашу виртуальную среду:
$ pip install pytest
                

                                        
Создадим файл с простой функцией, которая удваивает значения:

# utils.py

def double(value):
  new_value = value * 2
  return new_value
                

Начнем работу над тестами. Перед началом работы Pytest собирает по всему дереву проекта файлы, классы и функции, похожие на тесты. Pytest ожидает, что наши тесты будут расположены в файлах, имена которых начинаются с test_ или заканчиваются на _test.py , поэтому наш план действий такой:


                    Создаем файл utils_test.py.
Импортируем всё нужное.
Добавляем функцию, которая начинается с test.
Внутри функции пишем выражение с assert.
                

                    
# utils_test.py

import pytest

from utils import double


def test_double():
    assert double(2) == 4
                

Теперь запустим pytest из консоли (да, можно запускать без параметров):


                    
pytest
                

Мы увидим сообщение, которое означает, что тест пройден:


                    
platform darwin — Python 3.10.0, pytest-7.0.1, pluggy-1.0.0  # версии
rootdir: /Users/dev/skypro/lesson_13/tests     # путь, где запущен pytest
collected 1 item     # сколько тестов удалось найти                                                                                                                                                         

utils_test.py .                                                                                                                                                       [100%]

=====================
1 passed in 0.01s
                

Пишем тесты для знакомой функции

Вернемся к не совсем правильной функции вычисления стоимости билета из прошлого задания и покроем ее тестами с помощью пайтест. Шаг 1. Добавим эту функцию в utils.py (файл, где хранятся полезные функции).


                    def ticket_price(age):

	if 0 < age < 7:
		return "Бесплатно"
	elif 7 <= age < 18:
		return "100 рублей"
	elif 18 <= age < 25:
		return "200 рублей"
	elif 25 <= age < 60:
		return  "300 рублей" 
	else:
		return "Ошибка"
                

Шаг 2. Добавим тесты для этой функции в файл utils_test.py . Сейчас код выглядит достаточно громоздко, но позже мы научимся сокращать его с помощью фикстур.


                    
def test_ticket_price_0():
    assert ticket_price(0) == "Бесплатно", "Ошибка для 0 лет"


def test_ticket_price_1():
    assert ticket_price(1) == "Бесплатно", "Ошибка для 1 лет"


def test_ticket_price_7():
    assert ticket_price(7) == "100 рублей", "Ошибка для 7 лет"


def test_ticket_price_18():
    assert ticket_price(18) == "200 рублей", "Ошибка для 18 лет"


def test_ticket_price_25():
    assert ticket_price(25) == "300 рублей", "Ошибка для 25 лет"


def test_ticket_price_60():
    assert ticket_price(60) == "Бесплатно", "Ошибка для 60 лет"


def test_ticket_price_minus_1():
    assert ticket_price(-1) == "Ошибка", "Ошибка для -1 лет"
                

                    
Набираем в терминале pytest и запускаем тесты!

 

$ pytest
                

                    
Получаем сообщение о 2 проваленных тестах:
FAILED utils_test.py::test_ticket_price_0 - AssertionError: Ошибка для 0лет
FAILED utils_test.py::test_ticket_price_60 - AssertionError: Ошибка для 60лет
                

На основании полученных советов исправляем функцию:


                    
def ticket_price(age):

	if 0 <= age < 7 or age >= 60:
		return "Бесплатно"
	elif 7 <= age < 18:
		return "100 рублей"
	elif 18 <= age < 25:
		return "200 рублей"
	elif 25 <= age < 60:
		return  "300 рублей" 
	else:
		return "Ошибка"
                

Запускаем тест снова: Получаем сообщение, что всё в порядке и 7 тестов были выполнены:

Обратите внимание на точки после utils_test.py: ....... — их семь. Это значит, семь тестов прошли успешно. Если бы, например, первая пара тестов не прошла, последовательность выглядела бы так: FF.....

Объединяем тесты в классы

Разработав несколько тестов, вы можете сгруппировать их в класс. Pytest позволяет создать класс, содержащий много тестов. Тут есть сразу несколько плюсов:


                    Больше структуры — больше порядка.
Не нужно придумывать много оригинальных имен.
Можно запускать только тесты какого-то класса.
                

Давайте перепишем наши тесты: превратим функции в методы и положим всё в класс:


                    # utils_test.py

import pytest

from utils import ticket_price

class TestTicketPrice:

    def test_0(self):
        assert ticket_price(0) == "Бесплатно", "Ошибка для 0 лет"

    def test__1(self):
        assert ticket_price(1) == "Бесплатно", "Ошибка для 1 лет"

    def test_7(self):
        assert ticket_price(7) == "100 рублей", "Ошибка для 7 лет"

    def test_18(self):
        assert ticket_price(18) == "200 рублей", "Ошибка для 18 лет"

    def test_25(self):
        assert ticket_price(25) == "300 рублей", "Ошибка для 25 лет"

    def test_60(self):
        assert ticket_price(60) == "Бесплатно", "Ошибка для 60 лет"

    def test_minus_1(self):
        assert ticket_price(-1) == "Ошибка", "Ошибка для -1 лет"
                

Обработка ошибок в Pytest

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


                    def divide(first, second):
    return first / second


divide(True, None)

divide("кот", "хлеб")

divide(100, 0)
                

Разумеется, вы получите TypeError или ZeroDivisionError. Как же быть, если поведение «выбрасывать ошибку» — нормальное и ожидаемое для функции? Есть два подхода.

Контекстный менеджер pytest.raises

Чтобы проверить, выбрасывает ли функция ошибку в нужное время, нужно использовать специальный менеджер контекста pytest.raises ( <Тип ошибки> ). Обратите внимание: тут нет assert, но тесты запускаются:


                    def test_type_mismatch():
    with pytest.raises(TypeError):
        divide(True, None)
                

Поскольку наша функция может возвращать еще и ZeroDivisionError, допишем второй тест:


                    def test_type_mismatch():
    with pytest.raises(ZeroDivisionError):
        divide(100, 0)
                

Добавим еще пару тестов и соберем всё вместе:


                    # utils.py


def divide(first, second):
    return first / second

#utils_test.py

import pytest
from utils import divide


def test_positive_int():
    assert divide(100, 10) == 10.0

def test_negative_int():
    assert divide(-20, -5) == 4.0

def test_zero_to_int():
    assert divide(0, 2) == 0.0

def test_float():
    assert divide(2.2, 2) == 1.1

def test_type_mismatch():
    with pytest.raises(TypeError):
        divide(True, None)

def test_zero_division():
    with pytest.raises(ZeroDivisionError):
        divide(100, 0)
                

                    Получим в результате:
collected 6 items                                                           
utils_test.py ......                                                          
6 passed in 0.01s =
                

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

Параметризация — создание нескольких тестов на основе одной функции (или метода), которая будет вызываться многократно с разными параметрами Случается так, что мы хотим проверить функцию сразу на большом диапазоне значений. Вот, например, у нас есть такая функция:


                    def double(value):
  new_value = value * 2
  return new_value
                

И мы хотим написать для нее тесты. Получится примерно так (дополнительные пропуски строк мы удалили для читаемости):


                    def test_zero():
	assert double(0) == 0, "Неверно для 0"

def test_one():
	assert double(1) == 2, "Неверно для 1"

def test_float():
	assert double(10.0) == 20.0, "Неверно для 10.0"

def test_negative():
	assert double(-3) == -6, "Неверно для -3"

def test_bigint():
assert double(123456789) == 246913578, "Неверно для 123456789"
                

Ужасно выглядит, правда? Код получается однотипный, повторяющийся. Может быть, есть инструмент, который сократит количество кода, но обеспечит выполнение такого же количества тестов? Можно даже предположить, что мы будем создавать пары ключ-значение, а на их основе будут выполняться ассерты. Действительно, такой инструмент существует и называется параметризацией. Встроенный декоратор pytest.mark.parametrize позволяет параметризовать аргументы тестовых функций.

Чтобы запустить параметризацию, нужно:


                    Добавить декоратор 
@pytest.mark.parametrize
.
Задать в кавычках названия переменных, которые будут передаваться в функцию.
@pytest.mark.parametrize("test_input, expected"
                

Перечислить в кортежах аргумент(ы) — возвращаемое значение.


                    @pytest.mark.parametrize(
	"test_input, expected",
	[(0, 0), (1, 2),(10.0, 20.0), (-3, -6), (123456789, 246913578))]
)
                

Дописать функцию, которая принимает перечисленные аргументы (test_input, expected).


                    @pytest.mark.parametrize(
	"test_input, expected",
	[(0, 0), (1, 2),(10.0, 20.0), (-3, -6), (123456789, 246913578))]
)
def test_eval(test_input, expected):
assert eval(test_input) == expected
                

Теперь запустим всё вместе!


                    # utils.py

def double(value):
  new_value = value * 2
  return new_value


# utils_test.py

import pytest
from utils import double

@pytest.mark.parametrize(
    "test_input, expected",
    [(0, 0), (1, 2), (10.0, 20.0), (-3, -6), (123456789, 246913578)]
)
def test_double(test_input, expected):
    assert double(test_input) == expected
                

Это означает, что было выполнено пять полноценных тестов при одной написанной функции!

Функции с несколькими аргументами

Хорошо, а как быть, если вы хотите параметризовать тесты функции с несколькими аргументами. Например, такой:


                    def sum_of_two(first, second):
    return first+second
                

Тесты для нее будут выглядеть так:


                    assert sum_of_two(0, 0) == 0, "Неверно для 0 + 0"

assert sum_of_two(1, 1) == 2, "Неверно для 1 + 1"

assert sum_of_two(-10, 10) == 0, "Неверно для -10 + 10"
                

Но как их параметризовать? Первое, что мы делаем, — увеличиваем количество аргументов в списке:


                    @pytest.mark.parametrize(
	"first, second, expected",
...
                

Второе — превращаем пары в тройки.


                    @pytest.mark.parametrize(
	"first, second, expected",
	[(0, 0, 0), (1, 1, 2),(-10, 10, 0)]
)
                

Ловим параметры в тестовой функции:


                    @pytest.mark.parametrize(
	"first, second, expected",
	[(0, 0, 0), (1, 1, 2),(-10, 10, 0)]
)
def test_sum_of_two(first, second, expected):
    assert sum_of_two(first, second) == expected
                

Запускаем, получаем что-то такое:


                    collected 3 items                           
utils_test.py ...                                    
3 passed in 0.01s
                

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

Повышаем читаемость

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


                    import pytest

from utils import sum_of_two

sum_of_two_parameters = [(0, 0, 0), (1, 1, 2), (-10, 10, 0)]

@pytest.mark.parametrize("first, second, expected", sum_of_two_parameters)
def test_sum_of_two(first, second, expected):
    assert sum_of_two(first, second) == expected
                

Запускаем, получаем:


                    collected 3 items                                                                                                                                                           
utils_test.py ...                                                                                                                                                     
3 passed in 0.01s
                

Наши тесты стали более читаемыми и всё еще работают. Отлично!

Тестирование функций

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

Разбор функции: площадь круга

Напишите функцию get_square(radius) , которая принимает радиус кружочка и возвращает площадь кружочка. Формула площади = радиус ** 2 * Пи .


                    import math

def get_circle_square(radius):

	if type(radius) not in [int, float]:
		raise TypeError("Должно быть int или float больше 0")

	if radius < 0:
		raise ValueError("Должно быть int или float больше 0")

	return radius ** 2 * math.pi
                

Напишем сперва тесты для двух нормальных значений и нуля:


                    import pytest

from utils import get_circle_square


def test_get_circle_square_zero():
    square = get_circle_square(0)
    assert square == 0, "Неверное значение для 0"

def test_get_circle_square_one():
    square = get_circle_square(1)
    assert round(square, 2) == 3.14, "Неверное значение для 1"

def test_get_circle_square_normal():
    square = get_circle_square(3)
    assert round(square, 2) == 28.27, "Неверное значение для 3"
                

А затем для исключительных случаев:


                    def test_get_circle_square_value_error():
    with pytest.raises(ValueError):
        get_circle_square(-2)

def test_get_circle_square_type_error():
    with pytest.raises(TypeError):
        get_circle_square("2")
                

Соберем всё вместе и получим такой код:


                    # test_utils.py


import pytest


from utils import get_circle_square



def test_get_circle_square_zero():
    square = get_circle_square(0)
    assert square == 0, "Неверное значение для 0"

def test_get_circle_square_one():
    square = get_circle_square(1)
    assert round(square, 2) == 3.14

def test_get_circle_square_normal():
    square = get_circle_square(3)
    assert round(square, 2) == 28.27

def test_get_circle_square_value_error():
    with pytest.raises(ValueError):
        get_circle_square(-2)

def test_get_circle_square_type_error():
    with pytest.raises(TypeError):
        get_circle_square("2")
                

Разбор функции вычисления балла

Начнем с функции, которая получает оценку (2/3/4/5) и возвращает строку (плохо, удовлетворительно, хорошо, отлично). Сначала распишем, какое поведение мы хотим от функции с учетом обработки ошибок:

Теперь посмотрим на саму функцию:


                    def get_verbal_grade(grade):
    if type(grade) != int: raise TypeError("Должно быть целое между 2 и 5")
    if grade < 2 or grade > 5: raise ValueError("Должно быть между 2 и 5")

    if grade == 2:
        return "Плохо"

    elif grade == 3:
        return "Удовлетворительно"

    elif grade == 4:
        return "Хорошо"

    elif grade == 5:
        return "Отлично"
                

Начнем писать тесты, сперва соберем список «парочек» для параметризации в формате (<аргумент>, <возвращаемое значение>) :


                    grade_parameters = [
    (2, "Плохо"),
    (3, "Удовлетворительно"),
    (4, "Хорошо"),
    (5, "Отлично"),
]
                

Запилим тестирующую функцию, как используем наши grade_parameters :


                    @pytest.mark.parametrize("grade_int, grade_str", grade_parameters)
def test_get_verbal_grade(grade_int, grade_str):
    assert get_verbal_grade(grade_int) == grade_str
                

Добавляем тесты на выбрасываемые исключения:


                    def test_get_verbal_grade_value_error_1():
    with pytest.raises(ValueError):
        get_verbal_grade(1)


def test_get_verbal_grade_value_error_6():
    with pytest.raises(ValueError):
        get_verbal_grade(6)


def test_get_verbal_grade_type_error_str():
    with pytest.raises(TypeError):
        get_verbal_grade("5")


def test_get_verbal_grade_type_error_floart():
    with pytest.raises(TypeError):
        get_verbal_grade(5.0)
                

Кстати, эти исключения тоже можно параметризовать, если хочется:


                    grade_exceptions = [
    (1, ValueError),
    (6, ValueError),
    ("5", TypeError),
    (5.0, TypeError),
]


@pytest.mark.parametrize("grade_int, exception", grade_exceptions)
def test_get_verbal_grade_exceptions(grade_int, exception):
    with pytest.raises(exception):
        get_verbal_grade(grade_int)
                

Собираем всё вместе:


                    # utils.py

def get_verbal_grade(grade):
    if type(grade) != int: raise TypeError("Должно быть целое число между 2 и 5")
    if grade < 2 or grade > 5: raise ValueError("Должно быть между 2 и 5")

    if grade == 2:
        return "Плохо"
    elif grade == 3:
        return "Удовлетворительно"
    elif grade == 4:
        return "Хорошо"
    elif grade == 5:
        return "Отлично"
                

                    # utils_test.py

import pytest

from utils import get_verbal_grade

grade_parameters = [
    (2, "Плохо"),
    (3, "Удовлетворительно"),
    (4, "Хорошо"),
    (5, "Отлично"),
]


@pytest.mark.parametrize("grade_int, grade_str", grade_parameters)
def test_get_verbal_grade(grade_int, grade_str):
    assert get_verbal_grade(grade_int) == grade_str


grade_exceptions = [
    (1, ValueError),
    (6, ValueError),
    ("5", TypeError),
    (5.0, TypeError)
]


@pytest.mark.parametrize("grade_int, exception", grade_exceptions)
def test_get_verbal_grade_exceptions(grade_int, exception):
    with pytest.raises(exception):
        get_verbal_grade(grade_int)
                

Теперь запускаем тест:


                    collected 8 items     
utils_test.py ........     
8 passed in 0.02s
                

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

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

Разбор тестирования класса: класс круга

У вас есть класс Circle , с полем radius (указывается при инициализации), например c = Circle(10) . Класс поддерживает три метода: get_radius() — возвращает радиус; get_diameter() — возвращает двойной радиус; get_perimeter() — возвращает 2*радиус*Пи. Вот как выглядит наш класс:


                    import math

class Circle:

	def __init__(self, radius):
		if type(radius) not in [int, float]: 
			raise TypeError("Радиус должен быть числом, int или float")
		if radius < 0: 
			raise ValueError("Радиус должен быть положительным")

		self.radius = radius

	def get_radius(self):
		return self.radius

	def get_diameter(self):
		return self.radius * 2

	def get_perimeter(self):
		return 2 * self.radius * math.pi
                

Напишем для него набор тестов. Писать будем сразу внутри класса CircleTest:


                    class CircleTest:

    def test_get_radius(self):
        circle = Circle(1)
        assert circle.get_radius() == 1, "Ошибка в радиусе"

    def test_get_diameter(self):
        circle = Circle(1)
        assert circle.get_diameter() == 2, "Ошибка в диаметре"

	def test_get_perimeter(self):
	        circle = Circle(1)
	        assert round(circle.get_perimeter(), 2) == 6.28, "Ошибка в возвращаемом периметре"
                

Теперь добавим контроль выбрасываемых исключений:


                    def test_init_type_error(self):
      with pytest.raises(TypeError):
          circle = Circle("1")

def test_init_value_error(self):
      with pytest.raises(ValueError):
          circle = Circle(-1)
                

Собираем всё вместе:


                    # circle.py

import math

class Circle:

    def __init__(self, radius):
        if type(radius) not in [int, float]:
            raise TypeError("Радиус должен быть числом, int или float")
        if radius < 0:
            raise ValueError("Радиус должен быть положительным")

        self.radius = radius

    def get_radius(self):
        return self.radius

    def get_diameter(self):
        return self.radius * 2

    def get_perimeter(self):
        return 2 * self.radius * math.pi
                

                    # circle_test.py

import pytest

from circle import Circle

class TestCircle:

    def test_get_radius(self):
        circle = Circle(1)
        assert circle.get_radius() == 1, "Ошибка в  радиусе"

    def test_get_diameter(self):
        circle = Circle(1)
        assert circle.get_diameter() == 2, "Ошибка в диаметре"

    def test_get_perimeter(self):
        circle = Circle(1)
        assert round(circle.get_perimeter(), 2) == 6.28, "Ошибка в периметре"

    def test_init_type_error(self):
        with pytest.raises(TypeError):
            circle = Circle("1")

    def test_init_value_error(self):
        with pytest.raises(ValueError):
            circle = Circle(-1)
                

Запускаем и получаем:


                    collected 5 items 
circle_test.py .....    
5 passed in 0.02s
                

Фикстуры

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


                    # utils.py

def sum_func(a, b):
    return a + b
                

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


                    # utils_test.py

import pytest
from app import sum_func


# делаем фикстуры для чисел

class TestSumFunc:

    def test_sum_positive(self):
        c = sum_func(1, 1)
        assert c == 2

    def test_sum_positive_and_negative(self):
        c = sum_func(1, -1)
        assert c == 0

    def test_sum_negative2(self):
        c = sum_func(-30, -10)
        assert c == -40
                

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


                    @pytest.fixture()
def positive_numbers():
    return [1, 1]

@pytest.fixture()
def negative_numbers():
    return [-10, -30]

@pytest.fixture()
def positive_and_negative_numbers():
    return [1, -1]
                

Теперь принимаемся за написание тестов, оформим его в виде класса:


                    class TestSumFunc:

    def test_sum_positive(self, positive_numbers):
        c = sum_func(positive_numbers[0], positive_numbers[1])
        assert c 	> 0
        assert c == 2

    def test_sum_negative(self, negative_numbers):
        c = sum_func(negative_numbers[0], negative_numbers[1])
        assert c 	< 0
        assert c == -40

    def test_sum_positive_and_negative(self, positive_and_negative_numbers):
        c = sum_func(
					positive_and_negative_numbers[0], 
					positive_and_negative_numbers[1]
				)
        assert c == 0
                

Что тут происходит: Функция test_sum_positive() принимает аргумент positive_numbers . Вызывается тестируемая функция с использованием данных positive_numbers . Проверяется корректность работы функции с помощью assert. Однако постойте: откуда взялся positive_numbers ? На самом деле, ответ очень простой: когда мы создаем фикстуры (то есть пишем функцию с декоратором @pytest.fixture()), внутри каждой тестирующей функции будет доступна переменная positive_numbers . А значение у нее будет такое, которое одноименная фикстура вернет. То есть если наша фикстура выглядела так:


                    @pytest.fixture()
def my_nice_values():  #  запомните это имя
    return [1, 1]
                

То в любой тестирующей функции можно сделать так:


                    def test_something_nice(self, my_nice_values):   #  обратите внимание на имя

	assert my_nice_values[0] == my_nice_values[1]
                

Шаринг фикстур между тестами

У нас есть необходимость делать фикстуру доступной между несколькими тестами и несколькими файлами тестов, чтобы разные тесты могли использовать одни и те же данные. Для этого можно создать файл conftest.py, в нем писать все фикстуры и оттуда всё импортировать, если фикстуры используются в большом количестве файлов.

Рассматриваем внимательно нашу функцию и придумываем тесты:


                    # utils.py

def sum_func(a, b):
    return a + b
                

Создаем файл conftest.py. Дописываем в него фикстуры:


                    # conftest.py

import pytest

@pytest.fixture()
def two_numbers_sum():  #  запомните это имя
    return (1, 1, 2)
                

Импортируем фикстуры в файл с тестами:


                    # utils_test.py

import conftest.py
                

Используем фикстуры в файле с тестами:


                    # utils_test.py

import conftest
from utils import sum_func

def test_sum_func( two_numbers_sum):  # обратите внимание на имя

    sum_result = sum_func(two_numbers_sum[0], two_numbers_sum[1])
    assert sum_result == two_numbers_sum[2]
                

Собираем всё вместе:


                    # utils.py   ###############################################

def sum_func(a, b):
    return a + b

# conftest.py  #############################################

import pytest

@pytest.fixture()
def two_numbers_sum():  #  запомните это имя
    return (1, 1, 2)

# utils_test.py  ############################################

import conftest
from utils import sum_func

def test_sum_func( two_numbers_sum):  # обратите внимание на имя

    sum_result = sum_func(two_numbers_sum[0], two_numbers_sum[1])
    assert sum_result == two_numbers_sum[2]
                

Оглавление

Глоссарий

Виды тестирования:

Пирамида тестирования

Пример успешного теста:

Пример падающего теста:

Пример теста, который должен падать, и это нормально:

Пример теста, который пропускается:

Пример параметризованного теста:

Пример класса с тестами:

Пример простой фикстуры, которая отдает число:

Пример использования фикстуры:

Как посмотреть, какие фикстуры были запущены?

Как запустить тесты, чтобы увидеть вывод команды print?

Глоссарий

**Фикстуры** — это функции, выполняемые до или после тестов. Фикстуры используются для подготовки данных.

Виды тестирования:

- Функциональное: - юнит-тесты, - интеграционные тесты.

- Нефункциональное: - тестирование производительности, - тестирование защищенности, - тестирование надежности.

Пирамида тестирования

Много unit-тестов, так как они дешевые, легкие, быстрые. Среднее количество интеграционных тестов, так как они дороже, сложнее и дольше выполняются. Мало UI-тестов, потому что они дорогие, сложные и долго выполняются.

Как запустить тесты? В терминале перейдите в папку с тестами и введите: $ pytest

Как pytest ищет тесты? Если вы не укажете какие-либо файлы или каталоги, pytest будет искать тесты в текущем рабочем каталоге и подкаталогах. Он ищет файлы, начинающиеся с test_ или заканчивающиеся на _test.

Пример успешного теста:


                            
def test_ok():
    assert 1 == 1
                        

Пример падающего теста:


                            
def test_fail():
    assert 2 == 1
                        

Пример теста, который должен падать, и это нормально:


                            
@pytest.mark.xfail()
    def test_have_to_fail():
        raise Exception()
                        

Пример теста, который пропускается:


                            
@pytest.mark.skip(reason="OK")
    def test_skip(self):
        raise Exception()
                        

Пример параметризованного теста:


                            
    triplets = [(1, 2, 3), (3, 4, 7), (-3, -4, -7)]

    triplets_ids = ['Triplet({}+{}={})'.format(p[0], p[1], p[2]) for p in triplets]

    @pytest.mark.parametrize('tripl', triplets, ids=triplets_ids)
    def test_sum_numbers(tripl):
        assert (tripl[0] + tripl[1]) == tripl[2]
                        

Пример класса с тестами:


                            
class TestSumFunc:
    def test_sum_positive(self):
        c = sum_func(1, 1)
        assert c > 0
        assert c == 2

    def test_sum_positive_and_negative(self):
        c = sum_func(1, -1)
        assert c == 0

    def test_sum_negative2(self):
        c = sum_func(-30, -10)
        assert c < 0
        assert c == -40

    triplets = [(1, 2, 3), (3, 4, 7), (-3, -4, -7)]

    triplets_ids = ['Triplet({}+{}={})'.format(p[0], p[1], p[2]) for p in triplets]

    @pytest.mark.parametrize('tripl', triplets, ids=triplets_ids)
    def test_sum_numbers(self, tripl):
        assert (tripl[0] + tripl[1]) == tripl[2]

    @pytest.mark.xfail()
    def test_have_to_fail(self):
        raise Exception()

    @pytest.mark.skip(reason="OK")
    def test_skip(self):
        raise Exception()
                        

Пример простой фикстуры, которая отдает число:


                            
import pytest


@pytest.fixture()
def number_42():
    return 42
                        

Если вы хотите шарить фикстуры между тестами, создайте conftest.py.

Пример использования фикстуры:


                            
import pytest


@pytest.fixture()
def number_42():
    return 42


def test_number(number_42):
    assert number_42 == 42
                        

Как посмотреть, какие фикстуры были запущены?


                            
$ pytest --setup-show -v
                        

Как запустить тесты, чтобы увидеть вывод команды print?


                            
$ pytest -s
                        

Оглавление

Глоссарий

Зачем нужен мок?

Какую библиотеку мы используем для моков?

Используем мок в фикстуре:

Пример тестов реального класса:

В какой директории должны быть тесты?

Глоссарий

Мокированный класс — это методы, при вызове которых происходит не то, что в них написано, а то, что вам нужно для тестов, то, что вы заранее предусмотрели.

Зачем нужен мок?


                        - Работа с БД.
- Работа с внешним сервисом.
- Работа с чем-то, чего нет в локальном окружении, чью работу надо симулировать, чтобы протестировать нужный класс.
                    

Какую библиотеку мы используем для моков?

unittest.mock Пример, как мокировать метод:


                        from unittest.mock import MagicMock

class ProductionClass:
    def m1(self):
        print("m1")

    def m2(self):
        print("m2")

pc = ProductionClass()
pc.m1 = MagicMock(return_value="123")
print(pc.m1()) # 123

pc.m2 = MagicMock(side_effect=Exception("oh no"))
pc.m2() # Exception!
                    

Используем мок в фикстуре:


                        from unittest.mock import MagicMock
import pytest

@pytest.fixture()
def prod_class():
	pc = ProductionClass()
	pc.m1 = MagicMock(return_value="123")
	return pc
                    

Пример тестов реального класса:


                        # тесты
@pytest.fixture()
def user_dao():
    user_dao = UserDAO(db.session)

    jonh = User(id=1, name='jonh', age=30)
    kate = User(id=2, name='kate', age=31)
    max = User(id=3, name='max', age=32)

    user_dao.get_one = MagicMock(return_value=jonh)
    user_dao.get_all = MagicMock(return_value=[jonh, kate, max])
    user_dao.create = MagicMock(return_value=User(id=3))
    user_dao.delete = MagicMock()
    user_dao.update = MagicMock()
    return user_dao


class TestUserService:
    @pytest.fixture(autouse=True)
    def user_service(self, user_dao):
        self.user_service = UserService(dao=user_dao)

    def test_get_one(self):
        user = self.user_service.get_one(1)
        assert user != None
        assert user.id != None

    def test_get_all(self):
        users = self.user_service.get_all()
        assert len(users) > 0

    def test_create(self):
        user_d = {
            "name": "Ivan",
            "age": 39,
        }
        user = self.user_service.create(user_d)
        assert user.id != None

    def test_delete(self):
        self.user_service.delete(1)

    def test_update(self):
        user_d = {
            "id": 3,
            "name": "Ivan",
            "age": 39,
        }
        self.user_service.update(user_d)

# реальный класс
from dao.user import userDAO


class UserService:
    def __init__(self, dao: UserDAO):
        self.dao = dao

    def get_one(self, bid):
        return self.dao.get_one(bid)

    def get_all(self):
        return self.dao.get_all()

    def create(self, user_d):
        return self.dao.create(user_d)

    def update(self, user_d):
        return self.dao.update(user_d)

    def partially_update(self, user_d):
        user = self.get_one(user_d["id"])
        if "name" in user_d:
            user.name = user_d.get("name")
        self.dao.update(user)

    def delete(self, rid):
        self.dao.delete(rid)
                    

В какой директории должны быть тесты?

Создаем папку `tests` в корне с проектом. Внутри создаем директорию для тестов-сервисов `test_service`. Внутри каждой директории создаем файлы на каждый тестируемый класс, например `test_director.py`.

© 2023 Все права защищены