Практические руководства

Рекомендации по тестированию библиотек машинного обучения

Разработка лучших библиотек с помощью pytest

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

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

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

В настоящее время мы используем pytest для тестирования всех наших внутренних библиотек. Итак, давайте начнем с базовых знаний о pytest!

Основы pytest

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

1. Структура папки

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

тесты

| - conftest.py

| - test_some_name.py

| - test_some_other_name.py

Как видим, есть один файл conftest.py и несколько файлов test _ *. Py. conftest.py - это место, где вы настраиваете тестовые конфигурации и храните тестовые наборы, которые используются тестовыми функциями. Конфигурации и тестовые наборы в pytest называются приспособлением. В файлах test _ *. Py находятся фактические тестовые функции. Помните, что это соглашение об именах является обязательным. В противном случае pytest не сможет найти фикстуры и тестовые функции.

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

2. Содержание conftest.py

Проще говоря, conftest.py - это набор фикстур pytest, которые используются тестовыми функциями в разных файлах test _ *. Py. Перед записью каких-либо приспособлений не забудьте

"""
conftest.py
"""
import pytest

2.1 Конфигурация приспособления

Сначала давайте рассмотрим пример приспособления для конфигурирования, приспособления для конфигурирования искры.

"""
conftest.py
"""
@pytest.fixture(scope="session")
def spark_session(request):
    """ fixture for creating a spark context
    Args:
    request: pytest.FixtureRequest object
    """
    spark = (
        SparkSession
        .builder
        .master("local[4]")
        .appName("testing-something")
        .getOrCreate()
    )
    request.addfinalizer(lambda: spark.sparkContext.stop())
    return spark

Вы должны заметить три вещи в этом фрагменте кода:

  1. На самом деле фикстура pytest - это просто функция, обернутая декоратором pytest.fixture, и она возвращает экземпляр Spark, который будет использоваться для тестирования.
  2. У него есть необязательный аргумент scope, который указывает, как долго будет сохраняться фиксация. По умолчанию он имеет значение «функция», поэтому прибор, в нашем случае экземпляр Spark, будет создан для каждой тестовой функции. Поскольку это дорогостоящая операция и экземпляр Spark может повторно использоваться различными функциями тестирования, мы указываем область действия как «сеанс», что означает, что он будет сохраняться в течение всего сеанса тестирования.
  3. Наша функция принимает аргумент запроса, который является встроенным приспособлением pytest. Мы используем его, чтобы остановить экземпляр Spark после завершения сеанса тестирования, что выполняется строкой перед оператором return. Если в вашей конфигурации не требуется этап разборки, вы можете просто удалить запрос из сигнатуры функции.

2.2 Крепление тестового корпуса

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

"""
conftest.py
"""
@pytest.fixture
def text_language_df():
    return pd.DataFrame({
        "text": ['hello', 'hola', 'bonjour'],
        "language": ["english", "spanish", "french"]
    })

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

Далее мы посмотрим на содержимое файлов test _ *. Py, и, надеюсь, вы увидите волшебство pytest.

3. Содержание теста _ *. Py

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

Итак, в нашем test_language_detection.py у нас будет этот фрагмент кода:

"""
test_language_detection.py
"""
# import the detect_language function here
def test_detect_language(text_language_df):
    for i in range(len(text_language_df)):
        assert detect_language(text_language_df.text[i]) == text_language_df.language[i]

Вы должны заметить две вещи в этом фрагменте кода:

  1. Название тестовой функции начинается с «test». Это необходимо для того, чтобы тестовая функция была видна pytest при ее вызове. Один из приемов, который использует это свойство, заключается в добавлении символа подчеркивания к имени тестовой функции, которую вы хотите пока пропустить.
  2. text_language_df - это прибор, который вы объявили в conftest.py. Без какого-либо импорта или дополнительных накладных расходов вы можете использовать его в любой из тестовых функций в любом из файлов test _ *. Py. Вы можете рассматривать его как обычный фрейм данных pandas.

Теперь вы поймете, почему мы говорим, что conftest.py - это «набор фикстур pytest, которые используются тестовыми функциями в разных файлах test _ *. Py». Эти приспособления определяются один раз и используются везде.

Фактически, pytest также позволяет создавать фикстуры pytest в каждом файле test _ *. Py. Мы считаем, что лучше всего поместить туда фикстуры, которые используются только этим одним файлом test _ *. Py, чтобы файл conftest.py не был перегружен фикстурами.

4. pytest CLI

pytest вызывается в CLI. Самый прямой способ - позвонить

$ pytest tests/
or
$ pytest tests/test_language_detection.py tests/test_something.py
or
$ pytest tests/test_language_detection.py::test_detect_language tests/test_something.py

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

Общие стратегии тестирования

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

0. Классификация тестов

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

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

1. Рабочий процесс тестирования

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

1.1 Рабочий процесс тестирования нового кода

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

1.2 Рабочий процесс тестирования кода исправления ошибок

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

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

2. Параметризация тестовых наборов

В предыдущем разделе мы показали, как создать фикстуру тестового примера в conftest.py, а затем повторно использовать ее в наших тестовых функциях. Приспособление testcase - это фрейм данных pandas, который состоит из списка тестовых наборов. Один из недостатков использования фреймов данных (фактически любой структуры данных коллекции, такой как список, кортеж, набор) заключается в том, что при сбое одного из тестовых наборов в кадре данных вся функция тестирования будет помечена как сбойная. Трудно определить, какой тестовый набор не выполняет функцию. Что еще более неудобно, если функция тестирования требует больших вычислительных ресурсов, вы не сможете получить результат тестирования для всех наборов тестов за один прогон, если один из наборов тестов выйдет из строя. Вы должны сначала исправить неудачный тестовый сценарий, повторно запустить pytest и повторить эту процедуру, если произойдет еще один сбой.

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

Для тестовых наборов, которые используются одной тестовой функцией

Это делается с помощью декоратора @ pytest.mark.parametrize, который применяется непосредственно к тестовой функции. Давайте посмотрим, как это работает, на нашем примере тестирования на определение языка.

"""
test_language_detection.py
"""
@pytest.mark.parametrize(
    "text,expected",
    [
        ('hello', 'english'),
        ('hola', 'spanish'),
        ('bonjour', 'french')
    ]
)
def test_detect_language(text, expected):
    assert detect_language(text) == expected

Это самоочевидно. За один запуск тестовая функция будет вызываться три раза, чтобы мы могли получить результат для каждого тестового случая отдельно.

Для получения дополнительной информации о параметризации тестовых функций обратитесь к официальной документации.

Для тестовых случаев, которые используются несколькими тестовыми функциями

В этом случае вместо параметризации тестовых функций мы параметризуем функцию фиксации.

"""
conftest.py
"""
import pytest
from collections import namedtuple
TestCase = namedtuple("TestCase", ["text", "expected"])
@pytest.fixture(
    params=[
        TestCase("hello", "english"),
        TestCase("hola", "spanish"),
        TestCase("bonjour", "french")
    ]
)
def test_case(request):
    return request.param

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

"""
test_language_detection.py
"""
def test_detect_language(test_case):
    assert detect_language(test_case.text) == test_case.expected

Вы должны заметить две вещи в этом фрагменте кода:

  1. Встроенная фиксация запросов отвечает за координацию параметризации. На первый взгляд это несколько нелогично, но вы можете думать об этом как о простом «синтаксисе» для параметризации.
  2. Аргумент params в pytest.fixture принимает список. Здесь мы определяем каждый элемент в списке как именованный набор, чтобы избежать жестко закодированных индексов или строк. Использование namedtuple позволяет нам ссылаться на ввод и вывод тестового примера как на test_case.text и test_case.expected позже в тестовой функции. Вместо этого, если у вас есть элементы, имеющие форму [«привет», «английский»], тогда вы должны ссылаться на них как на test_case [0] и test_case [1] в тестовой функции, что не является хорошей практикой программирования.

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

Еще одно неявное преимущество параметризации тестовых наборов состоит в том, что оно позволяет легко обновлять существующие тесты новыми тестовыми наборами. Мы считаем это чрезвычайно полезным в процессе тестирования кода исправления ошибок. Например, предположим, что пользователь сообщает нам, что функция detect_language неправильно присваивает «nǐ hǎo» значению «vietnamese», которое должно быть «китайским». Следуя рабочему процессу тестирования кода исправления ошибок, мы сначала добавляем регрессионные тесты. Поскольку тестовые наборы уже параметризованы, это можно сделать, просто добавив кортеж («nǐ hǎo», «китайский») в список, если мы используем @ pytest.mark.parametrize, или добавив TestCase («nǐ hǎo», «китайский»), если мы параметризуем функцию фиксации. Было бы намного сложнее добиться того же эффекта, если бы тестовые наборы не были параметризованы.

3. Имитация сложных классов

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

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

"""
test_classifier.py
"""
class MockedClassifier(Classifier):
    def load_dataset(self, *args, **kwargs):
        pass
    def load_model(self, *args, **kwargs):
        pass
    def compute_metrics(self, *args, **kwargs):
        pass

Затем мы можем использовать MockedClassifier вместо Classifier для проверки его функциональности. Например, чтобы протестировать его создание

"""
test_classifier.py
"""
def test_instantiation():
    trainer = MockedClassifier("init args here")

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

"""
test_class_with_expensive_operation.py
"""
class MockedClass(ClassWithExpensiveOP):
    def some_expensive_operation(self, *args, **kwargs):
        # code for lighter operation here

Продвинутый pytest

В последнем разделе мы рассмотрим некоторые из передовых техник pytest, которые мы считаем полезными из прошлого опыта.

1. Передача аргументов из командной строки

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

Чтобы добавить аргумент командной строки,

"""
conftest.py
"""
import pytest
def pytest_addoption(parser):
    parser.addoption(
        "--platform",
        action="store",
        default="platform_0",
        choices=["platform_0", "platform_1", "platform_2"],
        help="The name of the platform you are on"
)
@pytest.fixture
def platform(pytestconfig):
    return pytestconfig.getoption("platform")

Теперь мы можем позвонить

$ pytest tests/ --platform platform_2

И приспособление платформы будет хранить информацию о платформе, которую мы вводим из командной строки. По умолчанию используется «платформа_0», если не указана явная платформа.

Затем мы добавляем фиксатор пути к файлу, который определяется платформой, на которой мы находимся.

"""
conftest.py
"""
@pytest.fixture
def filepath(platform):
    if platform == "platform_0":
        return "the file path on platform_0"
    elif platform == "platform_1":
        return "the file path on platform_1"
    elif platform == "platform_2":
        return "the file path on "platform_2""

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

"""
test_file_load.py
"""
def test_load_file(filepath):
    with open(filepath, "r") as f:
        f.read()

2. Временные каталоги и файлы

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

3. mocker.spy

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

4. Разложение светильников.

Один из способов работы с приборами - поместить приборы, которые используются в нескольких файлах test _ *. Py, в conftest.py, оставив там приборы, специфичные для тестового модуля. Одним из потенциальных недостатков этого подхода является то, что он приводит к огромному размеру файла conftest.py, в котором сложно ориентироваться и который может привести к конфликтам слияния, даже если люди работают над разными модулями тестирования.

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

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

Чтобы быть более конкретным, предположим, что у нас есть dataset_fixtures.py и config_fixtures.py, которые выглядят следующим образом:

"""
dataset_fixtures.py
"""
import pytest
@pytest.fixture
def dataset_fixture_0():
    # some code here
@pytest.fixture
def dataset_fixture_1():
    # some code here
"""
config_fixtures.py
"""
import pytest
@pytest.fixture
def config_fixture_0():
    # some code here
@pytest.fixture
def config_fixture_1():
    # some code here

Затем, чтобы включить их обратно в conftest.py, нужно всего лишь добавить одну строку

"""
conftest.py
"""
import pytest
pytest_plugins = ["dataset_fixtures", "config_fixtures"]
# code for fixtures in conftest.py here

Вот и все! Эти методы тестирования довольно легко выполнить, но они действительно пригодятся на протяжении всего цикла разработки. Надеюсь, они помогут вам разрабатывать библиотеки Python быстрее и надежнее :)