Простые Unit-тесты в Android

Простые Unit-тесты в Android

Вот и настало время разобраться и написать небольшую заметку о том, что из себя представляет тестирование логики 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 объекта (в нашем случае — числа):

public class ExampleUnitTest {
 
 
   @Test
   public void addition_correct() throws Exception {
       assertEquals(4, 2 + 2);
   }
 
 
   @Test
   public void addition_isNotCorrect() throws Exception {
       assertEquals("Numbers isn't equals!", 5, 2 + 2);
   }
}

Для этого создадим в пакете для локальных тестов класс и разместим в нем наши методы. Чтобы JUnit знал, что эти методы являются тестами, они помечаются соответствующей аннотацией @Test. Метод же assertEquals() кидает ошибку AssertionError в случае, если первый (ожидаемый) и второй (результат) не соответствуют друг другу.

Давайте запустим их (нажав правой кнопкой на класс и выбрав Run ‘ExampleUnitTest’ в появившемся меню) и посмотрим на результат выполнения:

Результат выполнения теста

Видно, что первый метод выполнился, а во втором произошла ошибка, т.к. 5 != (2 + 2). Также можно настроить тест на ожидаемое исключение используя параметр expected:

@Test(expected = NullPointerException.class)
public void nullStringTest() {
   String str = null;
   assertTrue(str.isEmpty());
}

В таком случае тест выполнится, т.к. это исключение мы ожидали. Для длинных операций можно также указать параметр timeout и установить значение в миллисекундах. Если метод не выполнится в течение заданного времени, тест будет считаться проваленным:

@Test(timeout = 1000)
public void 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
public void 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(new LinkedList());
 
 
spyList.add("one");
spyList.clear();
 
 
verify(spyList).add("one");
verify(spyList).clear();

Instrumented тесты в Android

При помощи этого типа тестов мы получаем доступ к реальному контексту, а также ко всем возможностям Android API. Помимо этого, у нас есть «режим Бога», при помощи которого мы можем управлять жизненным циклом активности. Для того, чтобы тест распознавался как инструментальный, нам нужно пометить класс соответствующей аннотацией:

@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
   ...
}

Сами же тесты мы помечаем так же, как и в случае локальных — при помощи аннотации @Test. Давайте проверим, соответствует ли пакет нашего контекста нашему приложению:

@Test
public void useAppContext() throws Exception {
   Context appContext = InstrumentationRegistry.getTargetContext();
   assertEquals("com.stfalcon.demoapp", appContext.getPackageName());
}

С помощью имеющегося инструментария можно прослушивать состояние активностей:

ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(new ActivityLifecycleCallback() {
   @Override
   public void onActivityLifecycleChanged(Activity activity, Stage stage) {
       //do some stuff
   }
});

...или и вовсе управлять их состоянием:

Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
instrumentation.callActivityOnCreate(activity, bundle);

Заключение

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