2. PyTest проект телефонная книга

Содержание
Введение
Появление зависимости
Заглушка StubAuthorizer
Фикстуры stub_authorizer и enterprise_phonebook
create_autospec и Mock
Похожие статьи

Введение

Продолжим работать на телефонной книгой.

Создадим версию с расширенным функционалом и назовём её EnterprisePhonebook

Добавим следующий функционал:

pb ├── enterprise.py ├── phonebook.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_enterprise.py │ └── test_phonebook.py └── venv

Так как теперь у нас два файла, которым нужно использовать фикстуру phonebook() вынесем её в файл conftest.py

# conftest.py import pytest from phonebook import Phonebook @pytest.fixture def phonebook(): return Phonebook()

Начнём с базовой версии enterprise.py которая использует такой же lookup() как и обычная телефонная книга.

# enterprise.py from phonebook import Phonebook class EnterprisePhonebook: def __init__(self, phonebook: Phonebook): self.phonebook = phonebook def lookup(self, name): return self.phonebook.lookup(name)

В новой корпоративной телефонной книге мы хотим чтобы авторизованный пользователь мог делать поиск а неавторизованный не мог.

Подготовим основу для тестирование этой функциональности.

# test_enterprise.py import pytest from enterprise import EnterprisePhonebook # - lookup when authorized # - lookup when not authorized - raise error def test_lookup_authorized(phonebook): phonebook.add("Bob", "1234") enterprise_phonebook = EnterprisePhonebook(phonebook) assert enterprise_phonebook.lookup("Bob") == "1234" def test_lookup_not_authorized(phonebook): phonebook.add("Bob", "1234") enterprise_phonebook = EnterprisePhonebook(phonebook) with pytest.raises(RuntimeError): enterprise_phonebook.lookup("Bob")

python -m pytest -vv .\pb\tests\test_enterprise.py

… collected 2 items pb\tests\test_enterprise.py::test_lookup_authorized PASSED [ 50%] pb\tests\test_enterprise.py::test_lookup_not_authorized FAILED [100%] ========================= FAILURES ========================= ________________ test_lookup_not_authorized ________________ phonebook = <phonebook.Phonebook object at 0x0000021280A52FF0> def test_lookup_not_authorized(phonebook): phonebook.add("Bob", "1234") enterprise_phonebook = EnterprisePhonebook(phonebook) > with pytest.raises(RuntimeError): ^^^^^^^^^^^^^^^^^^^^^^^^^^^ E Failed: DID NOT RAISE <class 'RuntimeError'> pb\tests\test_enterprise.py:22: Failed ======================== short test summary info ======================== FAILED pb\tests\test_enterprise.py::test_lookup_not_authorized - Failed: DID NOT RAISE <class 'RuntimeError'> ========================================================== 1 failed, 1 passed in 0.06s ===========================================================

Так как в обоих тестах мы просто делаем обычный lookup() исключение RuntimeError не появляется и второй тест падает.

Появление зависимости

Изучим разные способы улучшить lookup() в EnterprisePhonebook

Очевидный способ улучшить авторизацию - делать запрос на сервер и если пользователь аутентифицирован - авторизовать запрос. Запрос можно по-прежнему делать обычным lookup() из Phonebook

import requests from phonebook import Phonebook class EnterprisePhonebook: def __init__(self, phonebook: Phonebook, auth_url): self.phonebook = phonebook self.auth_url = auth_url def lookup(self, name): request = requests.get(self.auth_url) if not request.status_code == 200: raise RuntimeError("unauthorized lookup") return self.phonebook.lookup(name)

В такой реализации мы сразу же получили зависимость от модуля requests.

EnterprisePhonebook → requsts

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

В этом случае тестировщикам будет гораздо легче создавать заглушки (test double)

EnterprisePhonebook → Промежуточный класс, который мы контролируем → requsts

Вынесем код, который делает запросы с помощью requests в файл authorization.py

# authorization.py import requests class SecurityClearanceAuthorizer: def __init__(self, url): self.url = url def is_authorized(self): request = requests.get(self.url) return request.status_code == 200

Теперь EnterprisePhonebook не делает запросы самостоятельно, а импортирует authorizer.

Код изменися не сильно, но нужно не забывать при создании экземпляра класса EnterprisePhonebook передавать в него authorizer.

# enterprise.py from phonebook import Phonebook class EnterprisePhonebook: def __init__(self, phonebook: Phonebook, authorizer): self.phonebook = phonebook self.authorizer = authorizer def lookup(self, name): authorized = self.authorizer.is_authorized() if not authorized: raise RuntimeError("unauthorized lookup") return self.phonebook.lookup(name)

Заглушка StubAuthorizer

Создадим заглушку на класс SecurityClearanceAuthorizer

Назовём этот класс-заглушку - StubAuthorizer

Так же как и оригинальный SecurityClearanceAuthorizer он должен иметь метод is_authorized(), который возвращает bool

# test_enterprise.py import pytest from enterprise import EnterprisePhonebook # - lookup when authorized # - lookup when not authorized - raise error class StubAuthorizer: def __init__(self, should_authorize): self.should_authorize = should_authorize def is_authorized(self): return self.should_authorize def test_lookup_authorized(phonebook): phonebook.add("Bob", "1234") stub_authorizer = StubAuthorizer(True) enterprise_phonebook = EnterprisePhonebook(phonebook, stub_authorizer) assert enterprise_phonebook.lookup("Bob") == "1234" def test_lookup_not_authorized(phonebook): phonebook.add("Bob", "1234") stub_authorizer = StubAuthorizer(False) enterprise_phonebook = EnterprisePhonebook(phonebook, stub_authorizer) with pytest.raises(RuntimeError): enterprise_phonebook.lookup("Bob")

python -m pytest -vv .\pb\tests\test_enterprise.py

… collected 2 items pb\tests\test_enterprise.py::test_lookup_authorized PASSED [ 50%] pb\tests\test_enterprise.py::test_lookup_not_authorized PASSED [100%] ============================= 2 passed in 0.01s ==============================

Введём баг в enterprise.py

# enterprise.py from phonebook import Phonebook class EnterprisePhonebook: def __init__(self, phonebook: Phonebook, authorizer): self.phonebook = phonebook self.authorizer = authorizer def lookup(self, name): authorized = self.authorizer.is_authorized() # BUG: должно быть if not authorized if authorized: raise RuntimeError("unauthorized lookup") return self.phonebook.lookup(name)

python -m pytest -vv .\pb\tests\test_enterprise.py

… collected 2 items pb\tests\test_enterprise.py::test_lookup_authorized FAILED [ 50%] pb\tests\test_enterprise.py::test_lookup_not_authorized FAILED [100%] ============================ FAILURES ============================ ______________________ test_lookup_authorized ______________________ phonebook = <phonebook.Phonebook object at 0x000001D471A26540> def test_lookup_authorized(phonebook): phonebook.add("Bob", "1234") stub_authorizer = StubAuthorizer(True) enterprise_phonebook = EnterprisePhonebook(phonebook, stub_authorizer) > assert enterprise_phonebook.lookup("Bob") == "1234" ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ pb\tests\test_enterprise.py:22: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = <enterprise.EnterprisePhonebook object at 0x000001D471A26C30>, name = 'Bob' def lookup(self, name): authorized = self.authorizer.is_authorized() if authorized: > raise RuntimeError("unauthorized lookup") E RuntimeError: unauthorized lookup pb\enterprise.py:13: RuntimeError _____________________________________ test_lookup_not_authorized ______________________ phonebook = <phonebook.Phonebook object at 0x000001D471A27530> def test_lookup_not_authorized(phonebook): phonebook.add("Bob", "1234") stub_authorizer = StubAuthorizer(False) enterprise_phonebook = EnterprisePhonebook(phonebook, stub_authorizer) > with pytest.raises(RuntimeError): ^^^^^^^^^^^^^^^^^^^^^^^^^^^ E Failed: DID NOT RAISE <class 'RuntimeError'> pb\tests\test_enterprise.py:30: Failed =================== short test summary info =================== FAILED pb\tests\test_enterprise.py::test_lookup_authorized - RuntimeError: unauthorized lookup FAILED pb\tests\test_enterprise.py::test_lookup_not_authorized - Failed: DID NOT RAISE <class 'RuntimeError'> =============================================================== 2 failed in 0.07s ================================================================

Оба теста упали. Там где ожидалось ислючение RuntimeError оно не произошло. Там где не ожидалось исключений появилось RuntimeError.

Исправим enterprise.py обратно.

Заменим if authorized: на if not authorized:

Теперь добавим баг в SecurityClearanceAuthorizer

# authorization.py import requests class SecurityClearanceAuthorizer: def __init__(self, url): self.url = url def is_authorized(self): request = requests.get(self.url) return not request.status_code == 403

python -m pytest -vv .\pb\tests\test_enterprise.py

… collected 2 items pb\tests\test_enterprise.py::test_lookup_authorized PASSED [ 50%] pb\tests\test_enterprise.py::test_lookup_not_authorized PASSED [100%] ============================= 2 passed in 0.01s ==============================

Так как мы используем заглушку - баг в оригинальном SecurityClearanceAuthorizer мы не нашли.

Фикстуры stub_authorizer и enterprise_phonebook

Сделаем StubAuthorizer и EnterprisePhonebook фикстурами stub_authorizer и enterprise_phonebook

Фикстура enterprise_phonebook будет зависеть и от stub_authorizer и от phonebook

import pytest from enterprise import EnterprisePhonebook # - lookup when authorized # - lookup when not authorized - raise error class StubAuthorizer: def __init__(self, should_authorize): self.should_authorize = should_authorize def is_authorized(self): return self.should_authorize @pytest.fixture def stub_authorizer(): return StubAuthorizer(True) @pytest.fixture def enterprise_phonebook(phonebook, stub_authorizer): return EnterprisePhonebook(phonebook, stub_authorizer) def test_lookup_authorized(phonebook, stub_authorizer, enterprise_phonebook): phonebook.add("Bob", "1234") assert enterprise_phonebook.lookup("Bob") == "1234" def test_lookup_not_authorized(phonebook, stub_authorizer, enterprise_phonebook): phonebook.add("Bob", "1234") stub_authorizer.should_authorize = False with pytest.raises(RuntimeError): enterprise_phonebook.lookup("Bob")

python -m pytest -vv .\pb\tests\test_enterprise.py

… collected 2 items pb\tests\test_enterprise.py::test_lookup_authorized PASSED [ 50%] pb\tests\test_enterprise.py::test_lookup_not_authorized PASSED [100%] ============================= 2 passed in 0.01s ==============================

create_autospec и Mock

Заменим нашу самописную заглушку StubAuthorizer на заглушку на основе unittest.mock

С помощью create_autospec создадим симуляцию экземпляра класса SecurityClearanceAuthorizer.

С помощью Mock будем симулировать метод is_authorized()

import pytest from unittest.mock import create_autospec, Mock from authorization import SecurityClearanceAuthorizer from enterprise import EnterprisePhonebook # - lookup when authorized # - lookup when not authorized - raise error @pytest.fixture def stub_authorizer(): stub = create_autospec(SecurityClearanceAuthorizer) stub.is_authorized = Mock(return_value=True) return stub @pytest.fixture def enterprise_phonebook(phonebook, stub_authorizer): return EnterprisePhonebook(phonebook, stub_authorizer) def test_lookup_authorized(phonebook, stub_authorizer, enterprise_phonebook): phonebook.add("Bob", "1234") assert enterprise_phonebook.lookup("Bob") == "1234" def test_lookup_not_authorized(phonebook, stub_authorizer, enterprise_phonebook): phonebook.add("Bob", "1234") stub_authorizer.is_authorized = Mock(return_value=False) with pytest.raises(RuntimeError): enterprise_phonebook.lookup("Bob")

python -m pytest -vv .\pb\tests\test_enterprise.py

… collected 2 items pb\tests\test_enterprise.py::test_lookup_authorized PASSED [ 50%] pb\tests\test_enterprise.py::test_lookup_not_authorized PASSED [100%] ============================= 2 passed in 0.01s ==============================

Окончательные версии:

import csv import dataclasses from typing import Tuple from phonebook import Phonebook class EnterprisePhonebook: def __init__(self, phonebook: Phonebook, authorizer, alerter, enterprise_logger): self.phonebook = phonebook self.authorizer = authorizer self.alerter = alerter self.logger = enterprise_logger def lookup(self, name): authorized = self.authorizer.is_authorized() # BUG: should be "if not authorized" if not authorized: raise RuntimeError("unauthorized lookup") self.logger.info(name) return self.phonebook.lookup(name) def add_from_file(self, filename): with open(filename) as file: self.add_data(file) def add_data(self, file): name = "Name" number = "Phone Number" expected_header = "%s,%s" % (name, number) actual_header = file.readline().strip() assert actual_header == expected_header reader = csv.DictReader(file, [name, number]) for row in reader: # Bug: should be "row[number]" self.phonebook.add(row[name], row[number]) def add(self, name, number): clashes = self.phonebook.find_clashes(number) if not clashes: self.phonebook.add(name, number) else: for i in range(len(clashes)): # Bug! Should be "clashes[i]" entry = clashes[i] event = BadPhonebookEntryEvent(len(self.phonebook), name, number, entry) self.alerter.send_alert(event) @dataclasses.dataclass class BadPhonebookEntryEvent: size: int new_name: str new_number: str problem: Tuple[str, str]

# test_enterprise.py import unittest from io import StringIO from textwrap import dedent from unittest.mock import create_autospec, Mock import pytest from alerts import InconsistentPhonebookAlerter from authorization import SecurityClearanceAuthorizer from enterprise import EnterprisePhonebook, BadPhonebookEntryEvent # - lookup when authorized # - lookup when not authorized - raise error # - batch update phonebook entries from a file # - don't add inconsistent entries # - report attempts to add inconsistent entries - publish an event # - record names that are looked up in a log file @pytest.fixture def stub_authorizer(): stub = create_autospec(SecurityClearanceAuthorizer) stub.is_authorized = Mock(return_value=True) return stub @pytest.fixture def spy_alerter(): spy = create_autospec(InconsistentPhonebookAlerter) return spy @pytest.fixture def enterprise_phonebook(phonebook, stub_authorizer, spy_alerter): return EnterprisePhonebook(phonebook, stub_authorizer, spy_alerter, Mock()) def test_alert_inconsistent_entry_attempts(enterprise_phonebook, spy_alerter): enterprise_phonebook.add("Bob", "12345") enterprise_phonebook.add("Sid", "12346") enterprise_phonebook.add("Ted", "1234") expected_calls = [ unittest.mock.call(BadPhonebookEntryEvent(2, "Ted", "1234", ("Bob", "12345"))), unittest.mock.call(BadPhonebookEntryEvent(2, "Ted", "1234", ("Sid", "12346"))), ] spy_alerter.send_alert.assert_has_calls(expected_calls) def test_do_not_add_inconsistent_entry(enterprise_phonebook): enterprise_phonebook.add("Bob", "12345") enterprise_phonebook.add("Sid", "12346") enterprise_phonebook.add("Ted", "1234") assert enterprise_phonebook.phonebook.storage == {"Bob": "12345", "Sid": "12346"} def test_update_from_file(enterprise_phonebook): fake_file = StringIO( dedent("""\ Name,Phone Number Bob,1234 Ann,6789""") ) enterprise_phonebook.add_data(fake_file) assert enterprise_phonebook.phonebook.storage == {"Bob": "1234", "Ann": "6789"} def test_lookup_authorized(phonebook, stub_authorizer, enterprise_phonebook): phonebook.add("Bob", "1234") assert enterprise_phonebook.lookup("Bob") == "1234" def test_lookup_not_authorized(phonebook, stub_authorizer, enterprise_phonebook): phonebook.add("Bob", "1234") stub_authorizer.is_authorized = Mock(return_value=False) with pytest.raises(RuntimeError): enterprise_phonebook.lookup("Bob")

# phonebook.py class Phonebook: def __init__(self): self.storage = {} def add(self, name, number): self.storage[name] = number def lookup(self, name): return self.storage[name] def names(self): return self.storage.keys() def is_consistent(self): for name1, number1 in self.storage.items(): for name2, number2 in self.storage.items(): if name1 == name2: continue if number1.startswith(number2): return False return True

# test_phonebook.py import pytest from phonebook import Phonebook # Look up by name # Missing name # Consistent # Inconsistent @pytest.fixture def phonebook(): return Phonebook() def test_is_consistent_empty(phonebook): assert phonebook.is_consistent() def test_is_consistent_one_entry(phonebook): phonebook.add("Bob", "1234") assert phonebook.is_consistent() def test_is_consistent_multiple_entry(phonebook): phonebook.add("Bob", "1234") phonebook.add("Sue", "78955") assert phonebook.is_consistent() def test_is_inconsistent_entry_prefix_of_another(phonebook): phonebook.add("Bob", "1234") phonebook.add("Ann", "12345678") assert not phonebook.is_consistent() def test_is_inconsistent_entry_identical(phonebook): phonebook.add("Bob", "1234") phonebook.add("Ted", "1234") assert not phonebook.is_consistent() @pytest.mark.parametrize( "entry1,entry2,is_consistent", [ [("Bob", "1234"), ("Ted", "1234"), False], [("Bob", "1234"), ("Ann", "12345678"), False], [("Bob", "1234"), ("Sue", "78955"), True], ] ) def test_is_consistent_two_entries(phonebook, entry1, entry2, is_consistent): phonebook.add(*entry1) phonebook.add(*entry2) assert phonebook.is_consistent() == is_consistent def test_lookup_missing_name(phonebook): with pytest.raises(KeyError): phonebook.lookup("Bob") def test_names(phonebook): phonebook.add("Bob", "1234") names = phonebook.names() assert ["Bob"] == list(names) def test_look_up_by_name(phonebook): phonebook.add("Bob", "1234") result = phonebook.lookup("Bob") assert result == "1234" def test_look_up_by_name_longer_number(phonebook): phonebook.add("Ann", "12345678") result = phonebook.lookup("Ann") assert result == "12345678"

Изображение баннера

Автор статьи: Андрей Олегович

Похожие статьи
Основы
Параметризация
@fixture
conftest.py
Удалённый запуск
Ошибки
Видео
Изображение баннера

Поиск по сайту

Подпишитесь на Telegram канал @aofeed чтобы следить за выходом новых статей и обновлением старых

Перейти на канал

@aofeed

Задать вопрос в Телеграм-группе

@aofeedchat

Контакты и сотрудничество:
Рекомендую наш хостинг beget.com
Пишите на info@aredel.com если Вы:
1. Хотите написать статью для нашего сайта или перевести статью на свой родной язык.
2. Хотите разместить на сайте рекламу, подходящую по тематике.
3. Реклама на моём сайте имеет максимальный уровень цензуры. Если Вы увидели рекламный блок недопустимый для просмотра детьми школьного возраста, вызывающий шок или вводящий в заблуждение - пожалуйста свяжитесь с нами по электронной почте
4. Нашли на сайте ошибку, неточности, баг и т.д. ... .......
5. Статьи можно расшарить в соцсетях, нажав на иконку сети: