Лучшие практики модульного тестирования — Именование
Я почти уверен, что все уже ознакомились с преимуществами модульного тестирования, поэтому я не буду утомлять вас этим постом. Вместо этого давайте сосредоточимся на том, как создавать отличные, удобные в сопровождении и легко читаемые модульные тесты. В этой серии постов я планирую познакомить вас с некоторыми распространенными передовыми методами модульного тестирования.
Контекст
Итак, давайте предположим, что нас только что завербовал наш друг доктор Оук, который вот-вот закончит свой шедевр, революционный покедекс. К сожалению, разработчики ушли из компании, так и не написав юнит-тестов. Стыд. И, осознавая важность модульного тестирования, он попросил нас сделать это за него. Кажется хорошим, верно?
Именование модульных тестов
Итак, начнем с самого начала. Да, мой дорогой читатель, выбор хорошего имени очень важен для будущего модульного теста. Хотя «TestAdd» и «TestFail» могут показаться приемлемыми вариантами, вы видите, что они не совсем ясны. И почему так? Давайте копаться в этом сейчас. Продолжая модульное тестирование Pokédex, мы собираемся создать тесты для функции «Сканировать». Вы знаете, когда пользователь наводит его на покемона, и он выдает кучу информации. Что-то, что мне действительно понравилось бы в играх.
Первое, что нужно знать об именах, это то, что модульные тесты должны документировать программное обеспечение. Например, давайте проанализируем код ниже:
public PokemonDescription ScanCreature(object creature)
{
if (creature is not Pokemon pokemon)
throw new NotAPokemonException("The specified creature is not a Pokémon");
return pokemon.Description;
}
Кажется достаточно простым. Итак, если бы мы создавали тест для этого класса, мы могли бы подумать, что «ScanWorks» будет понятен, так что давайте! О да, если вам интересно, что это за комментарии, они касаются шаблона AAA, очень аккуратного способа организовать ваши тесты. Вы можете прочитать об этом подробнее здесь.
Идеально верно? Да ну, я не могу утверждать, что это не работает, но есть некоторые проблемы с этим:
- Не глядя на тестовый код, мы не знаем точно, что значит «работает». Поэтому мы не можем посмотреть на модульные тесты этого класса и сразу узнать, что он должен делать.
- Что, если метод ScanCreature что-то изменит, например, его возвращаемое значение, и тест сломается? Нам пришлось бы потратить время на поиски того, что должен тестировать код. Конечно, в данном случае это довольно просто, но я видел модульные тесты с более чем 100 строками. Это не красивое зрелище.
Если вы все еще не уверены, позвольте мне немного усложнить ситуацию. Настроим наш метод ScanCreature:
public PokemonDescription ScanCreature(object creature)
{
if (creature is not Pokemon pokemon)
throw new NotAPokemonException("The specified creature is not a Pokémon");
if(pokemon.Generation is null)
throw new GenerationException("Unable to identify the generation of the Pokémon");
if(pokemon.Generation != Generation)
throw new GenerationException("This Pokémon is from a different generation than this pokédex. Consider upgrading to Pokedex PRO.");
if (pokemon.HasOwner)
throw new GDPRException("This Pokémon data is protected by the laws of GDPR");
return pokemon.Description;
}
Итак, теперь, если бы мы перешли к нашему упрощенному подходу к именованию, мы бы использовали что-то вроде «ScanThrows». Но видите ли, у нас есть 3 ситуации, которые могут вызвать исключение, как бы мы их назвали?
Мы могли бы назвать методы тестирования после исключения!
Хотя это не совсем плохая идея, она не сработает, так как у нас есть 2 случая, которые вызовут исключение GenerationException. И следуя идее, что тест должен документировать код, мы не сможем знать, когда должно быть выброшено исключение.
Как, черт возьми, мы их называем?
Это просто: мы просто должны точно сказать, что мы тестируем. Вот хороший шаблон именования, которому я предпочитаю следовать:
MethodName_Scenario_ExpectedResult
Но вам не обязательно следовать именно этому пути. Иногда мы присоединяемся к команде, у которой уже есть шаблон, поэтому вместо того, чтобы менять каждое название теста, мы должны адаптироваться. Важно рассказать, что делает тест. Итак, в нашем случае у нас будут следующие тестовые случаи:
- ScanCreature_ShouldReturnPokemonDetails
- ScanCreature_PokemonHasNoGeneration_ThrowsGenerationException
- ScanCreature_HasOwner_ThrowsGDPRException
- ScanCreature_PokemonGenerationIsDifferentThanPokedexGeneration_ThrowsGenerationException
Вы видите, что в первом тестовом примере я не указал сценарий? Это потому, что я лично предпочитаю опускать его, если это нормальное/ожидаемое поведение метода. Но, конечно, мы могли бы придерживаться нашего шаблона и сделать что-то вроде «ScanCreature_NoErrors_ShouldReturnPokemonDetails».
Заключение
Итак, в основном, когда дело доходит до именования, мы хотим сделать его как можно более явным для того, что проверяется модульным тестом. А именно, мы хотим сказать, что тестируется, чего мы ожидаем и когда нам следует этого ожидать.