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