Въведение

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

За твърдолюбивите поклонници на Юпитер като мен, въпросът е какво имате предвид да ги хванете рано? Просто копирате и поставяте вашия тестван код във файл .py и го наричате ден, нали? За съжаление, през повечето време кодът в един бележник на Jupyter е твърде объркан за монорепо на корпоративно ниво. Въпреки че съществуват проекти като nbdev, въвеждането на такава библиотека към съществуващо репо не е тривиално. Независимо от това може дори да е организационно изискване да има високо покритие на кода чрез тестване, доколкото е възможно.

Този урок е част от триковете, които взех по пътя, включително най-добрите практики. Те включват как да тествате големи модели на Deep Learning. Не твърдя, че съм гуру в тестването или нещо близо до него.

Структура на основния тест на единица (конвенции)

Обикновено ще имате папка tests, която ще съдържа тестови файлове, които започват с test_*.py. Тези файлове обикновено съответстват 1 към 1 с това, което е във вашата src директория, която тествате (напр. src/a.py ще има tests/test_a.py). Всяка функция/клас, който тествате, по подобен начин ще има def test_*() функция. Всички тествани функции трябва да започват с test_. И накрая, обикновено ще имате израз assert в тези тестове, но тестването надхвърля тези изрази и не е необходимост.

За да ги стартирате, можете просто да стартирате pytest /path/to/folders/tests/.

Инжектиране на зависимост

Тъй като те обикновено се изпълняват в рамките на CICD, важно е тези тестове да се изпълняват бързо. Следователно не трябва да инстанцираме големи NLP/CV модели вътре в тест. Един от начините да се заобиколи това е да се инжектира зависимостта към функция.

Помислете за следните две функции:

def create_classification_model(num_classes: int) -> nn.Module:
    model = models.resnet34(pretrained=True)
    return torch.nn.Sequential(
        *(
            list(model.children())[:-1] + [nn.Linear(512, num_classes)]
        )
    )

# don't name it with_injection, this is just for illustration
def create_classification_model_with_injection(base_model: nn.Module, num_classes: int) -> nn.Module:
    return torch.nn.Sequential(
        *(
            list(base_model.children())[:-1] + [nn.Linear(512, num_classes)]
        )
    )

От двете, втората е по-тестваема, тъй като не е необходимо 1. да инсталираме голям модел, 2. да изтегляме нещо от интернет. При тестване бихме могли да предадем нещо толкова просто като test_base_model = nn.Conv2D(3, 512). Въпреки че е вярно, че не тестваме пълен resnet модел, все още можем да проверяваме за грешки, които може да са причинени от изпълнението по-горе.

Pytest Fixtures и conftest.py

Да предположим, че имате нужда от model дефиниция за множество тестови функции. Въпреки че можем да създадем екземпляр на фиктивен модел вътре във функция test_*, един от начините да напишем този пример веднъж е да напишем функция, наречена def dummy_model() -> nn.Module и да я украсим с @pytest.fixture. След като това стане, можем да го предадем на тестовите функции като аргумент и pytest ще се погрижи за предаването в инстанцирана версия. Ако тази дефиниция на модела се изисква в други файлове за тестване, можем да я преместим в conftest.py, което ще я направи достъпна за всички файлове в тази tests директория. Ето пример за модел на фиктивен трансформатор и токенизатор във файл conftest.py.

@pytest.fixture
def model() -> transformers.PreTrainedModel:
    config = transformers.DistilBertConfig(
        vocab_size=4,  # must be the same as the vocab size in the tokenizer
        n_layers=1,
        n_heads=1,
        dim=4,
        hidden_dim=4,
    )
    model = transformers.DistilBertModel(config)
    return model

@pytest.fixture
def tokenizer(tmp_path: pathlib.Path) -> transformers.PreTrainedTokenizer:
    with open(tmp_path / "vocab.txt", "w") as f:
        f.write("[CLS]\n[SEP]\n[MASK]\n[UNK]\n")
    tokenizer = transformers.DistilBertTokenizer(tmp_path / "vocab.txt")
    return tokenizer
@pytest.fixture
def test_sentences() -> list[str]:
    return [
        "Never gonna give you up",
        "Never gonna let you down",
        "Never gonna run around and desert you",
    ]

И използването в тестов файл (не conftest) е показано по-долу:

def test_model_output(model, tokenizer, test_sentences):
    values = model(**tokenizer(test_sentences))
    assert len(values) == len(test_sentences)

Подигравателен

В зависимост от сложността и случая на използване може да не искате да конструирате фиктивен обект. Вместо това можем да създадем unittest.mock.Mock обекти. Магията на тези обекти е, че 1. Можете да ги извиквате с безкрайно много методи (с изключение на някои assert_* метода), което означава, че не е необходимо да прилагате методи, свързани с тези екземпляри.

Нека разгледаме функцията create_classification_model_with_injection. В този случай, вместо да създаваме фалшив тестов модел, нека направим следното:

def test_create_classification_model_with_injection():
    mock_model = mock.Mock()
    create_classification_model_with_injection(mock_model, 10)
    mock_model.children.assert_called_once()

В горното това, което тестваме, е, че атрибутът children на модела е извикан. Това означава, че всяка бъдеща реализация ще изисква children да бъде извикана в нейната реализация, освен ако тестовете не бъдат променени. Ще ви насоча към този отличен блог за допълнителна магия, която можете да правите с фалшиви класове.

Преди да продължа, искам да подчертая, че тестването на единица не трябва да се отнася до съпоставяне на входове с очаквани резултати.

Изкърпване

Някои функции изискват да извършвате действия, които не можете да тествате. Изтеглянето е един такъв пример. Да предположим, че имам тази функция:

# in models.py
def get_model_and tokenizer(model_name: str):
    model = AutoModel.from_pretrained(model_name)
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    return model, tokenizer

Един от начините да тествате това е да „закърпите“ функциите AutoModel.from_pretrained и AutoTokenizer.from_pretrained.

def test_get_model(model, tokenizer):
    with mock.patch.object(
        models.AutoModel, "from_pretrained", return_value=model
    ) as mock_model, mock.patch.object(
        models.AutoTokenizer, "from_pretrained", return_value=tokenizer
    ) as mock_tokenizer:
        model_returned, tokenizer_returned = models.get_model_and_tokenizer("bert")
    assert model == model_returned
    assert tokenizer == tokenizer_returned

В горния случай ние ефективно тестваме дали from_pretrained се извиква по време на функцията.

За да се използва mock.patch.object, първият аргумент е models.AutoModel, въпреки факта, че AutoModel идва от библиотеката transformers. Това е така, защото „инстанцията“, която коригираме, е във файла models.py. Вторият аргумент е низ от функцията, която извикваме, и накрая аргументът return_value принуждава тази функция да върне това независимо от аргумента.

Параметризиране

Може да искате да тествате за различни стойности на определен вход. Въпреки че е възможно да се направи това с помощта на for цикъл, pytest предлага pytest.mark.parametrize декоратора. Да предположим, че имаме фалшив базов модел за модела за класификация на изображения, който дефинирахме по-горе. В следващия пример можем да тестваме множество num_classes без да прибягваме до грозен for цикъл.

@pytest.mark.parametrize("num_classes", [10, 15])
def test_create_classification_model(
    base_model: nn.Module, # this comes from a fixture
    num_classes: int,
):
    model = create_classification_model_with_injection(base_model, num_classes)
    fake_input = torch.randn(16, 3, 28, 28) 
    assert model(fake_input).shape[-1] == num_classes

Заключение

В заключителните си бележки бих искал да подчертая, че някои тестове са по-добри от никакви. Аз лично не вярвам, че тестовете трябва да бъдат изчерпателни, но мога да разбера дали това е спорна точка.

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

Успех с вашето тестово пътуване!

Браво

Слава на Ryan Lin за цялата помощ при писането на тестове.

Безсрамна самореклама

Ако сте харесали урока купете моя курс (обикновено 90% отстъпка).

Първоначално публикувано на адрес https://sachinruk.github.io на 3 юли 2022 г.