Вот и настало время разобраться и написать небольшую заметку о том, что из себя представляет тестирование логики Android-приложений. К этому вопросу я пришел не сразу, однако учиться никогда не поздно!
Общие сведения
Для начала определимся, что такое тесты и зачем их нужно писать. Из Wikipedia:
Модульное тестирование, или юнит-тестирование (англ. unit testing) — процесс в программировании, позволяющий проверить на корректность отдельные модули исходного кода программы.
Идея состоит в том, чтобы писать тесты для каждой нетривиальной функции или метода. Это позволяет достаточно быстро проверить, не привело ли очередное изменение кода к регрессии, то есть к появлению ошибок в уже оттестированных местах программы, а также облегчает обнаружение и устранение таких ошибок.
Другими словами, тесты — это методы, которые проверяют работоспособность нашего кода. Но каким образом это делается? Давайте по порядку. Тест считается выполненным, если он выполнился без ошибок. А для разного рода проверок используются вспомогательные методы вроде «семейства» методов assertXXX
(см. примеры ниже).
Unit-тестирование в Android можно разделить на 2 типа:
- Локальные unit-тесты (Local unit tests) — тесты, для выполнения которых используется только JVM. Они предназначены для тестирования бизнес-логики, которая не взаимодействует с операционной системой.
- Инструментальные unit-тесты (Instrumented unit tests) — тесты, с помощью которых тестируется логика, «завязанная» на Android API. Их выполнение происходит на физическом девайсе/эмуляторе, что занимает значительно больше времени, чем локальные тесты.
Выбор одного из этих 2 типов зависит от целей тестируемой логики. Естественно, если это возможно, лучше писать локальные тесты.
Также при создании тестов стоит уделить внимание организации пакетов. Существует следующая структура, которой стоит придерживаться:
app/src/main/java
— исходный код приложения;app/src/test/java
— здесь размещаются локальные тесты;app/src/androidTest/java
— сюда помещаем инструментальные тесты.
Настройка
Если вы почему-то не используете Android Studio, которая генерирует пакеты при создании проектов, стоит запомнить структуру выше. К тому же, IDE конфигурирует Gradle-файл модуля app:
android { ... defaultConfig{ ... testInstrumentationRunner"android.support.test.runner.AndroidJUnitRunner"} ... } dependencies { ... testCompile'junit:junit:4.12'}
Для упрощения написания тестов огромной популярностью пользуется фреймворк Mockito. В его возможности входит имитация и наблюдение объектов, а также вспомогательные средства проверок. Чтобы начать его использовать, добавим зависимость:
testCompile 'org.mockito:mockito-core:1.10.19'
Local-тесты в Android
Теперь давайте рассмотрим 2 простейших теста, которые проверяют на соответствия 2 объекта (в нашем случае — числа):
publicclass ExampleUnitTest { @Test publicvoid addition_correct()throwsException{ assertEquals(4, 2+2);} @Test publicvoid addition_isNotCorrect()throwsException{ assertEquals("Numbers isn't equals!", 5, 2+2);}}
Для этого создадим в пакете для локальных тестов класс и разместим в нем наши методы. Чтобы JUnit знал, что эти методы являются тестами, они помечаются соответствующей аннотацией @Test
. Метод же assertEquals()
кидает ошибку AssertionError
в случае, если первый (ожидаемый) и второй (результат) не соответствуют друг другу.
Давайте запустим их (нажав правой кнопкой на класс и выбрав Run ‘ExampleUnitTest’ в появившемся меню) и посмотрим на результат выполнения:
Видно, что первый метод выполнился, а во втором произошла ошибка, т.к. 5 != (2 + 2). Также можно настроить тест на ожидаемое исключение используя параметр expected
:
@Test(expected =NullPointerException.class)publicvoid nullStringTest(){String str =null; assertTrue(str.isEmpty());}
В таком случае тест выполнится, т.к. это исключение мы ожидали. Для длинных операций можно также указать параметр timeout
и установить значение в миллисекундах. Если метод не выполнится в течение заданного времени, тест будет считаться проваленным:
@Test(timeout =1000)publicvoid requestTest(){ … }
Есть еще такой хитрый механизм как Matchers. К примеру, их на вход принимает метод assertThat(T actual, Matcher<? super T> matcher)
и возвращают методы класса org.hamcrest.CoreMatchers
. Матчеры представляют собой логические операции совпадения. Рассмотрим несколько из них:
assertThat(x, is(3)); assertThat(x, is(not(4))); assertThat(list, hasItem("3"));
Как видно из названий, is()
можно описать как оператор «равно», is(not())
как «неравно», а hasItem()
— как проверку на наличие элемента в списке. И читается это как связное предложение. Здесь можно найти весь перечень матчеров.
Пока мы видели несложные примеры, на основании которых уже можно писать простые тесты. Но я бы хотел рассмотреть библиотеку Mockito, с чьей помощью наши тесты станут по-настоящему крутыми!
Как упоминалось выше, Mockito используется для создания «заглушек». Они называются mock-объектами. Их цель — заменить собой сложные объекты, которые не нужно/невозможно тестировать. Объявить их можно двумя способами:
List mockedList = mock(List.class);
или
@Mock List mockedList;
Обратите внимание, что для того, чтобы использовать аннотацию @Mock
, класс нужно пометить аннотацией @RunWith(MockitoJUnitRunner.class)
или вызвать MockitoAnnotations.initMocks(this);
в @Before
-методе:
@Before publicvoid init(){ MockitoAnnotations.initMocks(this);}
Получив «замоканный» объект, можно творить настоящие чудеса! К примеру, для того, чтобы «переопределить» название приложения из строкового ресурса, можно сделать следующее:
when(mockContext.getString(R.string.app_name)) .thenReturn("Fake name");
Теперь при вызове метода getString()
будет возвращаться «Fake string», даже если он вызван неявно (за пределами теста):
SomeClass obj =new SomeClass(mockContext);String result = obj.getAppName();
Но что если мы хотим переопределить все строковые ресурсы? Неужели нам придется прописывать эту конструкцию для каждого из них? Естественно, нет. Для этого предусмотрены методы anyXXX()
, (anyInt()
, anyString()
, etc. Теперь, если заменить R.string.app_name
на anyInt()
(не забываем, что в Android все ресурсы имеют тип Integer) — все строки будут заменены на нашу строку.
А убедиться в этом мы можем, дописав assertThat()
в конце нашего теста:
assertThat(result, is("Fake name"));
В случае, когда мы хотим выбросить Exception, можно использовать конструкцию when(...).thenThrow(...);
.
Есть еще магический метод verify()
который провалит тест в случае, если указанный метод не был до этого вызван. Взглянем на код:
mockedList.add("Useless string"); mockedList.clear(); verify(mockedList).add("one"); verify(mockedList).clear();
Работает это так: мы передаем в verify(mockedList)
наш мок-лист, после чего указываем, какие именно методы нас интересуют. В данном случае добавление строки и очистка списка. Неотъемлемый инструмент, как по мне :)
Но по-настоящему потенциал этого метода раскрывается при использовании spy-объектов. Их главное отличие в том, что они не создаются напрямую, в отличие от mock’ов. А толку-то с них, спросите вы? А толк в том, что создав «шпиона» вы можете следить за ним также, как и за фейковым объектом:
List spyList = spy(newLinkedList()); spyList.add("one"); spyList.clear(); verify(spyList).add("one"); verify(spyList).clear();
Instrumented тесты в Android
При помощи этого типа тестов мы получаем доступ к реальному контексту, а также ко всем возможностям Android API. Помимо этого, у нас есть «режим Бога», при помощи которого мы можем управлять жизненным циклом активности. Для того, чтобы тест распознавался как инструментальный, нам нужно пометить класс соответствующей аннотацией:
@RunWith(AndroidJUnit4.class)publicclass ExampleInstrumentedTest { ... }
Сами же тесты мы помечаем так же, как и в случае локальных — при помощи аннотации @Test
. Давайте проверим, соответствует ли пакет нашего контекста нашему приложению:
@Test publicvoid useAppContext()throwsException{Context appContext = InstrumentationRegistry.getTargetContext(); assertEquals("com.stfalcon.demoapp", appContext.getPackageName());}
С помощью имеющегося инструментария можно прослушивать состояние активностей:
ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(new ActivityLifecycleCallback(){ @Override publicvoid onActivityLifecycleChanged(Activity activity, Stage stage){//do some stuff}});
...или и вовсе управлять их состоянием:
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); instrumentation.callActivityOnCreate(activity, bundle);
Заключение
Это, пожалуй, самые важные вещи, которые мне удалось найти при первом знакомстве. Поэтому не закидывайте камнями, как говорится :) Но, я думаю, для начала этого будет достаточно. Для тех, кто хочет узнать больше о возможностях junit, рекомендую почитать официальную документацию, а вот материал про возможности инструментальных тестов.