Прості 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, рекомендую почитати офіційну документацію, а ось матеріал про про можливості інструментальних тестів.