Search
Write a publication
Pull to refresh
13.32

Тесты не лгут — прислушивайтесь к ним: часть 2

Level of difficultyMedium
Reading time13 min
Views1.3K
A tester and a dev working together
Тестируем все вместе

(Статья — результат совместной работы с Максимом Степановым)

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

  • Хрупкость

  • Зависимость от внешних систем

  • Невозможность протестировать пользовательский сценарий в отдельности

  • Избыточное покрытие

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

  • Тестовые дублёры, которые могли бы заменить реальные веб-сервисы или запись на диск

  • Способ контролировать, какие конкретные вызовы делают наши функции

Но для текущей версии кода мы такой тест написать не сможем. Поэтому перейдём к следующему рефакторингу. 

Шаг 3: Разъединение зависимостей

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

Вот пример того, как это выглядит:

def save_measurement(
    save_city: SaveCityFunction, # операция ввода/вывода теперь передается извне
    measurement: Measurement,
    diff: TemperatureDiff|None
):
    """  
    Если прошло достаточно времени с последнего измерения, сохраняем измерение. 
    """
    if diff is None or (measurement.when - diff.when) > timedelta(hours=6):
        new_record = HistoryCityEntry(
            when=measurement.when,
            temp=measurement.temp,
            feels=measurement.feels
        )
        save_city(measurement.city, new_record)

(источник)

Мы заметили на прошлом этапе, что в save_measurement нам не удалось разделить зоны ответственности: в этой функции по-прежнему содержится и логика приложения (проверка того, следует ли сохранять), и ввод/вывод (собственно сохранение). Теперь же ввод/вывод внедряется извне, и это делает функцию более согласованной, её единственной ответственностью остаётся логика приложения.

Обратите внимание, что внедренная часть является абстракцией: мы создали для неё отдельный тип, SaveCityFunction. Это делает код менее связанным, поскольку функция не зависит напрямую от внешней системы, а полагается на абстракцию. Реализацию этой абстракции можно изменять, при этом ничего не трогая внутри функции.

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

Такой подход имеет множество преимуществ:

  • Изменяемость — можно менять реализацию без затрагивания логики.

  • Устойчивость к деградации — модули меньше зависят друг от друга.

  • Тестируемость — легко изолировать логику от ввода/вывода.

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

Тесты для шага 3

На этом шаге мы применили новый подход к функции save_measurement — поэтому попробуем протестировать именно её. Внедрение зависимостей позволяет нам написать тестовый дублёр, который мы будем использовать вместо выполнения реальных операций ввода/вывода:

@dataclass
class __SaveSpy:
    calls: int = 0
    last_city: str | None = None
    last_entry: HistoryCityEntry | None = None


@pytest.fixture
def save_spy():
    spy = __SaveSpy()
    def __save(city, entry):
        spy.calls += 1
        spy.last_city = city
        spy.last_entry = entry
    yield __save, spy

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

Вот как мы протестировали save_measurement с этим шпионом:

@pytest.fixture
def measurement():
    yield Measurement(
        city="New York",
        when=datetime(2023, 1, 2, 0, 0, 0),
        temp=8,
        feels=12,
    )

@allure.title("save_measurement должна выполнить операцию сохранения, если не обнаружено прошлых измерений")
def test_measurement_with_no_diff_saved(save_spy, measurement):
    save, spy = save_spy

    save_measurement(save, measurement, None)

    assert spy.calls == 1
    assert spy.last_city == "New York"
    assert spy.last_entry == HistoryCityEntry(
        when=datetime(2023, 1, 2, 0, 0, 0),
        temp=8,
        feels=12,
    )


@allure.title("save_measurement не должна выполнять операцию сохранения, если обнаружено недавнее измерение")
def test_measurement_with_recent_diff_not_saved(save_spy, measurement):
    save, spy = save_spy

    # Прошло менее 6 часов
    save_measurement(save, measurement, TemperatureDiff(
        when=datetime(2023, 1, 1, 20, 0, 0),
        temp=10,
        feels=10,
    ))

    assert not spy.calls


@allure.title("save_measurement должна выполнить операцию сохранения, если прошло достаточно времени с последнего измерения")
def test_measurement_with_old_diff_saved(save_spy, measurement):
    save, spy = save_spy

    # Прошло более 6 часов
    save_measurement(save, measurement, TemperatureDiff(
        when=datetime(2023, 1, 1, 17, 0, 0),
        temp=-2,
        feels=2,
    ))

    assert spy.calls == 1
    assert spy.last_city == "New York"
    assert spy.last_entry == HistoryCityEntry(
        when=datetime(2023, 1, 2, 0, 0, 0),
        temp=8,
        feels=12,
    )

(источник)

Обратите внимание, насколько хорошо мы сейчас контролируем save_measurement. Раньше, если бы мы захотели протестировать, обнаруживает ли эта функция прошлые измерения, нам пришлось бы вручную удалять файл с этими измерениями — кошмар! Благодаря тестовому дублёру эта проверка теперь автоматизирована.

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

Шаг 4: Модульная архитектура

Наш код полностью переродился. Вот центральный модуль, app_logic:

def get_temp_diff(
    last_measurement: HistoryCityEntry | None,
    new_measurement: Measurement
) -> TemperatureDiff|None:
    if last_measurement is not None:
        return TemperatureDiff(
            when=last_measurement.when,
            temp=new_measurement.temp - last_measurement.temp,
            feels=new_measurement.feels - last_measurement.feels
        )


def save_measurement(
    save_city: SaveCityFunction,
    measurement: Measurement,
    diff: TemperatureDiff|None
):
    if diff is None or (measurement.when - diff.when) > timedelta(hours=6):
        new_record = HistoryCityEntry(
            when=measurement.when,
            temp=measurement.temp,
            feels=measurement.feels
        )
        save_city(measurement.city, new_record) # внедрённое IO


def local_weather(
    get_my_ip: GetIPFunction,
    get_city_by_ip: GetCityFunction,
    measure_temperature: MeasureTemperatureFunction,
    load_last_measurement: LoadCityFunction,
    save_city_measurement: SaveCityFunction,
    show_temperature: ShowTemperatureFunction,
):
    # Логика приложения (Пользовательский сценарий)
    # Низкоуровневые зависимости внедряются во время выполнения
    # Логика инициализации теперь в __init__.py
    # Функцию можно тестировать с помощью заглушек, стабов и шпионов!

    ip_address = get_my_ip() # внедрённый ввод/вывод
    city = get_city_by_ip(ip_address) # внедрённый ввод/вывод
    if city is None:
        raise ValueError("Не удаётся определить город")
    measurement = measure_temperature(city) # внедрённый ввод/вывод
    last_measurement = load_last_measurement(city) # внедрённый ввод/вывод
    diff = get_temp_diff(last_measurement, measurement) # логика приложения
    save_measurement(save_city_measurement, measurement, diff) # логика приложения (с внедрённым вводом/выводом)
    show_temperature(measurement, diff) # внедрённый ввод/вывод

(источник)

Наш код теперь как конструктор Лего. Функции собираются при инициализации приложения (в init.py), а центральный модуль их только выполняет. В результате главный модуль работает только с абстракциями и не обращается к низкоуровневому коду, который полностью скрыт в подмодулях (console_io, file_io и web_io). 

Вот как мы передаём конкретные функции в главный модуль из init.py:

def local_weather(
    get_my_ip=None,
    get_city_by_ip=None,
    measure_temperature=None,
    load_last_measurement=None,
    save_city_measurement=None,
    show_temperature=None,
):
    # Логика инициализации
    default_load_last_measurement, default_save_city_measurement =\
        file_io.initialize_history_io()
    return app_logic.local_weather(
        get_my_ip=get_my_ip or web_io.get_my_ip,
        get_city_by_ip=get_city_by_ip or web_io.get_city_by_ip,
        measure_temperature=measure_temperature or web_io.init_temperature_service(
            file_io.load_secret
        ),
        load_last_measurement=load_last_measurement or default_load_last_measurement,
        save_city_measurement=save_city_measurement or default_save_city_measurement,
        show_temperature=show_temperature or console_io.print_temperature,
    )

Инициализация здесь выполнена с помощью функций (file_io.initialize_history_io() и web_io.init_temperature_service()) — но с таким же успехом инициализировать можно было бы и через классы, например, создать объект класса WeatherClient. На Java мы почти наверняка так и сделали бы, но здесь остальной код написан в более функциональном стиле, поэтому мы решили быть последовательными и сделать всё через функции.

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

Тесты для шага 4

Финальную версию нашей тестовой можно разбить на три отдельных модуля:

  • e2e_test — нам нужен только один E2E тест, потому что наш пользовательский сценарий довольно простой. Мы уже написали этот тест на шаге 1.

  • plugin_test — это тесты для низкоуровневых функций; их полезно иметь, но они медленные и хрупкие. Мы написали их на шаге 2.

  • unit_test — здесь происходит всё самое интересное.

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

  • заглушки (dummies) — простые заполнители

  • стабы (stubs) — возвращают жёстко закодированное значение

  • шпионы (spies) — регистрируют вызовы

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

Тест для пользовательского сценария

@allure.title("local_weather должна использовать переданный ей город")
def test_temperature_of_current_city_is_requested():    
    def get_ip_stub(): return "1.2.3.4"  
    def get_city_stub(*_): return "New York"  
    captured_city = None  
  
    def measure_temperature(city):  
        nonlocal captured_city  
        captured_city = city  
        # Выполнение local_weather остановится здесь  
        raise ValueError()  
    
    def dummy(*_): raise NotImplementedError()  
  
    # Нас не интересует большая часть выполнения local_weather,  
    # поэтому мы можем передать заглушки, которые никогда не будут вызваны    
    with pytest.raises(ValueError):  
        local_weather(  
            get_ip_stub,  
            get_city_stub,  
            measure_temperature,   
            dummy,  
            dummy,  
            dummy  
        )  
  
    assert captured_city == "New York"

Здесь мы тестируем только один аспект функции local_weather: мы хотим убедиться, что она использует правильный город. Дублёр позволяет выполнить только необходимую  часть функции.

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

@allure.title("Сценарий использования должен сохранить измерение, если не обнаружено прошлых записей")  
def test_new_measurement_is_saved(measurement, history_city_entry):  
    # Нас не интересует это значение:  
    def get_ip_stub(): return "Not used"  
    # И это тоже:  
    def get_city_stub(*_): return "Not used"  
    # Мы проверяем это:  
    def measure_temperature(*_): return measurement  
    # local_weather должна считать, что
    # недавних измерений на диске нет:    
    def last_measurement_stub(*_): return None  
  
    captured_city = None  
    captured_entry = None  
  
    # Шпион, который регистрирует попытки local_weather 
    # записать данные на диск:
    def save_measurement_spy(city, entry):  
        nonlocal captured_city  
        nonlocal captured_entry  
        captured_city = city  
        captured_entry = entry  
  
    def show_temperature_stub(*_): pass  
  
    local_weather(  
        get_ip_stub,  
        get_city_stub,  
        measure_temperature,  
        last_measurement_stub,  
        save_measurement_spy,  
        show_temperature_stub,  
    )  
  
    assert captured_city == "New York"  
    assert captured_entry == history_city_entry

С помощью дублёров мы можем контролировать выполнение local_weather и заставить её думать, что на диске ничего нет, при этом не выполняя реальных операций чтения. Конечно, можно протестировать и противоположное поведение — опять же, без каких-либо операций ввода/вывода (это делается в test_recent_measurement_is_not_saved). Эти и другие тесты покрывают все возможные пути выполнения нашего пользовательского  сценария.

Тестовая база со слабой связанностью

У созданной нами тестовой сюиты множество преимуществ.

Скорость выполнения

Поскольку у кода слабая связанность, а тесты гранулярные, мы можем разделить их на быстрые и медленные. В pytest это можно сделать с помощью пользовательских меток, например, fast и slow (мы это обсуждали в первой части статьи). При наличии таких меток можно запустить быстрые тесты отдельно через консоль:

pytest tests -m "fast"

Быстрые тесты в основном находятся в модуле unit_test — к нему метка fast применена на уровне модуля. Откуда мы знаем, что в нём всё быстрое? Потому что все компоненты  там разъединены; можно отключить компьютер от интернета, и всё по-прежнему будет работать. Сравним скорость юнит-тестов и тех, которым приходится иметь дело с внешними ресурсами:

Difference in speed
Разница в скорости

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

Долговечность

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

Точка зрения пользователя

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

Низкоуровневые тексты позволяют выйти из локального контекста, но вы все ещё по локоть в коде. Если же вы тестируете API, вы принимаете точку зрения пользователя (этим пользователем может быть другой программист или вы сами в будущем). Идеальный тест всегда имитирует пользователя.

Публичный API, не зависящий от подробностей реализации — это контракт с пользователем, обещание, что предоставленная ему ручка не изменится. Если тесты привязаны к этому API (как наши юнит-тесты), их написание заставляет вас взглянуть на свой код через этот контракт. Вы лучше понимаете, как структурировать приложение. И если API неуклюж и неудобен в использовании, это вы тоже увидите.

Предсказание эволюции кода

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

Давайте сравним шаг 4 (инверсия зависимостей) с шагом 2 (где мы просто спрятали всё в функции). Мы добились гораздо большей разъединённости, что обеспечило множество преимуществ, включая более низкую стоимость изменений. Но код в шаге 2 гораздо проще, а в Python простое лучше сложного, разве не так? 

Без тестов преимущества инверсии зависимостей в нашем коде стали бы очевидными только если бы мы попытались расширить приложение. Почему они стали бы очевидными? Потому что мы получили бы больше сценариев использования и нам пришлось бы добавить новые функции. Это вызвало бы гниение кода и показало бы стоимость изменений. 

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

Испытание дополнением

Новый провайдер города

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

def get_city_by_ip(ip: str):
    """Определяем город с помощью IP"""

    url = f"https://geolocation-db.com/json/{ip}?position=true"
    response = requests.get(url).json()
    return response["city"]

(источник)

Затем нам нужно вызвать local_weather (наш пользовательский сценарий) в main.py, передав в него новую функцию:

local_weather(get_city_by_ip=weather_geolocationdb.get_city_by_ip)

Мы изменили только одну строку. Здесь нам удалось соблюсти правило открытости/закрытости, которое гласит, что код должен быть открытым к дополнению, но закрытым к изменению. 

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

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

Новый вывод

Теперь давайте изменим вывод погоды для пользователя. Раньше мы делали это через консоль с помощью простого вызова print(). Затем, чтобы сделать логику приложения тестируемой, нам пришлось заменить это функцией, передаваемой в качестве переменной. Это могло показаться ненужным усложнением, которое пришлось ввести только ради тестируемости. Но что будет, если мы захотим добавить к приложению простой пользовательский интерфейс?

The tkinter UI
UI на Tkinter

Это примитивное окно, написанное на Tkinter. Вы можете взглянуть на код здесь, но реализация нам сейчас не важна. Что потребуется изменить в логике приложения, чтобы добавить этот интерфейс? Ответ — буквально ничего. Теперь мы просто запускаем приложение из модуля нового UI, и добавляем новую функцию для вывода:

def local_weather():  
    weather.local_weather(  
        show_temperature=show_temperature  
    )

Эта функция запускается кнопкой Tkinter. Как видим, здесь мы тоже расширяем, а не изменяем код.

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

Чтобы подвести итог, представим все шаги рефакторинга и полученную от них выгоду в таблице “до/после”:

Шаг

Что было «до»

Что стало «после»

Применённые принципы SOLID

Выгоды / недостатки

1. Исходная версия

Одна большая функция, смешаны логика, ввод/вывод, работа с внешними API.

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

2. Разбиение на функции

Вынесена часть логики в отдельные функции, но зависимости всё ещё внутри.

Чуть больше модульности, но всё ещё сильная связанность с I/O.

SRP (Single Responsibility Principle) (частично)

Немного проще читать код, но тесты по-прежнему зависят от реальных сервисов.

3. Внедрение зависимостей (Dependency Injection)

Функции сами создают подключения к внешним системам и вызывают их напрямую.

Ввод/вывод передаётся через параметры (save_city, get_my_ip и т.д.).

DIP (Dependency Inversion Principle), SRP

Можно подставлять тестовые дублёры (stubs, spies). Логика тестируется без внешних сервисов.

4. Модульная архитектура

Главная функция знает про низкоуровневые детали.

Логика инициализации вынесена в init.py, модули работают только с абстракциями.

SRP + DIP + OCP (Open/Closed Principle)

Код как «Лего» — легко собирать из модулей. Тесты быстрые и независимые. Расширяем без изменения старого кода, тесты не ломаются

И вот какую архитектуру мы получили:

схема от
схема от @VadimLunin

Заключение

Тестируемость и SOLID

Работая над нашим примером, мы на каждом шаге рефакторинга проходили один и тот же цикл:

  1. Нужно написать тесты

  2. У нас не получается, потому что код не позволяет

  3. Мы переписываем код

  4. Мы получаем множество преимуществ, не сводящихся к тестированию

Во многом нам помогли принципы SOLID, в частности:

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

  • Инверсия зависимостей позволила нам заменить дорогие вызовы дублёрами и написать быструю тестовую сюиту.

  • Эти два принципа позволили соблюсти нам правило открытости/закрытости. Благодаря ему мы смогли добавлять новый функционал, не меняя существующие тесты.

Неудивительно, что авторы, пишущие о SOLID, так много внимания уделяют тестируемости, упоминают тестируемость: в конце концов, принципы SOLID были сформулированы человеком, который много внёс и в движение TDD (Test-Driven Development, разработка через тестирование).

Но TDD — довольно спорная тема, и мы не станем в неё углубляться. Не будем сейчас обсуждать, обязательно ли писать тесты до того, как пишешь код. Главное, что тесты пишутся, и что к советам тестировщиков прислушиваются; это обязательно сделает ваш код лучше. Причём не только с точки зрения корректности, но и с точки зрения структуры. 

Иначе говоря, разработчики должны участвовать в обеспечении качества; если отдел QA тихо занимается своим делом в отрыве от остальной команды, его эффективность существенно снижается. Больше того, исследования показали, что

"наличие автоматизированных тестов, созданных и поддерживаемых в основном QA или аутсорсинговой стороной, не коррелирует с производительностью IT".

Тесты дают подсказки о структуре кода

Мы не пытаемся доказать, что тестируемость лежит в основе хорошей архитектуры. Наоборот, следование лучшим инженерным практикам (таким, как SOLID) даёт в качестве одного из преимуществ хорошую тестируемость.

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

Tags:
Hubs:
+1
Comments2

Articles

Information

Website
qatools.ru
Registered
Founded
Employees
51–100 employees
Location
Россия