5.TDD на Высокой и Низкой передаче

 TDD на Высокой и Низкой передаче

TDD на Высокой и Низкой передаче


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

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

Harry Says: Seeing a Test Pyramid in Action Was a Light-Bulb Moment

Вот несколько слов непосредственно от Гарри:

Я изначально скептически относился ко всем архитектурным паттернам Боба, но настоящая тестовая пирамида сделала меня новообращенным.

Как только вы реализуете моделирование предметной области и уровень сервиса, вы действительно можете перейти к этапу, когда модульные тесты на порядок превосходят интеграционные и сквозные тесты. Работая в местах, где тестовая сборка E2E заняла бы несколько часов (по сути, "подождите до завтра"), я не могу сказать вам, какая разница, если вы сможете запустить все свои тесты за считанные минуты или секунды.

Прочтите несколько рекомендаций о том, как решить, какие тесты писать и на каком уровне. Мышление с высокой передачей High Gear и низкой передачей Low Gear действительно изменило мою тестовую жизнь.

Как выглядит наша тестовая пирамида?

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

Example 1. Подсчет типов тестов

Неплохо! У нас есть 15 модульных тестов, 8 интеграционных тестов и всего 2 сквозных теста. Это уже здоровая на вид тестовая пирамида.

Должны ли тесты доменного уровня перейти на уровень сервиса?

Посмотрим, что произойдет, если мы сделаем еще один шаг. Поскольку мы можем тестировать наше программное обеспечение на уровне сервисов, нам больше не нужны тесты для модели предметной области. Вместо этого мы могли бы переписать все тесты уровня домена из [chapter_01_domain_model] с точки зрения уровня обслуживания:

Example 2. Переписывание теста домена на уровне сервиса (tests/unit/test_services.py)

Зачем нам это нужно?

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

В этом есть смысл, если вы перестанете задумываться о назначении автоматических тестов. Мы используем тесты, чтобы убедиться, что свойство системы не меняется во время работы. Мы используем тесты, чтобы проверить, что API продолжает возвращать 200, что сеанс базы данных продолжает фиксироваться и что заказы все еще распределяются.

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

По мере того, как мы углубимся в книгу, вы увидите, как уровень сервиса формирует API для нашей системы, которым мы можем управлять разными способами. Тестирование этого API сокращает объем кода, который нам нужно изменить при рефакторинге нашей модели предметной области. Если мы ограничимся тестированием только на уровне сервиса, у нас не будет никаких тестов, которые напрямую взаимодействуют с «частными» методами или атрибутами объектов нашей модели, что дает нам больше свободы для их рефакторинга.

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

О принятии решения О том, Какие Тесты писать

Вы можете спросить себя: «А стоит ли мне тогда переписать все свои модульные тесты? Разве неправильно писать тесты для модели предметной области?» Чтобы ответить на эти вопросы, важно понимать компромисс между связью и обратной связью по проекту (см. [test_spectrum_diagram]).

images/apwp_0501.png
Figure 1. Тестовый спектр

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

Однако мы получаем эту обратную связь только тогда, когда тесно работаем с целевым кодом. Тест HTTP API ничего не говорит нам о детальном дизайне наших объектов, потому что он находится на гораздо более высоком уровне абстракции.

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

На другом конце спектра тесты, которые мы написали в [chapter_01_domain_model], помогли нам конкретизировать наше понимание необходимых нам объектов. Тесты привели нас к разработке, которая имеет смысл и читается на языке предметной области. Когда наши тесты читаются на языке предметной области, мы чувствуем себя комфортно, потому что наш код соответствует нашей интуиции относительно проблемы, которую мы пытаемся решить.

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

Мы часто «зарисовываем» новое поведение, написав тесты на этом уровне, чтобы увидеть, как может выглядеть код. Однако, когда мы хотим улучшить дизайн кода, нам нужно будет заменить или удалить эти тесты, потому что они тесно связаны с конкретной implementation реализацей.

Высокая и низкая передача

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

Например, при написании функции add_stock или функции cancel_order мы можем работать быстрее и с меньшей связью, написав тесты на уровне сервиса.

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

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

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

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

Чтобы иметь уровень сервиса, который полностью отделен от домена, нам нужно переписать его API, чтобы работать в терминах примитивов.

Наш уровень обслуживания в настоящее время принимает доменный объект OrderLine:

Example 3. До: allocate принимает объект домена (service_layer/services.py)

Как бы это выглядело, если бы все его параметры были примитивными типами?

Example 4. После: allocate принимает строки и целые числа (service_layer/services.py)
def allocate(
        orderid: str, sku: str, qty: int, repo: AbstractRepository, session
) -> str:

Мы также переписываем тесты в этих терминах:

Example 5. Tests now use primitives in function call (tests/unit/test_services.py)
def test_returns_allocation():
    batch = model.Batch("batch1", "COMPLICATED-LAMP", 100, eta=None)
    repo = FakeRepository([batch])

    result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, FakeSession())
    assert result == "batch1"

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

Смягчение последствий: Храните Все доменные зависимости в функциях Fixture

Мы могли бы, по крайней мере, абстрагировать это до вспомогательной функции или фикстуры в наших тестах. Вот один из способов, которым вы могли бы это сделать, добавив фабричную функцию в FakeRepository:

Example 6. Фабричные функции для фикстур — это одна из возможностей (tests/unit/test_services.py)

По крайней мере, это переместило бы все зависимости наших тестов из домена в одно место.

Добавление отсутствующей службы

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

Example 7. Тест для нового сервиса add_batch (tests/unit/test_services.py)
def test_add_batch():
    repo, session = FakeRepository([]), FakeSession()
    services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, repo, session)
    assert repo.get("b1") is not None
    assert session.committed
TipВ общем, если вам нужно делать что-то на уровне домена непосредственно в тестах уровня сервиса, это может быть признаком того, что ваш уровень сервиса не завершен.

А реализация — это всего две строчки:

Example 8. Новый сервис для add_batch (service_layer/services.py)
def add_batch(
        ref: str, sku: str, qty: int, eta: Optional[date],
        repo: AbstractRepository, session,
):
    repo.add(model.Batch(ref, sku, qty, eta))
    session.commit()


def allocate(
        orderid: str, sku: str, qty: int, repo: AbstractRepository, session
) -> str:
    ...
NoteСтоит ли писать новую службу только потому, что она поможет устранить зависимости из ваших тестов? Возможно нет. Но в этом случае нам почти наверняка однажды понадобится сервис add_batchтак или иначе.

Теперь это позволяет нам переписать все наши тесты сервисного уровня исключительно с точки зрения самих сервисов, используя только примитивы и без каких-либо зависимостей от модели:

Example 9. Тесты сервисов теперь используют только сервисы (tests/unit/test_services.py)
def test_allocate_returns_allocation():
    repo, session = FakeRepository([]), FakeSession()
    services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, repo, session)
    result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, session)
    assert result == "batch1"


def test_allocate_errors_for_invalid_sku():
    repo, session = FakeRepository([]), FakeSession()
    services.add_batch("b1", "AREALSKU", 100, None, repo, session)

    with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"):
        services.allocate("o1", "NONEXISTENTSKU", 10, repo, FakeSession())

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

Внесение улучшений в тесты E2E

Точно так же, как добавление add_batch помогло отделить наши тесты сервисного уровня от модели, добавление конечной точки API для добавления пакета устранило бы необходимость в уродливом приспособлении add_stock, и наши тесты E2E могли бы быть свободны от этих жестко закодированных SQL-запросов и прямой зависимости от базы данных.

Благодаря нашей сервисной функции добавить endpoint очень просто, требуется всего лишь немного порботать с JSON и один раз вызвать функцию:

Example 10. API для добавления batch (entrypoints/flask_app.py)
@app.route("/add_batch", methods=['POST'])
def add_batch():
    session = get_session()
    repo = repository.SqlAlchemyRepository(session)
    eta = request.json['eta']
    if eta is not None:
        eta = datetime.fromisoformat(eta).date()
    services.add_batch(
        request.json['ref'], request.json['sku'], request.json['qty'], eta,
        repo, session
    )
    return 'OK', 201
NoteВы думаете про себя, POST to _ /add_batch_? Это не очень RESTful! Вы совершенно правы. Мы, к счастью, небрежны, но если вы хотите сделать все более RESTy, возможно, POST to /batches,тогда сам щёлкни себя по носу! Поскольку Flask - тонкий адаптер, это будет несложно. See the next sidebar.

И наши жестко закодированные SQL-запросы из conftest.py заменяются некоторыми вызовами API, что означает, что тесты API не имеют никаких зависимостей, кроме API, что тоже неплохо:

Example 11. Тесты API теперь могут добавлять свои собственные пакеты (tests/e2e/test_api.py)
def post_to_add_batch(ref, sku, qty, eta):
    url = config.get_api_url()
    r = requests.post(
        f'{url}/add_batch',
        json={'ref': ref, 'sku': sku, 'qty': qty, 'eta': eta}
    )
    assert r.status_code == 201


@pytest.mark.usefixtures('postgres_db')
@pytest.mark.usefixtures('restart_api')
def test_happy_path_returns_201_and_allocated_batch():
    sku, othersku = random_sku(), random_sku('other')
    earlybatch = random_batchref(1)
    laterbatch = random_batchref(2)
    otherbatch = random_batchref(3)
    post_to_add_batch(laterbatch, sku, 100, '2011-01-02')
    post_to_add_batch(earlybatch, sku, 100, '2011-01-01')
    post_to_add_batch(otherbatch, othersku, 100, None)
    data = {'orderid': random_orderid(), 'sku': sku, 'qty': 3}
    url = config.get_api_url()
    r = requests.post(f'{url}/allocate', json=data)
    assert r.status_code == 201
    assert r.json()['batchref'] == earlybatch

Заключение

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

Резюме: Эмпирические правила для различных типов тестов
Стремитесь к одному сквозному тесту для каждой функции.

Это может быть написано, например, для HTTP API. Цель состоит в том, чтобы продемонстрировать, что функция работает, и что все движущиеся части правильно склеены.

Пишите основную часть ваших тестов на уровне сервиса.

Эти сквозные тесты предлагают хороший компромисс между охватом, временем выполнения и эффективностью. Каждый тест обычно охватывает один путь кода функции и использует подделки для ввода-вывода. Это место, где можно полностью охватить все крайние случаи и тонкости вашей бизнес-логики.[1]

Поддерживайте небольшое ядро ​​тестов, написанных для вашей модели предметной области.

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

Обработку ошибок считайте функцией.

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

В этом вам помогут несколько вещей:

  • Выражайте уровень обслуживания в терминах примитивов, а не объектов предметной области.

  • В идеальном мире у вас будут все сервисы, которые вам нужны, чтобы иметь возможность полностью протестировать уровень сервиса, а не взламывать состояние через репозитории или базу данных. Это также окупается в ваших сквозных тестах.

Переходим к следующей главе!


1. Обоснованное беспокойство по поводу написания тестов на более высоком уровне заключается в том, что это может привести к комбинаторному взрыву для более сложных случаев использования. В этих случаях может быть полезно перейти к модульным тестам более низкого уровня различных сотрудничающих объектов домена. Смотрите также [chapter_08_events_and_message_bus] и [fake_message_bus].

Комментарии

Популярные сообщения из этого блога

4.Наш первый Use Case или пример использования: Flask API и Service Layer

Введение

2.Repository Pattern