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 | |
| Удалённый запуск | |
| Ошибки | |
| Видео |