Недавно мы с коллегой обменялись запросами на перенос аннотации Android @VisibleForTesting. Для тех, кто не знаком с этой аннотацией, вот что говорится в документации по Android:

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

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

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

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

Последствия @VisibleForTesting

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

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

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

Тестирование публичного контракта уже должно тестировать ваши частные функции

При разработке класса и реализации его функциональности очень важным шагом является выбор того, должны ли функции быть закрытыми или общедоступными. По сути, вы заключаете договор между системой и ее пользователем. В случае с VendingMachine у нас есть четко определенный контракт: пользователь может получить напиток, если он вставит нужную сумму денег и желаемый тип напитка. Чтобы протестировать этот класс, мы должны иметь возможность протестировать всю его функциональность с помощью открытого контракта: buyDrink. В конце концов, клиент никогда не будет напрямую звонить calculateTax или calculateDrinkPrice. Итак, если вы не можете проверить результат этих функций через buyDrink, почему функции даже находятся в одном классе?

В качестве примера, чтобы протестировать всю функциональность в классе VendingMachine, мы можем настроить тест, который проверяет правильность выполнения вычислений. Мы знаем, что напиток «Вода» стоит 2,15 доллара с учетом налога с продаж. Итак, мы можем настроить такой тест:

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

Изменения в системе

Помимо того факта, что не необходимости тестировать частные функции, потому что вы можете проверить это с помощью открытого контракта, на самом деле бывают ситуации, когда может быть вредно только специально протестировать частные функции. Предположим, мы тестируем функцию calculateTax с аннотацией @VisibleForTesting. У нас есть некоторые тестовые данные, которые мы используем, чтобы убедиться, что алгоритм работает, что-то вроде этих строк:

Мы полагаем, что эта функция содержит большую часть сложной логики для класса, и мы называем это днем. Большая часть нашей (сложной) логики проверяется, так что все должно быть в порядке, верно?

Как это часто бывает, люди совершают ошибки, и кто-то вводит новую логику в buyDrink функцию, которая работает не так, как задумано. Кто-то предположил, что воду не следует облагать налогом, и создал это изменение в функции buyDrink класса VendingMachine:

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

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

Изменения в реализации

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

Допустим, вместо того, чтобы брать цену напитка и возвращать цену, включая налог с продаж, функция calculateTax теперь возвращает TaxRate, и цену можно просто умножить на ставку, например:

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

Итак, теперь мы подошли к тому моменту, когда мы можем сделать несколько выводов:

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

Что делать, если тестирование частной реализации через общедоступный API слишком сложно?

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

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

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

Если бы мы хотели протестировать всю логику внутри функции calculateTax с помощью открытого контракта buyDrink, нам потребовался бы целый ряд тестов, имитирующих все виды вывода этих зависимостей.

Настоящее решение этой проблемы состоит не в том, чтобы начать тестирование функции calculateTax непосредственно внутри класса VendingMachine, а в том, чтобы выделить ее в отдельный отдельный класс: TaxCalculator. Таким образом вы определяете новый публичный контракт и делаете VendingMachine потребителем этого контракта. Всякий раз, когда что-то трудно проверить с помощью государственного контракта, это может быть признаком того, что вы нарушаете Принцип единой ответственности. В этом примере VendingMachine не должен отвечать за расчет налога.

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

Заключение

Всякий раз, когда вы чувствуете желание протестировать частную функцию с помощью аннотации @VisibleForTesting, подумайте о следующих вещах:

  • Желательно протестировать публичный контракт класса. Защита от изменений в самом контракте и освобождение от работы по рефакторингу из-за жестких тестов, привязанных к реализации.
  • Если тестирование частных функций через публичный контракт затруднено, спросите себя, почему это так. Есть изъян в дизайне класса? Нарушает ли это принцип единой ответственности? Вам нужно имитировать слишком много зависимостей, и это сделает ваши тесты неясными? Рассмотрите возможность выделения логики в отдельный класс с отдельным контрактом вместо тестирования частных функций этого конкретного класса.

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

Это была моя первая публикация на Medium, надеюсь, она вам понравилась. Если вы хотите увидеть больше материалов о написании качественных приложений для Android или программного обеспечения в целом, нажмите кнопку «Follow»!