3.Краткая интерлюдия: О Связях и Абстракции

 


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

Tip

Код для этой главы находится в ветке chapter_03_abstractions on GitHub:

git clone https://github.com/cosmicpython/code.git
git checkout chapter_03_abstractions

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

Когда мы не можем изменить компонент A из опасения сломать компонент B, мы говорим, что компоненты стали связанными или сцепленными (coupled). Локальное сцепление — это хорошо: это признак того, что наш код работает дружно "всем коллективом", каждый компонент поддерживает другие, все они подходят друг к другу, как колёсики в часах. Говоря на жаргоне будет сказано как то так: это работает, когда существуют жесткие связи между связанными элементами.

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

Мы можем уменьшить степень сцепления внутри системы ([coupling_illustration1]) абстрагируясь от деталей ([coupling_illustration2]).

images/apwp_0301.png
Figure 1. Много жёстких связей
images/apwp_0302.png
Figure 2. Меньше жёстких связей

На обеих диаграммах у нас есть пара подсистем, одна из которых зависит от другой.В [coupling_illustration1] между ними существует высокая степень связаности; количество стрелок указывает на множество видов зависимостей между ними. Если нам нужно изменить систему B, есть большая вероятность, что это изменение отразится на системе A.

Однако в [coupling_illustration2] мы уменьшили степень связаности, вставив новую, более простую абстракцию. Поскольку она проще, система А имеет меньше видов зависимостей от абстракции. Абстракция служит для защиты нас от изменений, скрывая сложные детали того, что делает система B - мы можем изменить стрелки справа, не меняя стрелки слева.

Абстрагирование от Состояния Улучшает Тестируемость

Давайте рассмотрим пример. Представьте, что мы хотим написать код для синхронизации двух файловых каталогов, которые назовем source и destination:

  • Если файл существует в источнике, но не в месте назначения, скопируйте его.

  • Если файл существует в источнике, но имеет другое имя, отличное от имеющегося в папке назначения, переименуйте его в соответствующее.

  • Если файл существует в папке назначения, но отсутствует в источнике, удалите его.

Первое и третье требования достаточно просты: мы можем просто сравнить два списка путей. Но, вот, со вторым сложнее. Чтобы выявить необходимость переименования, нам придется проверить содержимое файлов. Для этого мы можем использовать функцию хеширования, такую ​​как MD5 или SHA-1. Код для генерации хэша SHA-1 из файла достаточно прост:

Example 1. Хеширование файла (sync.py)
BLOCKSIZE = 65536

def hash_file(path):
    hasher = hashlib.sha1()
    with path.open("rb") as file:
        buf = file.read(BLOCKSIZE)
        while buf:
            hasher.update(buf)
            buf = file.read(BLOCKSIZE)
    return hasher.hexdigest()

Теперь нам нужно чуть дописать, часть принятия решения "что делать" — Бизнес-логику, если хотите.

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

Наш первый подход выглядит примерно так:

Example 2. Базовый алгоритм синхронизации (sync.py)
import hashlib
import os
import shutil
from pathlib import Path

def sync(source, dest):
    # Пройдите по исходной папке и создайте список имен файлов и их хэшей
    source_hashes = {}
    for folder, _, files in os.walk(source):
        for fn in files:
            source_hashes[hash_file(Path(folder) / fn)] = fn

    seen = set()  # Следите за файлами, которые мы нашли в целевой папке

    # Пройдитесь по целевой папке и получите имена файлов и их хэши
    for folder, _, files in os.walk(dest):
        for fn in files:
            dest_path = Path(folder) / fn
            dest_hash = hash_file(dest_path)
            seen.add(dest_hash)

            # если в целевой папке есть файл, которого нет в исходной,
                        #  удалите его
                        if dest_hash not in source_hashes:
                dest_path.remove()

            # если в target есть файл, который имеет другой путь в source,
            # переместите его на правильный путь
            elif dest_hash in source_hashes and fn != source_hashes[dest_hash]:
                shutil.move(dest_path, Path(folder) / source_hashes[dest_hash])

    # для каждого файла, который появляется в исходной папке,
        # но не в целевой, скопируйте его в целевую
    for src_hash, fn in source_hashes.items():
        if src_hash not in seen:
            shutil.copy(Path(source) / fn, Path(dest) / fn)

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

Example 3. Парочка сквозных тестов (test_sync.py)
def test_when_a_file_exists_in_the_source_but_not_the_destination():
    try:
        source = tempfile.mkdtemp()
        dest = tempfile.mkdtemp()

        content = "Я очень полезный файл"
        (Path(source) / 'my-file').write_text(content)

        sync(source, dest)

        expected_path = Path(dest) /  'my-file'
        assert expected_path.exists()
        assert expected_path.read_text() == content

    finally:
        shutil.rmtree(source)
        shutil.rmtree(dest)


def test_when_a_file_has_been_renamed_in_the_source():
    try:
        source = tempfile.mkdtemp()
        dest = tempfile.mkdtemp()

        content = "Я файл, который был переименован"
        source_path = Path(source) / 'source-filename'
        old_dest_path = Path(dest) / 'dest-filename'
        expected_dest_path = Path(dest) / 'source-filename'
        source_path.write_text(content)
        old_dest_path.write_text(content)

        sync(source, dest)

        assert old_dest_path.exists() is False
        assert expected_dest_path.read_text() == content


    finally:
        shutil.rmtree(source)
        shutil.rmtree(dest)

Строго говоря, тут многовато установок для двух простых случаев! Проблема в том, что логика нашей предметной области «выяснение разницы между двумя каталогами» тесно связана с I/O кодом. Мы не можем запустить наш алгоритм поиска различий без вызова модулей pathlibshutil и hashlib.

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

Вдобавок наш код не очень расширяемый. Представьте, что вы пытаетесь реализовать флаг --dry-run, который заставляет наш код просто распечатать то, что он собирается делать, а не выполнять это на самом деле. А что, если мы хотим синхронизироваться с удаленным сервером или с облачным хранилищем?

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

Выбор правильной Абстракции(-й)

Что мы можем сделать, чтобы переписать наш код и сделать его более тестируемым?

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

  1. Мы опрашиваем файловую систему с помощью os.walk и определяем хэши для ряда путей. Это похоже как для исходного, так и конечного случая.

  2. Мы решаем, является ли файл новым, переименованным или лишним.

  3. Мы копируем, перемещаем или удаляем файлы в соответствии с источником.

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

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

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

source_files = {'hash1': 'path1', 'hash2': 'path2'}
dest_files = {'hash1': 'path1', 'hash2': 'pathX'}

А как насчет перехода от пункта 2 к пункту 3? Как мы можем абстрагироваться от фактического взаимодействия файловой системы перемещения/копирования/удаления?

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

("COPY", "sourcepath", "destpath"),
("MOVE", "old", "new"),

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

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

Example 4. Упрощенные входы и выходы в наших тестах (test_sync.py)

Реализация Выбранных Нами Абстракций

Это все очень хорошо, но как нам на самом деле написать эти новые тесты и как изменить нашу реализацию, чтобы все это работало?

Наша цель состоит в том, чтобы изолировать умную часть нашей системы и иметь возможность тщательно протестировать её без необходимости создавать реальную файловую систему. Мы создадим "ядро" кода, которое не имеет зависимостей от внешнего состояния, а затем посмотрим, как оно реагирует, когда мы даем ему входные данные из внешнего мира (такой подход был охарактеризован Гэри Бернхардтом как Functional Core, Imperative Shell, или FCIS).

Давайте начнем с разделения кода, чтобы отделить части с состоянием от логики.

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

Example 5. Разделим наш код на три (sync.py)
def sync(source, dest):
    # imperative shell Шаг 1, собрать входные данные
    source_hashes = read_paths_and_hashes(source)  #1
    dest_hashes = read_paths_and_hashes(dest)  #1

    # Шаг 2: вызов функционального ядра
    actions = determine_actions(source_hashes, dest_hashes, source, dest)  #2

    # imperative shell Шаг 3, применить результаты
    for action, *paths in actions:
        if action == 'copy':
            shutil.copyfile(*paths)
        if action == 'move':
            shutil.move(*paths)
        if action == 'delete':
            os.remove(paths[0])
1Первая функция, которую мы учитываем, read_paths_and_hashes(), которая изолирует часть ввода-вывода нашего приложения.
2Именно здесь мы вырежем функциональное ядро, бизнес-логику.

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

Example 6. Функция, которая просто выполняет ввод/вывод (sync.py)
def read_paths_and_hashes(root):
    hashes = {}
    for folder, _, files in os.walk(root):
        for fn in files:
            hashes[hash_file(Path(folder) / fn)] = fn
    return hashes

Функция define_actions() будет ядром нашей бизнес-логики, которая выясняет: «Учитывая эти два набора хэшей и имен файлов, что мы должны копировать/перемещать/удалять?». Она принимает простые структуры данных и возвращает простые структуры данных:

Example 7. Функция, которая просто выполняет бизнес-логику (sync.py)
def determine_actions(src_hashes, dst_hashes, src_folder, dst_folder):
    for sha, filename in src_hashes.items():
        if sha not in dst_hashes:
            sourcepath = Path(src_folder) / filename
            destpath = Path(dst_folder) / filename
            yield 'copy', sourcepath, destpath

        elif dst_hashes[sha] != filename:
            olddestpath = Path(dst_folder) / dst_hashes[sha]
            newdestpath = Path(dst_folder) / filename
            yield 'move', olddestpath, newdestpath

    for sha, filename in dst_hashes.items():
        if sha not in src_hashes:
            yield 'delete', dst_folder / filename

Теперь наши тесты действуют непосредственно на функцию determine_actions():

Example 8. Более приятные на вид тесты (test_sync.py)
def test_when_a_file_exists_in_the_source_but_not_the_destination():
    src_hashes = {'hash1': 'fn1'}
    dst_hashes = {}
    actions = determine_actions(src_hashes, dst_hashes, Path('/src'), Path('/dst'))
    assert list(actions) == [('copy', Path('/src/fn1'), Path('/dst/fn1'))]

def test_when_a_file_has_been_renamed_in_the_source():
    src_hashes = {'hash1': 'fn1'}
    dst_hashes = {'hash1': 'fn2'}
    actions = determine_actions(src_hashes, dst_hashes, Path('/src'), Path('/dst'))
    assert list(actions) == [('move', Path('/dst/fn2'), Path('/dst/fn1'))]

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

При таком подходе мы перешли от тестирования нашей основной функции точки входа sync() к тестированию функции более низкого уровня determine_actions(). Вы можете решить, что это нормально, потому что sync() теперь выполняется так просто. Или вы можете решить провести несколько интеграционных/приемочных тестов, чтобы проверить эту sync(). Но есть еще один вариант, который заключается в изменении функции sync(), чтобы её можно было тестировать модульно и тестировать от начала до конца; это подход, который Боб называет edge-to-edge testing.

Тестирование Edge to Edge с Fakes и Dependency Injection

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

Мы бы могли вернуться к нашим сквозным тестам, но они все еще так же сложны в написании и обслуживании, как и раньше. Вместо этого мы часто пишем тесты, которые вызывают целую систему вместе, но подделывают ввод-вывод, своего рода edge to edge:

Example 9. Явные зависимости (sync.py)
1Наша функция верхнего уровня теперь предоставляет две новые зависимости: reader и filesystem.
2Мы вызываем reader для создания наших файлов dict.
3Мы вызываем filesystem, чтобы применить обнаруженные нами изменения.
TipХотя мы используем инъекцию зависимостей, нет необходимости определять абстрактный базовый класс или какой-либо явный интерфейс. В этой книге мы часто показываем ABC, потому что надеемся, что этот модуль поможет вам понять, что такое абстракция, но в этом нет необходимости. Динамический характер Python означает, что мы всегда можем положиться на утиную типизацию[3].
Example 10. Тесты с использованием DI
1Боб обожает использовать списки для создания простых тестовых двойников, даже если это бесит его коллег. Это означает, что мы можем писать тесты вроде assert foo not in database.
2Каждый метод в нашей FakeFileSystem просто добавляет что-то в список, чтобы мы могли проверить это позже. Это пример spy object.

Преимущество этого подхода заключается в том, что наши тесты работают с той же функцией, которая используется нашим production кодом. Недостатком является то, что мы должны сделать наши компоненты с отслеживанием состояния явными и передавать их по кругу. Дэвид Хайнемайер Ханссон, создатель Ruby on Rails, как известно, описал это как "вызванное тестом повреждение конструкции."

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

Почему бы просто не запатчить это?

В этот момент вы можете почесать затылок и подумать: "Почему бы просто не использовать mock.patch и не сэкономить свои усилия?"

Мы избегаем использования моков в этой книге и в нашем production коде. Мы не собираемся устраивать ХолиВар по этому поводу, но инстинкт подсказывает, что mocking frameworks, особенно monkeypatching, - это дурнопахнущий код.

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

NoteВы можете увидеть пример в [chapter_08_events_and_message_bus], где мы mock.patch()-ем выводим модуль отправки электронной почты, но в конечном итоге заменяем его явным небольшим кодом внедрения зависимостей в [chapter_13_dependency_injection].

У нас есть три тесно связанных причины нашего предпочтения:

  • Исправление зависимости, которую вы используете, позволяет модульно протестировать код, но это никак не улучшает дизайн. Использование mock.patch не позволит вашему коду работать с флагом --dry-run и не поможет вам работать с FTP-сервером. Для этого вам нужно будет ввести абстракции.

  • Тесты, которые используют mocks стремятся быть более связанными с деталями реализации кодовой базы. Это потому, что имитационные тесты проверяют взаимодействие между объектами: вызывали ли мы shutil.copy с правильными аргументами? По нашему опыту, эта связь между кодом и тестом стремится сделать тесты более хрупкими.

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

NoteПроектирование для тестируемости на самом деле означает проектирование для расширяемости. Мы обмениваем немного большую сложность на более чистый дизайн, который допускает новые варианты использования.
Моки против фейков; Классический стиль в сравнении с TDD Лондонской школы[4]

Вот краткое и несколько упрощенное определение разницы между моком и фейком:

  • Моки используются для проверки как что-то используется; у них есть такие методы, как assert_called_once_with(). Они связаны с TDD лондонской школы.

  • Фейки-это рабочие реализации того, что они заменяют, но они предназначены для использования только в тестах. Они не будут работать "в реальной жизни"; наш репозиторий in-memory — хороший пример. Но вы можете использовать их, чтобы выполнить assert о конечном состоянии системы, а не о поведении на пути к этому состоянию, поэтому они связаны с классическим стилем TDD.

Здесь мы слегка смешиваем насмешки (mocks) со шпионами(spies) и фальшивки(fakes) с заглушками(stubs), однако вы можете прочитать длинный, правильный опус в классическом эссе Мартина Фаулера на эту тему под названием "Mocks Aren’t Stubs".

Также, вероятно, не помогает то, что объекты MagicMock, предоставляемые unittest.mock, строго говоря, не являются mocks; они шпионы(spies), если уж на то пошло. Но их также часто используют как заглушки(stubs) или пустышки(dummies). Ну вот, мы обещаем, что теперь покончим с придирками двойной терминологии тестирования.

А как насчет лондонской школы по сравнению с TDD в классическом стиле? Вы можете прочитать больше об этих двух подходах в статье Мартина Фаулера, которую мы только что процитировали, а также на Software Engineering Stack Exchange site, но в этой книге мы довольно твердо придерживаемся классицизма. Нам нравится строить наши тесты вокруг состояния как в сетапах, так и в ассертах, и нам нравится работать на самом высоком уровне абстракции, а не проверять поведение промежуточных участников.[5]

Подробнее об этом читайте в [kinds_of_tests].

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

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

В своем выступлении Стив Фриман приводит отличный пример чрезмерно замкнутых тестов. "Test-Driven Development". Вам также следует ознакомиться с этим выступлением PyCon, "Mocking and Patching Pitfalls", от нашего уважаемого технического обозревателя Эда Юнга, который также рассматривает mocking и их альтернативы. И в то время как мы рекомендуем доклады, не пропустите Брэндона Родса, говорящего о "Hoisting Your I/O", который действительно хорошо охватывает проблемы, о которых мы говорим, используя еще один простой пример.

TipВ этой главе мы потратили много времени, заменяя сквозные тесты модульными. Это не значит, что мы считаем, что вы никогда не должны использовать тесты E2E! В этой книге мы показываем методы, которые помогут вам составить достойную пирамиду тестов с максимально возможным количеством модульных тестов и с минимальным количеством тестов E2E, необходимых для уверенности. Прочтите [types_of_test_rules_of_thumb] для получения более подробной информации.
Так Что Же Мы Используем В Этой Книге? Функциональную или Объектно-ориентированную композицию?

Оба. Наша доменная модель полностью свободна от зависимостей и побочных эффектов, так что это наше функциональное ядро. Уровень сервиса, который мы строим вокруг него (в [chapter_04_service_layer]) позволяет нам управлять системой на перефирии, и мы используя инъекцию зависимостей, можем предоставить этим службам компоненты с отслеживанием состояния, так что мы все еще можем их модульно тестировать.

См. [chapter_13_dependency_injection] для более подробного изучения того, как сделать нашу инъекцию зависимостей более явной и централизованной.

Подведение итогов

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

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

  • Где я могу провести границу между моими системами, где я смогу использовать шов чтобы вставить эту абстракцию?

  • Что такое разумный способ разделения объектов на компоненты с различными обязанностями? Какие неявные понятия я могу сделать явными?

  • Что же такое зависимость, и каковы основные бизнес-логики?

Практика делает его менее несовершенным! А теперь вернемся к нашим баранам нашему обычному программированию…


1. code kata - это концепция, предлагающая оттачивать навыки программиста делая небольшие проблемы много раз, пытаясь улучшить код на каждой итерации. Название происходит от аналогии с Ката боевых искусств , где формы (aka kata) - это практика, выполняемая над и в результате улучшений. code kata - это небольшая, содержательная задача программирования, часто используемая для практики TDD. См. "Kata—The Only Way to Learn TDD" автор: Питер Провост.
2. Если вы привыкли мыслить терминами интерфейсов, то мы пытаемся дать определение именно этому. Прим переводчика: https://habr.com/ru/post/30444/
3. PEP 544 — Protocols: Structural subtyping (static duck typing) https://www.python.org/dev/peps/pep-0544/
4. "Лондонская школа", которая больше ориентирована на тестирование взаимодействия, mocking и end-to-end TDD, с особым упором на дизайн, основанный на ответственности, и подход «Говори, не спрашивай» к объектно-ориентированному дизайну, недавно повторно популяризированному Стивом Фриманом и Нэтом Прайсом, в потрясающей книге Growing Object Oriented Software Guided By Tests. http://codemanship.co.uk/parlezuml/blog/?postid=987
5. Это не значит, что мы считаем, что люди из лондонской школы ошибаются. Некоторые безумно умные люди работают именно так. Просто это не то, к чему мы привыкли.

Комментарии

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

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

Введение

2.Repository Pattern