Тестирование стало проще благодаря минимализму фреймворка и программной архитектуре
Опубликовано: 2022-03-10Как и многие другие разработчики Android, мой первоначальный набег на тестирование платформы привел меня к тому, что я сразу же столкнулся с деморализующей степенью жаргона. Кроме того, те немногие примеры, с которыми я столкнулся в то время (около 2015 г.), не представляли практических вариантов использования, которые могли склонить меня к мысли, что соотношение затрат и выгод изучения такого инструмента, как Espresso , для проверки того, что TextView.setText( …) работал исправно, было разумным вложением.
Что еще хуже, у меня не было практического понимания архитектуры программного обеспечения ни в теории, ни на практике, а это означало, что даже если бы я удосужился изучить эти фреймворки, я бы писал тесты для монолитных приложений, состоящих из нескольких god
классов, написанных в коде спагетти . Суть в том, что создание, тестирование и поддержка таких приложений — это упражнение в самосаботаже, независимо от вашего опыта работы с фреймворком; однако это осознание становится ясным только после создания модульного , слабосвязанного и высокосвязного приложения.
Отсюда мы подходим к одному из основных пунктов обсуждения в этой статье, который я кратко изложу здесь: Среди основных преимуществ применения золотых принципов архитектуры программного обеспечения (не волнуйтесь, я буду обсуждать их на простых примерах и язык), заключается в том, что ваш код станет легче тестировать. Есть и другие преимущества применения таких принципов, но в центре внимания этой статьи находится взаимосвязь между архитектурой программного обеспечения и тестированием.
Однако для тех, кто хочет понять, почему и как мы тестируем наш код, мы сначала рассмотрим концепцию тестирования по аналогии; не требуя от вас заучивания какого-либо жаргона. Прежде чем углубиться в основную тему, мы также рассмотрим вопрос о том, почему существует так много сред тестирования, поскольку при изучении этого мы можем начать видеть их преимущества, ограничения и, возможно, даже альтернативное решение.
Тестирование: зачем и как
Этот раздел не будет новой информацией для любого опытного тестировщика, но, возможно, вам понравится эта аналогия. Конечно, я инженер-программист, а не инженер-ракетчик, но на мгновение я возьму аналогию, которая относится к проектированию и созданию объектов как в физическом пространстве, так и в пространстве памяти компьютера. Оказывается, при изменении среды процесс в принципе остается тем же самым.
Предположим на мгновение, что мы — инженеры-ракетчики, и наша задача — построить первую ступень* ракетного ускорителя космического челнока. Предположим также, что мы придумали работоспособный проект первой очереди, чтобы начать сборку и испытания в различных условиях.
«Первая ступень» относится к ускорителям, которые запускаются при первом запуске ракеты.
Прежде чем мы перейдем к процессу, я хотел бы указать, почему я предпочитаю эту аналогию: у вас не должно возникнуть трудностей с ответом на вопрос, почему мы утруждаем себя тестированием нашей конструкции, прежде чем применять ее в ситуациях, когда на карту поставлены человеческие жизни. Хотя я не буду пытаться убедить вас, что тестирование ваших приложений перед запуском может спасти жизни (хотя это возможно в зависимости от характера приложения), оно может спасти рейтинги, отзывы и вашу работу. В самом широком смысле тестирование — это способ, с помощью которого мы удостоверяемся в том, что отдельные части, несколько компонентов и целые системы работают, прежде чем использовать их в ситуациях, когда критически важно, чтобы они не вышли из строя.
Возвращаясь к аспекту «как» этой аналогии, я представлю процесс, с помощью которого инженеры тестируют конкретную конструкцию: избыточность . Избыточность в принципе проста: создайте копии тестируемого компонента в соответствии с той же спецификацией проекта, которую вы хотите использовать во время запуска. Протестируйте эти копии в изолированной среде, которая строго контролирует предварительные условия и переменные. Хотя это не гарантирует, что ракетный ускоритель будет работать должным образом, будучи интегрированным во весь шаттл, можно быть уверенным, что если он не будет работать в контролируемой среде, он вряд ли будет работать вообще.
Предположим, что из сотен или, возможно, тысяч переменных, с которыми были протестированы копии конструкции ракеты, все сводится к температуре окружающей среды, при которой ракетный ускоритель будет испытываться. При тестировании при 35° по Цельсию мы видим, что все работает без ошибок. Опять же, ракета без сбоев испытывается примерно при комнатной температуре. Финальное испытание будет проходить при самой низкой зарегистрированной температуре на стартовой площадке -5° по Цельсию. Во время этого последнего испытания ракета срабатывает, но через короткий промежуток времени ракета вспыхивает и вскоре после этого сильно взрывается; но, к счастью, в контролируемой и безопасной среде.
На данный момент мы знаем, что изменения температуры, по крайней мере, связаны с неудачным испытанием, что заставляет нас задуматься о том, какие части ракетного ускорителя могут быть неблагоприятно затронуты низкими температурами. Со временем было обнаружено, что один ключевой компонент, резиновое уплотнительное кольцо , которое служит для остановки потока топлива из одного отсека в другой, становится жестким и неэффективным при воздействии температур, приближающихся к нулю или ниже нуля.
Возможно, вы заметили, что его аналогия в общих чертах основана на трагических событиях крушения космического корабля " Челленджер ". Для тех, кто не в курсе, печальная правда (насколько завершились расследования) заключается в том, что было много неудачных испытаний и предупреждений от инженеров, и все же административные и политические проблемы подстегнули запуск, несмотря ни на что. В любом случае, запомнили ли вы термин избыточность или нет, я надеюсь, что вы усвоили основной процесс тестирования частей любой системы.
О программном обеспечении
Принимая во внимание, что предыдущая аналогия объясняла фундаментальный процесс тестирования ракет (хотя и допускала большую вольность в отношении более мелких деталей), теперь я подытожу в манере, которая, вероятно, более актуальна для вас и меня. Хотя можно протестировать программное обеспечение, только запустив его на устройства, как только оно находится в каком-либо развертываемом состоянии, вместо этого я полагаю, что мы можем сначала применить принцип избыточности к отдельным частям приложения.
Это означает, что мы создаем копии меньших частей всего приложения (обычно называемых модулями программного обеспечения), настраиваем изолированную тестовую среду и смотрим, как они ведут себя на основе любых переменных, аргументов, событий и ответов, которые могут возникнуть. во время выполнения. Тестирование действительно так же просто, как и в теории, но ключ даже к этому процессу лежит в создании приложений, которые можно тестировать. Это сводится к двум проблемам, которые мы рассмотрим в следующих двух разделах. Первая проблема связана с тестовой средой , а вторая — с тем, как мы структурируем приложения.
Зачем нам нужны фреймворки?
Для того, чтобы протестировать часть программного обеспечения (далее именуемого « Модуль» , хотя это определение является преднамеренно чрезмерным упрощением), необходимо иметь какую-то тестовую среду, которая позволяет вам взаимодействовать с вашим программным обеспечением во время выполнения. Для тех приложений, которые создаются исключительно в среде JVM ( виртуальная машина Java ), все, что требуется для написания тестов, — это JRE ( среда выполнения Java ). Возьмем, к примеру, этот очень простой класс калькулятора :
class Calculator { private int add(int a, int b){ return a + b; } private int subtract(int a, int b){ return a - b; } }
В отсутствие каких-либо фреймворков, пока у нас есть тестовый класс, содержащий main
функцию для фактического выполнения нашего кода, мы можем его протестировать. Как вы помните, функция main
обозначает начальную точку выполнения простой Java-программы. Что касается того, что мы тестируем, мы просто передаем некоторые тестовые данные в функции калькулятора и проверяем, правильно ли он выполняет основные арифметические действия:
public class Main { public static void main(String[] args){ //create a copy of the Unit to be tested Calculator calc = new Calculator(); //create test conditions to verify behaviour int addTest = calc.add(2, 2); int subtractTest = calc.subtract(2, 2); //verify behaviour by assertion if (addTest == 4) System.out.println("addTest has passed."); else System.out.println("addTest has failed."); if (subtractTest == 0) System.out.println("subtractTest has passed."); else System.out.println("subtractTest has failed."); } }
Тестирование Android-приложения — это, конечно, совсем другая процедура. Хотя глубоко внутри исходного кода файла ZygoteInit.java скрыта main
функция (детали которой здесь не важны), которая вызывается перед запуском приложения Android на JVM , даже начинающий разработчик Android должен знать, что за вызов этой функции отвечает сама система; не разработчик . Вместо этого точками входа для приложений Android являются класс Application
и любые классы Activity
, на которые система может указать через файл AndroidManifest.xml .
Все это лишь подводит к тому факту, что тестирование модулей в приложении для Android представляет собой более высокий уровень сложности, строго потому, что наша среда тестирования теперь должна учитывать платформу Android.
Укрощение проблемы жесткой связи
Тесная связь — это термин, описывающий функцию, класс или модуль приложения, которые зависят от конкретных платформ, фреймворков, языков и библиотек. Это относительный термин, означающий, что наш пример Calculator.java тесно связан с языком программирования Java и стандартной библиотекой, но это предел его связи. В том же духе проблема тестирования классов, которые тесно связаны с платформой Android, заключается в том, что вы должны найти способ работать с платформой или вокруг нее.
Для классов, тесно связанных с платформой Android, у вас есть два варианта. Во-первых, просто разверните свои классы на устройстве Android (физическом или виртуальном). Хотя я предлагаю вам протестировать развертывание кода вашего приложения перед отправкой его в производство, это крайне неэффективный подход на ранних и средних этапах процесса разработки с точки зрения времени.
Unit , какое бы техническое определение вы ни предпочли, обычно рассматривается как отдельная функция в классе (хотя некоторые расширяют определение, чтобы включить последующие вспомогательные функции, которые вызываются внутренне первоначальным вызовом одиночной функции). В любом случае, Юниты должны быть небольшими; создание, компиляция и развертывание всего приложения для тестирования одного модуля означает полное упущение смысла изолированного тестирования .
Другим решением проблемы тесной связи является использование тестовых фреймворков для взаимодействия или имитации (моделирования) зависимостей платформы. Такие платформы, как Espresso и Robolectric, предоставляют разработчикам гораздо более эффективные средства для тестирования модулей , чем предыдущий подход; первый полезен для тестов, запускаемых на устройстве (известных как «инструментальные тесты», потому что, по-видимому, называть их тестами устройства было недостаточно двусмысленно), а второй способен имитировать инфраструктуру Android локально на JVM.
Прежде чем я перейду к осуждению таких фреймворков вместо альтернативы, которую я вскоре обсужу, я хочу прояснить, что я не имею в виду, что вы никогда не должны использовать эти варианты. Процесс, который разработчик использует для создания и тестирования своих приложений, должен основываться на сочетании личных предпочтений и стремления к эффективности.
Для тех, кто не любит создавать модульные и слабосвязанные приложения, у вас не будет иного выбора, кроме как ознакомиться с этими фреймворками, если вы хотите иметь адекватный уровень покрытия тестами. Многие замечательные приложения были построены таким образом, и меня нередко обвиняют в том, что я делаю свои приложения слишком модульными и абстрактными. Независимо от того, примете ли вы мой подход или решите в значительной степени опираться на фреймворки, я приветствую вас за потраченное время и усилия на тестирование ваших приложений.
Держите свои рамки на расстоянии вытянутой руки
В качестве заключительной преамбулы к основному уроку этой статьи стоит обсудить, почему вы можете придерживаться минимализма, когда дело доходит до использования фреймворков (и это относится не только к тестированию фреймворков). Вышеприведенный подзаголовок является парафразой великодушного учителя лучших практик программного обеспечения: Роберта «Дяди Боба» К. Мартина. Из многих жемчужин, которые он дал мне с тех пор, как я впервые изучил его работы, для понимания этой потребовалось несколько лет непосредственного опыта.
Насколько я понимаю, о чем идет речь в этом утверждении, стоимость использования фреймворков заключается во временных затратах, необходимых для их изучения и поддержки. Некоторые из них меняются довольно часто, а некоторые недостаточно часто. Функции устаревают, фреймворки перестают поддерживаться, и каждые 6-24 месяца появляется новый фреймворк, заменяющий предыдущий. Таким образом, если вы сможете найти решение, которое можно реализовать в виде функции платформы или языка (которая, как правило, работает намного дольше), оно будет более устойчивым к изменениям различных типов, упомянутых выше.
С технической точки зрения, такие фреймворки, как Espresso и, в меньшей степени, Robolectric , никогда не смогут работать так же эффективно, как простые тесты JUnit или даже более ранние тесты без фреймворка. Хотя JUnit действительно является фреймворком, он тесно связан с JVM , которая имеет тенденцию меняться гораздо медленнее, чем сама платформа Android. Меньшее количество фреймворков почти всегда означает код, который более эффективен с точки зрения времени, необходимого для выполнения и написания одного или нескольких тестов.
Из этого вы, вероятно, можете понять, что сейчас мы будем обсуждать подход, который будет использовать некоторые методы, позволяющие нам держать платформу Android на расстоянии вытянутой руки; в то же время предоставляя нам большой охват кода, эффективность тестирования и возможность по-прежнему использовать фреймворк здесь или там, когда возникает необходимость.
Искусство архитектуры
Если использовать глупую аналогию, можно подумать о фреймворках и платформах как о властных коллегах, которые возьмут на себя ваш процесс разработки, если вы не установите для них соответствующие границы. Золотые принципы архитектуры программного обеспечения могут дать вам общие концепции и конкретные методы, необходимые как для создания, так и для обеспечения соблюдения этих границ. Как мы вскоре увидим, если вы когда-нибудь задумывались над тем, каковы на самом деле преимущества применения принципов архитектуры программного обеспечения в вашем коде, некоторые из них прямо, а многие косвенно облегчают тестирование вашего кода.
Разделение ответственности
По моему мнению, разделение интересов является наиболее универсально применимой и полезной концепцией в архитектуре программного обеспечения в целом (не говоря уже о том, что другими следует пренебрегать). Разделение ответственности (SOC) может применяться или полностью игнорироваться во всех аспектах разработки программного обеспечения, о которых я знаю. Чтобы кратко обобщить концепцию, мы рассмотрим SOC применительно к классам, но имейте в виду, что SOC можно применять к функциям посредством широкого использования вспомогательных функций , и его можно экстраполировать на целые модули приложения («модули», используемые в контекст Android/Gradle).
Если вы потратили много времени на изучение шаблонов архитектуры программного обеспечения для приложений с графическим интерфейсом, вы, вероятно, сталкивались по крайней мере с одним из: Model-View-Controller (MVC), Model-View-Presenter (MVP) или Model-View- ViewModel (МВВМ). Создав приложения во всех стилях, я сразу скажу, что не считаю ни один из них лучшим вариантом для всех проектов (или даже функций в рамках одного проекта). По иронии судьбы, шаблон, который команда Android представила несколько лет назад в качестве рекомендуемого подхода, MVVM, кажется наименее поддающимся тестированию в отсутствие конкретных сред тестирования Android (при условии, что вы хотите использовать классы ViewModel платформы Android, которые я, по общему признанию, фанат из).
В любом случае специфика этих паттернов менее важна, чем их общие черты. Все эти шаблоны — просто разные разновидности SOC, которые подчеркивают фундаментальное разделение трех видов кода, которые я называю: данные , пользовательский интерфейс , логика .
Итак, как именно разделение данных , пользовательского интерфейса и логики помогает вам тестировать ваши приложения? Ответ заключается в том, что, вытягивая логику из классов, которые должны иметь дело с зависимостями от платформы/фреймворка, в классы, которые практически не имеют зависимостей от платформы/фреймворка, тестирование становится простым, а фреймворк минимальным . Чтобы было ясно, я обычно говорю о классах, которые должны отображать пользовательский интерфейс, хранить данные в таблице SQL или подключаться к удаленному серверу. Чтобы продемонстрировать, как это работает, давайте рассмотрим упрощенную трехуровневую архитектуру гипотетического приложения для Android.
Первый класс будет управлять нашим пользовательским интерфейсом. Для простоты я использовал Activity для этой цели, но вместо этого я обычно выбираю Fragments в качестве классов пользовательского интерфейса. В любом случае оба класса имеют одинаковую тесную связь с платформой Android :
public class CalculatorUserInterface extends Activity implements CalculatorContract.IUserInterface { private TextView display; private CalculatorContract.IControlLogic controlLogic; private final String INVALID_MESSAGE = "Invalid Expression."; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); controlLogic = new DependencyProvider().provideControlLogic(this); display = findViewById(R.id.textViewDisplay); Button evaluate = findViewById(R.id.buttonEvaluate); evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } }); //..bindings for the rest of the calculator buttons } @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } }
Как видите, Activity выполняет две задачи: во-первых, поскольку это точка входа для данной функции приложения Android , она действует как своего рода контейнер для других компонентов этой функции. Проще говоря, контейнер можно рассматривать как своего рода корневой класс, к которому в конечном итоге привязаны другие компоненты через ссылки (или в данном случае закрытые поля-члены). Он также расширяет, связывает ссылки и добавляет слушателей в XML-макет (пользовательский интерфейс).
Тестирование логики управления
Вместо того, чтобы иметь ссылку на конкретный класс в бэкенде , у нас есть взаимодействие с интерфейсом типа CalculatorContract.IControlLogic.
Мы обсудим, почему это интерфейс, в следующем разделе. А пока просто поймите, что все, что находится по другую сторону этого интерфейса, должно быть чем-то вроде Presenter или Controller . Так как этот класс будет управлять взаимодействием между внешним Activity и внутренним Calculator , я решил назвать его CalculatorControlLogic
:
public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } }
public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } }
В конструкции этого класса есть много тонкостей, упрощающих его тестирование. Во-первых, все его ссылки взяты либо из стандартной библиотеки Java, либо из интерфейсов, определенных в приложении. Это означает, что протестировать этот класс без каких-либо фреймворков очень просто, и это можно сделать локально на JVM . Еще один небольшой, но полезный совет заключается в том, что все различные взаимодействия этого класса можно вызывать с помощью одной универсальной handleInput(...)
. Это обеспечивает единую точку входа для проверки каждого поведения этого класса.
Также обратите внимание, что в функции evaluateExpression()
я возвращаю класс типа Optional<String>
из серверной части. Обычно я бы использовал то, что функциональные программисты называют либо Монадой , либо, как я предпочитаю это называть, Оболочкой Результата . Какое бы дурацкое имя вы ни использовали, это объект, способный представлять множество различных состояний посредством одного вызова функции. Optional
— это более простая конструкция, которая может представлять либо значение null , либо некоторое значение предоставленного универсального типа. В любом случае, поскольку серверной части может быть передано недопустимое выражение, мы хотим предоставить классу ControlLogic
некоторые средства для определения результата внутренней операции; учет как успехов, так и неудач. В этом случае null будет означать сбой.
Ниже приведен пример тестового класса, написанного с использованием JUnit , и класса, который на жаргоне тестирования называется Fake :
public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } }
public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } }
Как видите, этот набор тестов можно не только выполнить очень быстро, но и на его написание не требуется много времени. В любом случае, сейчас мы рассмотрим некоторые более тонкие вещи, упрощающие написание этого тестового класса.
Сила абстракции и инверсии зависимостей
Есть еще две важные концепции, которые были применены к CalculatorControlLogic
, что значительно упростило его тестирование. Во-первых, если вы когда-нибудь задавались вопросом, каковы преимущества использования интерфейсов и абстрактных классов (вместе называемых абстракциями ) в Java, приведенный выше код является прямой демонстрацией. Поскольку тестируемый класс ссылается на абстракции , а не на конкретные классы, мы смогли создать поддельные тестовые двойники для пользовательского интерфейса и серверной части внутри нашего тестового класса. Пока эти тестовые двойники реализуют соответствующие интерфейсы, CalculatorControlLogic
не заботит то, что они не настоящие.
Во-вторых, CalculatorControlLogic
получает свои зависимости через конструктор (да, это форма Dependency Injection ), вместо создания собственных зависимостей. Следовательно, его не нужно переписывать при использовании в производственной или тестовой среде, что является бонусом за эффективность.
Внедрение зависимостей — это форма инверсии управления , которую сложно определить простым языком. Независимо от того, используете ли вы Dependency Injection или Service Locator Pattern , они оба достигают того, что Мартин Фаулер (мой любимый учитель по таким темам) описывает как «принцип отделения конфигурации от использования». Это приводит к тому, что классы легче тестировать и легче создавать изолированно друг от друга.
Тестирование логики вычислений
Наконец, мы подошли к классу ComputationLogic
, который должен аппроксимировать устройство ввода -вывода, такое как адаптер для удаленного сервера или локальной базы данных. Поскольку нам не нужно ни то, ни другое для простого калькулятора, он просто будет отвечать за инкапсуляцию логики, необходимой для проверки и оценки выражений, которые мы ему даем:
public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }
public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }
public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }
public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }
Об этом классе сказать особо нечего, поскольку, как правило, существует некоторая тесная связь с конкретной серверной библиотекой, которая создает те же проблемы, что и класс, тесно связанный с Android. Через мгновение мы обсудим, что делать с такими классами, но этот настолько легко протестировать, что мы тоже можем попробовать:
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
Проще всего тестировать классы, которым просто присваивается какое-то значение или объект, и ожидается, что они вернут результат без необходимости вызывать какие-то внешние зависимости. В любом случае наступает момент, когда независимо от того, сколько волшебства архитектуры программного обеспечения вы применяете, вам все равно придется беспокоиться о классах, которые нельзя отделить от платформ и фреймворков. К счастью, мы все еще можем использовать программную архитектуру, чтобы: в худшем случае упростить тестирование этих классов, а в лучшем — сделать их настолько тривиально простыми, что тестирование можно будет выполнить с первого взгляда .
Скромные объекты и пассивные взгляды
Вышеуказанные два имени относятся к шаблону, в котором объект, который должен общаться с низкоуровневыми зависимостями, настолько упрощен, что его, возможно , не нужно тестировать. Впервые я познакомился с этим паттерном в блоге Мартина Фаулера о вариантах Model-View-Presenter. Позже, благодаря работам Роберта К. Мартина, я познакомился с идеей трактовки определенных классов как скромных объектов , что означает, что этот шаблон не нужно ограничивать классами пользовательского интерфейса (хотя я не хочу сказать, что Фаулер когда-либо подразумевает такое ограничение).
Как бы вы ни назвали этот паттерн, он восхитительно прост для понимания, и в некотором смысле я считаю, что на самом деле это просто результат строгого применения SOC к вашим классам. Хотя этот шаблон применим и к внутренним классам, мы будем использовать наш класс пользовательского интерфейса , чтобы продемонстрировать этот принцип в действии. Разделение очень простое: классы, которые взаимодействуют с зависимостями платформы и фреймворка, не думают сами за себя (отсюда и прозвища Humble и Passive ). Когда происходит событие, единственное, что они делают, — это пересылают детали этого события любому логическому классу, который прослушивается:
//from CalculatorActivity's onCreate() function: evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } });
Логический класс, который должно быть тривиально легко тестировать, затем отвечает за очень детальное управление пользовательским интерфейсом . Вместо того, чтобы вызывать одну общую функцию updateUserInterface(...)
в классе user interface
и оставлять ее для выполнения работы по массовому обновлению, user interface
(или другой подобный класс) будет иметь небольшие и специфические функции, которые должно быть легко реализовать. имя и реализация:
//Interface functions of CalculatorActivity: @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } //…
В принципе, эти два примера должны дать вам достаточно, чтобы понять, как реализовать этот шаблон. Объект, обладающий логикой, слабо связан, а объект, сильно связанный с надоедливыми зависимостями, становится почти лишенным логики.
Now, at the start of this subsection, I made the statement that these classes become arguably unnecessary to test, and it is important we look at both sides of this argument. In an absolute sense, it is impossible to achieve 100% test coverage by employing this pattern, unless you still write tests for such humble / passive classes. It is also worth noting that my decision to use a Calculator as an example App, means that I cannot escape having a gigantic mass of findViewById(...)
calls present in the Activity. Giant masses of repetitive code are a common cause of typing errors, and in the absence of some Android UI testing frameworks, my only recourse for testing would be via deploying the feature to a device and manually testing each interaction. Ой.
It is at this point that I will humbly say that I do not know if 100% code coverage is absolutely necessary. I do not know many developers who strive for absolute test coverage in production code, and I have never done so myself. One day I might, but I will reserve my opinions on this matter until I have the reference experiences to back them up. In any case, I would argue that applying this pattern will still ultimately make it simpler and easier to test tightly coupled classes; if for no reason other than they become simpler to write.
Another objection to this approach, was raised by a fellow programmer when I described this approach in another context. The objection was that the logic
class (whether it be a Controller
, Presenter
, or even a ViewModel
depending on how you use it), becomes a God
class.
While I do not agree with that sentiment, I do agree that the end result of applying this pattern is that your Logic classes become larger than if you left more decisions up to your user interface class.
This has never been an issue for me as I treat each feature of my applications as self-contained components, as opposed to having one giant controller for managing multiple user interface screens. In any case, I think this argument holds reasonably true if you fail to apply SOC to your front end or back end components. Therefore, my advice is to apply SOC to your front end and back end components quite rigorously.
Further Considerations
After all of this discussion on applying the principles of software architecture to reduce the necessity of using a wide-array of testing frameworks, improve the testability of classes in general, and a pattern which allows classes to be tested indirectly (at least to some degree), I am not actually here to tell you to stop using your preferred frameworks.
For those curious, I often use a library to generate mock classes for my Unit tests (for Java I prefer Mockito , but these days I mostly write Kotlin and prefer Mockk in that language), and JUnit is a framework which I use quite invariably. Since all of these options are coupled to languages as opposed to the Android platform, I can use them quite interchangeably across mobile and web application development. From time to time (if project requirements demand it), I will even use tools like Robolectric , MockWebServer , and in my five years of studying Android, I did begrudgingly use Espresso once.
My hope is that in reading this article, anyone who has experienced a similar degree of aversion to testing due to paralysis by jargon analysis , will come to see that getting started with testing really can be simple and framework minimal .
Дальнейшее чтение на SmashingMag:
- Sliding In And Out Of Vue.js
- Designing And Building A Progressive Web Application Without A Framework
- CSS-фреймворки или CSS-сетка: что мне использовать в моем проекте?
- Использование Flutter от Google для действительно кроссплатформенной мобильной разработки