Въведение
Писането на тестове винаги е било отрова за мен. И все още не съм на етапа да пиша тестове за всичко, но идвам наоколо. Достатъчно е да кажа, че всъщност се забавлявам, докато ги пиша. Начинът, по който го виждам, смисълът на модулните тестове е да се хващат грешки и то рано.
За твърдолюбивите поклонници на Юпитер като мен, въпросът е какво имате предвид да ги хванете рано? Просто копирате и поставяте вашия тестван код във файл .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 г.