Насколько хорошо мы пишем тесты?

Насколько хорошо мы пишем тесты?

24 мая 2022 г.

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


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


Что делает тест хорошим?


Как и в производственном коде, существуют (несвязанные) качества, которые делают тесты хорошими.


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

  • Гибкость - первостепенное значение имеет то, что если мне нужно добавить новый нюанс в существующие тесты, или изменить API, или изменить структуру тестов, чтобы код, поддерживающий тесты, был гибким, и мне не нужно повторно внедрить строительные леса, которые уже слишком много.

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

Давайте рассмотрим пример (это надумано, они все такие)


Представьте себе простой класс Java с конструктором, как показано ниже.


```java


public SaleEvent (Instant transactionTime, Money transactionValue) {


this.transactionTime = время транзакции;


this.transactionValue = значение транзакции;


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


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


```java


интерфейс EventAggregatorService {


void submitEvents(String saleChannel, List saleEvents);


Деньги getTransactionTotal(


строка продажаканал,


LocalDate startInclusive,


LocalDate endInclusive


Я часто вижу тесты такого рода стилей.


```java


@Контрольная работа


public void testEventInsideRangeIsFound() {


eventAggregatorService.submitEvents(SALE_CHANNEL_1, ONE_SALE_EVENT_DAY1);


утверждатьЭто(


eventAggregatorService.getTransactionTotal(SALE_CHANNEL_1, DAY_1, DAY_2)


).isEqualTo(ONE_SALE_EVENT_TOTAL)


@Контрольная работа


public void testMultipleEventsInsideRangeIsFound() {


eventAggregatorService.submitEvents(SALE_CHANNEL_1, TWO_SALE_EVENTS_DAY1);


утверждатьЭто(


eventAggregatorService.getTransactionTotal(SALE_CHANNEL_1, DAY_1, DAY_2)


).isEqualTo(TWO_SALE_EVENT_TOTAL)


@Контрольная работа


public void testEventOutsideRangeIsNotFound() {


eventAggregatorService.submitEvents(SALE_CHANNEL_1, ONE_SALE_EVENT_DAY3);


утверждатьЭто(


eventAggregatorService.getTransactionTotal(SALE_CHANNEL_1, DAY_1, DAY_2)


).isEqualTo(Деньги.НОЛЬ)


Давайте оценим, хорошие ли это тесты по критериям (Читабельность/Гибкость/Корректность).


Читаются ли эти тесты?


Да, во многих отношениях эти тесты читабельны или, по крайней мере, более читабельны, чем могли бы быть. У них есть имена методов, которые представляют то, что они тестируют, они (почти) следуют стилю «дано, когда, тогда», который мы, энтузиасты BDD, любим видеть, и у них есть именованные параметры, которые в некотором роде логически читаемы и согласуются с тем, что это происходит. Однако эти тесты не соответствуют высоким стандартам читабельности. Это связано с тем, что часть построения SaleEvents происходит в контексте вне теста без каких-либо соглашений. TWO_SALE_EVENTS_DAY1, например, представляет собой список, который создается в другом месте, и нам нужно проверить его, чтобы узнать, какие даты используются, у нас есть подсказка, что это DAY_1, но мы не знаем, какой это день и является ли он в тот же день, как указано в ONE_SALE_EVENT_DAY1. Эти проблемы - запах читабельности.


Верны ли эти тесты?


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


Являются ли эти тесты гибкими?


Нет. Эти тесты совсем не гибкие. Проблема неуловима, но ее довольно легко обнаружить. Проблема заключается в привязке, которая существует между настройкой входных данных и литеральными значениями, используемыми в утверждениях. Если у нас нет контроля над построением входных объектов в самом тестовом контексте, то мы точно так же не имеем никакого контроля над выходами. Гибкий тест в его идеальной форме имеет возможность переопределять что-то во входных данных, не затрагивая другие тесты или, по крайней мере, таким образом, который легко совместим с другими тестами. Статические поля или поля-члены без соглашения не подходят для этого качества.


Что лучше?


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


```java


@Контрольная работа


public void testEventInsideRangeIsFound() {


Строка SALE_CHANNEL_1 = "SALE_CHANNEL_1";


LocalDateTime DAY_1 = LocalDateTime.of(2022, 4, 2, 0, 0);


LocalDateTime DAY_2 = LocalDateTime.of(2022, 4, 3, 0, 0);


BigDecimal ONE_SALE_EVENT_TOTAL = BigDecimal.valueOf(18,99);


List ONE_SALE_EVENT_DAY1 = List.of(


новое событие продажи(


1 ДЕНЬ,


новые деньги (Currency.getInstance ("USD"), BigDecimal.valueOf (18,99))


eventAggregatorService.submitEvents(SALE_CHANNEL_1, ONE_SALE_EVENT_DAY1);


утверждатьЭто(


eventAggregatorService.getTransactionTotal(SALE_CHANNEL_1, DAY_1, DAY_2)


).isEqualTo(ONE_SALE_EVENT_TOTAL)


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


Это не намного лучше, хотя….


Если вы считаете, что нужно сделать это для всех тестов, например. testMultipleEventsInsideRangeIsFound()


```java


@Контрольная работа


public void testMultipleEventsInsideRangeIsFound() {


Строка SALE_CHANNEL_1 = "SALE_CHANNEL_1";


LocalDateTime DAY_1 = LocalDateTime.of(2022, 4, 2, 0, 0);


LocalDateTime DAY_2 = LocalDateTime.of(2022, 4, 3, 0, 0);


BigDecimal TWO_SALE_EVENT_TOTAL = BigDecimal.valueOf(25,98);


List TWO_SALE_EVENTS_DAY1 = List.of(


новое событие продажи(


1 ДЕНЬ,


новые деньги (Currency.getInstance ("USD"), BigDecimal.valueOf (18,99)


новое событие продажи(


1 ДЕНЬ,


новые деньги (Currency.getInstance ("USD"), BigDecimal.valueOf (6,99)


eventAggregatorService.submitEvents(SALE_CHANNEL_1, TWO_SALE_EVENTS_DAY1);


утверждатьЭто(


eventAggregatorService.getTransactionTotal(SALE_CHANNEL_1, DAY_1, DAY_2)


).isEqualTo(TWO_SALE_EVENT_TOTAL)


Отлично, теперь у нас есть полный контроль над объектами в тестовом контексте. Это полезно и выглядит управляемым сегодня, но на самом деле, как шаблон, совершенно неуправляемый. Что, если по какой-то причине станет важным написать новый тест, в котором будет 100 событий SaleEvent в первый день и 2000 во второй день? что, если станет важно тестировать распродажи, например, равномерно распределяя их в течение 1 часа? может быть, есть тестовый пример, который требует, чтобы они пришли все одновременно, например, или, может быть, вам нужно исключить события в соответствии с набором правил, и важно знать, какие события подпадают под этот набор правил, а какие нет? Эти условные параметры также должны быть читаемы в тестовом коде, если мы продумаем, как это начинает выглядеть, в конечном итоге это снова выглядит довольно немасштабируемым и слишком многословным.


Какие у нас есть варианты?


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


Рассмотрим некоторые вспомогательные методы для решения этой проблемы для приведенного выше примера:


```java


частный список weeklySaleEvents(List... weeklySaleEvents) {


вернуть Arrays.stream(weeklySaleEvents)


.flatMap(список::поток)


.collect(Коллекторы.toList());


частный список dailyEvents (начало LocalDateTime, денежное значение) {


вернуть IntStream.range (0,24)


.mapToObj(


часдня ->


новое событие продажи(


start.plusHours(hourOfDay).toInstant(ZoneOffset.UTC),


ценность


.collect(Коллекторы.toList());


Эти помощники допускают использование, которое определено в каждом тестовом контексте и является динамическим, например


```java


List saleEvents = weeklyEvents(


dailyEvents(MONDAY_START, MONEY_VALUE),


dailyEvents(ВТОРНИК_START, MONEY_VALUE),


dailyEvents(WEDNESDAY_START, MONEY_VALUE),


dailyEvents(THURSDAY_START, MONEY_VALUE),


dailyEvents(FRIDAY_START, MONEY_VALUE),


dailyEvents(SATURDAY_START, MONEY_VALUE),


dailyEvents(SUNDAY_START, MONEY_VALUE)


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


Мы можем попытаться решить эту проблему, написав еще более плавные сборщики/помощники.


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


Сгенерированные fluent-билдеры, например, не заменят параметр varargs в нужном месте или хорошо названный метод в нужном месте, скрывающий какую-то бессмысленную часть конструкции объекта. Однако создание таких строительных лесов требует много времени и навыков, и мы также не можем автоматически генерировать строителей для всех типов проектов. И особенно в старых версиях Java эта стратегия может выглядеть довольно многословно, если только мы не делаем много ручного труда. Не всегда есть время оправдываться.


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



Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE