Седьмая лекция курса по архитектуре клиент-серверных android-приложений, в которой мы продолжаем тему тестирования. А именно об инструментальном тестировании на реальных устройствах, тестировании пользовательского интерфейса с помощью Espresso, подмену ответов сервера и другие приемы. Дополнительно рассмотрим библиотеку Dagger 2.
Введение
До этого момента мы рассматривали только Unit-тесты и только с помощью JUnit. Это очень удобный способ тестировать отдельные методы и классы с использование большого количества различных маленьких тестов.
Но мы также столкнулись и со значительными ограничениями: мы не могли использовать классы Android, не могли тестировать работу с базой данных и другими элементами приложения. Поэтому с помощью JUnit было бы тяжело протестировать работу слоя данных, а протестировать корректность поведения UI вообще невозможно.
К счастью, существует альтернатива тестирования на JUnit, а именно запуск тестов на реальном устройстве. Тестирование на реальном устройстве избавляет нас от предыдущих проблем и позволяет использовать классы Android без дополнительных ухищрений. К тому же в таком случае мы можем использовать все окружение системы, а именно работу с базой данных, работу с файловой системой и все остальное, что нам доступно и при разработке. Поэтому количество проблем, связанных с созданием различных моков уменьшается, и нам для тестирования нужно правильно подменять только ответы сервера.
И есть еще один важный плюс – мы можем тестировать UI-часть приложения, то есть проверять, как именно будет взаимодействовать с приложением конечный пользователь.
Тестирование Android-приложений на устройстве можно разделить условно на 2 вида:
- Инструментальные тесты – это тесты, позволяющие вам использовать классы и методы системы Android для проверки своих модулей.
- UI тесты – это тесты, проверяющие взаимодействие приложения с пользователем и выполняющиеся по принципу черного ящика.
Инструментальное тестирование
Инструментальные тесты – это тесты, работающие на реальном устройстве, которые благодаря этому могут использовать все классы и методы системы Android. Они нужны в первую очередь для модульных тестов, когда для тестов требуется использование каких-либо классов Android. Сюда относится тестирование работы с базой данных, с SharedPreferences, с Context и другими классами. Такие тесты мы могли бы применить для тестирования слоя данных, так как в нем выполняется много работы с классами Android (Realm, SharedPreferences).
Рассмотрим процесс создания инструментальных тестов. Во-первых, вам нужно указать в скрипте сборки Runner для инструментальных тестов:
android { defaultConfig { testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } }
После этого мы можем создать тестовый класс, указав в качестве Runner класс AndroidJUnit4:
@RunWith(AndroidJUnit4.class) public class GithubRepositoryTest { private GithubRepository mRepository; @Before public void setUp() throws Exception { mRepository = new DefaultGithubRepository(); } }
Этого достаточно, чтобы использовать выполнять тесты на реальном устройстве. Теперь давайте протестируем слой данных, используя инструментальные тесты. И начнем с метода для авторизации. Напомним, как выглядит этот метод:
@NonNull public Observable<Authorization> auth(@NonNull String login, @NonNull String password) { String authorizationString = AuthorizationUtils.createAuthorizationString(login, password); return ApiFactory.getGithubService() .authorize(authorizationString, AuthorizationUtils.createAuthorizationParam()) .flatMap(authorization -> { KeyValueStorage storage = RepositoryProvider.provideKeyValueStorage(); storage.saveToken(authorization.getToken()); storage.saveUserName(login); ApiFactory.recreate(); return Observable.just(authorization); }) .doOnError(throwable -> RepositoryProvider.provideKeyValueStorage().clear()) .compose(RxSchedulers.async()); }
Что здесь важно проверить? Основная проверка заключается в том, что все данные авторизации (логин и текущий токен) были сохранены локально в случае успешной авторизации, а ошибка авторизации или ситуация, когда сервер не отвечает, обрабатываются корректно, и мы получаем Observable с ошибкой.
При тестировании на устройстве мы можем легко проверить, что данные были сохранены в SharedPreference или другое хранилище. Единственная проблема заключается в том, чтобы корректно подменить работу сервера или сэмулировать нужный нам результат.
Конечно, можно использовать для тестов реальные обращения серверу. Мы обсуждали, что это не лучший подход, но для каких-то ситуаций он подходит (например, для итоговых тестов, чтобы проверить, как приложение будет работать без всяких моков). Если и Android-приложение, и серверная часть разрабатываются вместе, то можно сделать тестовый сервер и настроить его так, чтобы он отдавал нужные ответы в разных тестовых запросах. Но такая настройка больше относится к вопросам серверной части, на стороне приложения достаточно изменять url для серверов. Поэтому такой подход мы рассматривать не будем.
Есть и другие варианты подмены ответов сервера. Репозиторий обращается за данными к сервису Retrofit. Он получает сервис Retrofit через статический метод в классе ApiFactory. Мы можем поступить по аналогии с подменой Repository, то есть использовать статический метод для установки значения сервиса Retrofit. Так мы поднимаемся как бы на уровень выше с точки зрения подмены ответов.
Как реализовать такой подход? Все аналогично, мы реализуем сервис Retrofit в виде класса и переопределяем метод авторизации:
private class GithubAuthService extends TestGithubService { @Override public Observable<Authorization> authorize(@Header("Authorization") String authorization, @Body JsonObject params) { authorization = authorization.split("Basic ")[1]; String auth = new String(Base64.decode(authorization, Base64.DEFAULT)); String login = auth.split(":")[0]; if ("root".equals(login)) { Authorization response = new Authorization(); response.setToken(TOKEN); return Observable.just(response); } return Observable.error(new IOException()); } }
Здесь мы задаем правило, что для любого пользователя с логином root авторизация будет успешна, а для всех остальных пользователей будет ошибка при вызове этого метода. И теперь мы можем написать следующий тест для сценария успешной авторизации:
@Test public void testSuccessAuth() throws Exception { ApiFactory.setGithubService(new GithubAuthService()); Authorization authorization = mRepository.auth("root", "12345").toBlocking().first(); assertEquals(TOKEN, authorization.getToken()); KeyValueStorage storage = RepositoryProvider.provideKeyValueStorage(); assertEquals(TOKEN, storage.getToken()); assertEquals("root", storage.getUserName().toBlocking().first()); }
Мы подменяем ответ сервиса Retrofit, после чего пытаемся авторизоваться. После авторизации мы проверяем, что все нужные данные были сохранены в настройках. Разумеется, тест для проверки ошибочной авторизации пишется точно также.
Мы снова использовали статический метод для установки значения, как и на прошлой лекции. Этот способ является наиболее простым с точки зрения реализации, но он не лишен недостатков. При таком подходе мы снова не тестируем ответы сервера, а тестируем замоканные модельки, что ухудшает качество тестов. Существуют более продвинутые и удобные способы подмены ответов сервера и реализации принципов IoC. Давайте рассмотрим их.
Хорошим способом изменения ответов сервера является их подмена на уровне OkHttp. OkHttp можно настроить таким образом, чтобы он в зависимости от url запроса и от переданных параметров возвращал какой-либо замоканный ответ вместо реального обращения к серверу. Что мы получим в таком случае? Поскольку такая подмена осуществляется уже на уровне сетевого слоя, это никак не затрагивает приложение, а значит, мы тестируем приложение в условиях, максимально близких к реальным.
Посмотрим, как именно можно подменять запросы в OkHttp. Для этого можно использовать Interceptor, который позволяет перехватывать запросы и обрабатывать их так, как нам нужно. Более того, он позволяет изменять запрос и возвращать результат вообще без обращения к серверу.
Реализуем такой Interceptor. Для этого создадим обычный Interceptor и класс, который будет отвечать за перехват и подмену запросов. Поэтому мы можем написать примерно такой код:
public class MockingInterceptor implements Interceptor { private final RequestsHandler mHandlers; public MockingInterceptor() { mHandlers = new RequestsHandler(); } @Override public Response intercept(Chain chain) throws IOException { Request request = chain.request(); String path = request.url().encodedPath(); if (mHandlers.shouldIntercept(path)) { return mHandlers.proceed(request, path); } return chain.proceed(request); } }
В этом Interceptor мы перехватываем все запросы и с помощью экземпляра RequestsHandler решаем, какие именно запросы должны быть замоканы тестовыми ответами. В классе RequestsHandler мы создаем список запросов, которые будут замоканы:
private final Map<String, String> mResponsesMap = new HashMap<>(); public RequestsHandler() { mResponsesMap.put("/authorizations", "auth.json"); mResponsesMap.put("/user/repos", "repositories.json"); } Мы также определяем набор пар ключ-значение, где ключом является путь перехватываемого запроса, а значением – путь к JSON-файлу с нужным ответом. Метод, который определяет, нужно ли перехватывать запрос, проходит по всем ключам и проверяет, есть ли в этом списке метод, переданный в качестве параметра: public boolean shouldIntercept(@NonNull String path) { Set<String> keys = mResponsesMap.keySet(); for (String interceptUrl : keys) { if (path.contains(interceptUrl)) { return true; } } return false; }
Далее, после определения того, что запрос нужно замокать с помощью локальных данных, мы создаем объект Response из нужного JSON-файла и возвращаем его в качестве ответа:
@NonNull public Response proceed(@NonNull Request request, @NonNull String path) { Set<String> keys = mResponsesMap.keySet(); for (String interceptUrl : keys) { if (path.contains(interceptUrl)) { String mockResponsePath = mResponsesMap.get(interceptUrl); return createResponseFromAssets(request, mockResponsePath); } } return OkHttpResponse.error(request, 500, "Incorrectly intercepted request"); }
JSON-файлы ответов являются копией ответов сервера на соответствующие запросы. Например, ответ на успешный запрос авторизации выглядит следующим образом:
{ "id" : 1000, "token" : "acd68de0abc2da3e89da0da" }
И осталось добавить этот Interceptor в OkHttp:
@NonNull private static OkHttpClient buildClient() { return new OkHttpClient.Builder() .addInterceptor(LoggingInterceptor.create()) .addInterceptor(ApiKeyInterceptor.create()) .addInterceptor(MockingInterceptor.create()) .build(); }
Таким образом, мы подменяем окружение, и при этом остальные компоненты приложения этого не могут заметить, что очень удобно и позволяет тестировать приложение в максимально реальных условиях.
На самом деле есть еще два очень важных момента, которые нужно решить. Во-первых, мы рассмотрели только способы подмены успешных ответов, а мы хотели бы тестировать и возникающие ошибки. Здесь не существует простых вариантов, но можно в Interceptor передавать информацию о том, что нужно вернуть ошибку, через какие-то общие данные, например, SharedPreferences. Например, когда мы хотим сэмулировать ошибку, мы можем сохранить текст error вместо токена, и тогда Interceptor поймет, что нужно вернуть ответ с ошибкой:
@NonNull public Response proceed(@NonNull Request request, @NonNull String path) { String token = RepositoryProvider.provideKeyValueStorage().getToken(); if ("error".equals(token)) { return OkHttpResponse.error(request, 400, "Error for path " + path); } //... }
Во-вторых, и это более серьезная проблема, мы писали весь код для тестов в самом коде приложения, то есть все классы для создания моков, а также JSON-файлы с тестовыми ответами попадут в итоговое приложение, а нам бы этого не хотелось. Наличие таких файлов всегда требует их чистки перед публикацией приложения, а это чревато потенциальными ошибками и багами. Конечно, можно было бы переместить все в тесты и создать метод для подмены экземпляра OkHttpClient, но есть намного более удобный способ – product flavors.
Product flavors позволяют писать нам разный код и использовать разные ресурсы в рамках одного приложения для того, чтобы собирать разные .apk-файлы в зависимости конфигурации. Product flavors расширяют возможности системы сборки gradle.
Мы знаем, что Android Studio поддерживает два типа сборки приложения – debug и release варианты. Первый обычно предназначается для разработки, второй для публикации в Google Play. Но такое разделение позволяет создать только два разных .apk-файла. А что, если нам нужно больше? Самый популярный пример – это платная и бесплатная версии приложения. Например, мы бы хотели в бесплатном приложении показывать рекламу, а в платном убрать ее и добавить некоторые функции. Есть несколько вариантов того, как можно сделать две версии приложения. Во-первых, можно сделать два проекта, и использовать в каждом из них только тот код, который нужно. Разумеется, это крайне неудобный и в корне неправильный подход, так как вам приходится дублировать код и вносить одинаковые изменения сразу в два проекта. А если вам нужно не 2 варианта, а 10? Конечно, это не выход. Во-вторых, можно в коде выполнять различные проверки и, в зависимости от их выполнения, показывать пользователю нужные экраны. Но это также не очень удачный подход, так как даже в платную версию приложения нам придется добавлять библиотеку для показа рекламы, что увеличит вес приложения. К тому же, такие проверки усложняют саму логику приложения.
В идеале нам бы хотелось писать код в рамках одного проекта, но при этом иметь возможность собрать два .apk-файла с платной и бесплатной версией, избежав сложных проверок.
И такая возможность есть – это product flavors. Создать product flavors можно следующим образом:
android { //... productFlavors { free { applicationId "ru.gdgkazan.githubmvp" versionName "1.0" } full { applicationId "ru.gdgkazan.githubmvp.paid" versionName "1.0-full" } } //... }
И теперь самое главное – если вы посмотрите на варианты сборки, то убедитесь, что теперь их уже 4: freeDebug, freeRelease, fullDebug, fullRelease. И в результате у вас уже 4 возможных .apk-файла. Product flavors позволяют писать разный код или использовать разные ресурсы для разных flavor и объединять его в main (основном flavor).
Как мы можем использовать такую возможность в нашем приложении. Создадим два product flavor – prod и mock. В product flavor prod мы создадим экземпляр OkHttp, который не будет никак мокаться и будет всегда выполнять реальные запросы на сервер, а в mock – экземпляр OkHttp с MockingInterceptor, то есть в этом product flavor мы всегда будем подменять ответы сервера.
Создадим предложеннные product flavors:
productFlavors { prod { } mock { } }
Теперь осталось перенести код в разные product flavors. Если мы откроем папку приложения, то в папке app/src мы увидим папки main, androidTest и test. Сюда же мы можем добавить и новые папки, названия которых соответствуют созданным flavor, то есть mock и prod. В них также создаем папки, соответствующие названиям пакетов и переносим нужные классы.
Все различие этих product flavors заключается в создаваемом экземпляре OkHttpClient. Поэтому вынесем работу с OkHttpClient в отдельный класс OkHttpProvider. Этот класс будет создан как во флаворе mock, так и во флаворе prod. Его метод provideClient во флаворе prod будет возвращать обычный OkHttpClient, который всегда будет обращаться к реальному серверу, а во флаворе mock – экземпляр OkHttpClient, который будет использовать моки. И все тесты мы будем запускать в сборке mockDebug.
При таком использовании product flavors вы можете получить еще одно важное преимущество – использование моков для разработки. Вы можете легко оказаться в ситуации, когда вам придется разрабатывать приложение без работающего сервера. И тогда, используя сборку mockDebug, вы сможете писать ваш код полностью на моках, а переключиться на реальный сервер можно будет за счет одной команды в Android Studio, и не нужно будет чистить код от различных проверок.
Теперь, с использованием всех этих знаний, давайте напишем тесты для проверки корректности работы слоя данных для сценариев авторизации. Проверим сценарий успешной авторизации. Для него мы создали следующий JSON-файл:
{ "id" : 1000, "token" : "acd68de0abc2da3e89da0da" }
Тогда тест для успешной авторизации может быть таким:
@Test public void testSuccessAuth() throws Exception { Authorization authorization = mRepository.auth("root", "12345").toBlocking().first(); assertEquals(TOKEN, authorization.getToken()); KeyValueStorage storage = RepositoryProvider.provideKeyValueStorage(); assertEquals(TOKEN, storage.getToken()); assertEquals("root", storage.getUserName().toBlocking().first()); }
Точно также можно написать тест и для неудачной авторизации. Разница заключается в том, что мы должны перед отправкой запроса сохранить в качестве токена текст error:
@Test public void testErrorAuth() throws Exception { RepositoryProvider.provideKeyValueStorage().saveToken(ERROR); TestSubscriber<Authorization> testSubscriber = new TestSubscriber<>(); mRepository.auth("error", "12").subscribe(testSubscriber); testSubscriber.assertError(HttpException.class); KeyValueStorage storage = RepositoryProvider.provideKeyValueStorage(); assertEquals("", storage.getToken()); assertEquals("", storage.getUserName().toBlocking().first()); }
Таким образом, мы протестировали работу слоя данных для сценариев авторизации. Можно аналогичным образом написать тесты для метода для работы с репозиториями. Напомним, как выглядит этот метод:
@NonNull @Override public Observable<List<Repository>> repositories() { return ApiFactory.getGithubService() .repositories() .flatMap(repositories -> { Realm.getDefaultInstance().executeTransaction(realm -> { realm.delete(Repository.class); realm.insert(repositories); }); return Observable.just(repositories); }) .onErrorResumeNext(throwable -> { Realm realm = Realm.getDefaultInstance(); RealmResults<Repository> repositories = realm.where(Repository.class).findAll(); return Observable.just(realm.copyFromRealm(repositories)); }) .compose(RxSchedulers.async()); }
Какие тесты нужно дописать для этого метода? Во-первых, нужно проверить, что репозитории корректно получаются с сервера и возвращаются в Observable. Во-вторых, нужно проверить, что данные сохраняются в базу. И в-третьих, нужно написать тест для сценария ошибки и получения из базы закэшированных данных. Все это делается легко, нужно только создать файл, содержащий тело успешного ответа, а также написать тесты на все остальные случаи:
@Test public void testLoadRepositories() throws Exception { TestSubscriber<Repository> testSubscriber = new TestSubscriber<>(); mRepository.repositories().flatMap(Observable::from).subscribe(testSubscriber); testSubscriber.assertNoErrors(); testSubscriber.assertValueCount(22); } @Test public void testRepositoriesSaved() throws Exception { mRepository.repositories().subscribe(); int savedCount = Realm.getDefaultInstance() .where(Repository.class) .findAll() .size(); assertEquals(22, savedCount); }
Как видно, в первом тесте мы получаем список репозиториев в Observable и проверяем, что нам действительно приходят все данные без ошибок. Во втором тестовом методе мы проверяем, что после выполнения запроса все репозитории были сохранены в базу. Осталось проверить, что в случае ошибки при запросе мы получим данные из базы. Для этого сначала получим данные с сервера и сохраним их в базе, после этого сэмулируем ошибку и попробуем получить данные снова (в таком случае должна произойти ошибка, и мы получим данные из кэша):
@Test public void testRepositoriesRestoredFromCache() throws Exception { //load all repositories mRepository.repositories().subscribe(); //force error for loading RepositoryProvider.provideKeyValueStorage().saveToken(ERROR); TestSubscriber<Repository> testSubscriber = new TestSubscriber<>(); mRepository.repositories().flatMap(Observable::from).subscribe(testSubscriber); testSubscriber.assertNoErrors(); testSubscriber.assertValueCount(22); }
Таким образом, мы разобрали, как писать Unit-тесты для различных модулей, которые используют зависимости Android. Конечно, можно и нужно писать больше таких тестов на различные случаи, но уже главное, что теперь мы знаем, как писать тесты для таких модулей.
Можно также задаться вопрос о том, зачем вообще нужны тесты на JUnit, когда мы можем писать тесты Unit-тесты с помощью Instrumentation тестов? Основная причина заключается в том, что тесты на JUnit заставляют вас соблюдать определенную архитектуру и не позволяют вам беспорядочно писать весь код в Presenter. Вам нужно писать код аккуратно и чисто. К тому же настроить CI-сервер только на JUnit намного проще, чем на использование эмуляторов или реальных устройств.
Мы рассмотрели способы написания инструментальных тестов и использования их для Unit-тестов. Теперь все модули нашего приложения в отдельности покрыты тестами, и мы можем перейти к рассмотрению других видов тестирования – интеграционному и системному.
UI-тестирование
В начале прошлого занятия мы говорили о том, что интеграционное и системное тестирование в Android проводятся с точки зрения пользователя, то есть это в первую очередь тестирование UI. При этом грань между интеграционным и системным тестированием (тестирование одного экрана и тестирование всего приложения) для разработчика практически стерта, поэтому мы в общем будем употреблять слово UI-тестирование.
UI-тестирование подразумевает запуск приложения на реальном устройстве и проверку именно поведения UI, а не внутренних элементов. Мы не знаем и не обязаны знать, как реализован тот или иной экран. Мы открываем экран и выполняем на этом экране определенные действия так же, как это делал бы пользователь. Мы всегда знаем, к какому результату должны приводить те или иные действия пользователя, и можем их проверять опять же с точки зрения пользователя.
Относительно тестирования Android-приложений можно сказать очень важную вещь. Unit-тесты позволяют вам проверять то, что определенный метод вызывается при определенных действиях, а UI-тесты должны проверять то, что этот метод приводит к нужному для пользователя результату. Это звучит сложновато, поэтому проще привести в пример все тот же экран авторизации с прошлой лекции. В Unit-тестах мы проверяем, что в случае нажатии кнопки и пустом поле для логина Presenter вызовет метод showLoginError, а в UI-тестах проверяется уже то, что метод showLoginError во View действительно отображает ошибку в TextInputLayout.
Для UI-тестирования существует немало различных фреймворков. Когда-то наиболее популярным был фреймворк Robotium, но сейчас его вытеснил Espresso, который мы и будем рассматривать. Оба этих тестовых фреймворка позволяют выполнять действия над UI-элементами (кнопками, полями ввода и другими) и проверять различные условия для этих UI-элементов (корректность текста, enabled / disabled и другие). Кроме того, есть и другие фреймворки, например UI Automator, который позволяет проверять работу приложения с точки зрения UI на очень высоком уровне взаимодействия.
Мы начнем писать тесты с экрана авторизации, который мы уже протестировали с точки зрения логики и слоя данных, и заодно покажем, как можно использовать фреймворк Espresso.
В базовом представлении фреймворк Espresso позволяет находить View на экране, выполнять с ними какие-то действия и проверять выполнение условий. Простейший тест на Espresso может выглядеть следующим образом:
@RunWith(AndroidJUnit4.class) public class AuthActivityTest { @Rule public final ActivityTestRule<AuthActivity> mActivityRule = new ActivityTestRule<>(AuthActivity.class); @Test public void testTypeLogin() throws Exception { onView(withId(R.id.loginEdit)).perform(typeText("MyLogin")); } }
Что происходит в этом тесте? Мы указываем в качестве Runner класс AndroidJUnit4, как уже делали для инструментальных тестов. Далее мы задаем правило (Rule), которое указывает, какую Activity запустить для тестов. И основное – в тестовом методе мы находим View с id loginEdit и над ней выполняем действие ввода текста MyLogin.
Пока что в тестовом методе не выполняются никакие проверки, но мы можем их добавить. Проверим, что введенный текст действительно появился в поле ввода с id loginEdit:
@Test public void testTypeLogin() throws Exception { onView(withId(R.id.loginEdit)) .perform(typeText("MyLogin")) .check(matches(withText("MyLogin"))); }
То есть к предыдущему тесту добавляется проверка, что во View с id loginEdit отображается текст MyLogin.
Это и есть общая схема всех тестов с Espresso:
- Найти View, передав в метод onView объект Matcher.
- Выполнить какие-то действия над этой View, передав в метод perform объект ViewAction.
- Проверить состояние View, передав в метод check объект ViewAssertion. Обычно для создания объекта ViewAssertion используют метод matches, который принимает объект Matcher из пункта 1.
Espresso – это очень умный фреймворк, который грамотно проверяет все элементы, при этом он может ждать некоторое время, пока выполнится определенное условие, что очень удобно, так как не всегда элементы на экране появляются мгновенно.
Для каждого из этих объектов существует большое количество стандартных методов, которые позволяют покрыть подавляющее большинство сценариев проверки:
- withId, withText, withHint, withTagKey, … – Matcher.
- click, doubleClick, scrollTo, swipeLeft, typeText, … – ViewAction.
- matches, doesNotExist, isLeftOf, noMultilineButtons – ViewAssertion.
И, разумеется, если вам не хватит стандартных объектов, вы всегда можете создать свои собственные, и мы рассмотрим, как это можно сделать.
А пока вернемся к тестированию экрана авторизации. Что мы могли бы проверить на этом экране с точки зрения UI:
- Приложение запускается с пустыми полями ввода и кнопкой с корректным текстом.
- При вводе пустого логина и нажатии на кнопку входа в поле ввода логина отображается ошибка.
- При вводе пустого пароля и нажатии на кнопку входа в поле ввода пароля отображается ошибка.
- При вводе корректных данных отображается открывается экран со списком репозиториев.
- При вводе ошибочных данных в поле ввода логина отображается ошибка.
Первые 3 сценария не требуют от нас подмены ответов сервера и являются достаточно простыми в реализации. Поэтому начнем с них. Напишем тест, который проверит, что при старте отображаются поля ввода с пустыми текстами:
@Test public void testEmptyInputFields() throws Exception { onView(withId(R.id.loginEdit)).check(matches(allOf( withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), isFocusable(), isClickable(), withText("") ))); onView(withId(R.id.passwordEdit)).check(matches(allOf( withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), isFocusable(), isClickable(), withInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD), withText("") ))); }
В данном методе мы для обоих полей ввода проверяем, что они отображаются, что на них можно навести фокус и нажать, и то, что в них отображается пустой текст. Для поля ввода пароля дополнительно проверяется, что они имеет тип ввода для пароля.
Аналогичным образом можно проверить работу кнопки входа:
@Test public void testLogInButtonShown() throws Exception { onView(withId(R.id.logInButton)).check(matches(allOf( withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), isClickable(), withText(R.string.log_in) ))); }
Далее мы хотим проверить, что вводимый пользователем текст корректно отображается в полях ввода (например, мы не хотим, чтобы какой-то TextWatcher отслеживал изменения и менял текст логина / пароля в каких-то случаях):
@Test public void testInputDisplayed() throws Exception { onView(withId(R.id.loginEdit)).perform(typeText("TestLogin")); closeSoftKeyboard(); onView(withId(R.id.passwordEdit)).perform(typeText("TestPassword")); closeSoftKeyboard(); onView(withId(R.id.loginEdit)).check(matches(withText("TestLogin"))); onView(withId(R.id.passwordEdit)).check(matches(withText("TestPassword"))); }
В этом тесте мы вводим данные для логина и пароля, а потом проверяем, что они отобразились в полях ввода. Здесь есть важный момент – после каждого ввода данных нужно закрывать клавиатуру после ввода данных. Иначе клавиатура может закрыть собой элемент, который вы хотите найти, и тогда Espresso выдаст ошибку.
Таким образом, мы написали различные тесты для первого случая, которые проверяют корректность отображения и поведения View при старте экрана. Перейдем к следующим пунктам – проверка отображения ошибки при попытке входе, когда введен пустой логин или пароль. Напомним, что в такой ситуации мы отображаем ошибку с помощью TextInputLayout. В тесте мы вводим пустой текст в поле ввода логина и нажимаем на кнопку:
@Test public void testLoginErrorDisplayed() throws Exception { onView(withId(R.id.loginEdit)).perform(typeText("")); closeSoftKeyboard(); onView(withId(R.id.logInButton)).perform(click()); } И теперь возникает вопрос – как именно проверить тот факт, что в TextInputLayout отобразилась ошибка? Разумеется, можно получить экземпляр Activity, найти нужный TextInputLayout и проверить текущую ошибку: @Test public void testLoginErrorDisplayed() throws Exception { onView(withId(R.id.loginEdit)).perform(typeText("")); onView(withId(R.id.logInButton)).perform(click()); TextInputLayout inputLayout = (TextInputLayout) mActivityRule.getActivity().findViewById(R.id.loginInputLayout); assertEquals(inputLayout.getResources().getString(R.string.error), inputLayout.getError()); }
Но так никогда не нужно делать при тестирование с Espresso, так как это нарушает все принципы работы этого фреймворка. Вместо этого мы могли бы описать свой Matcher для проверки. Тогда бы наш тест выглядел вот так:
@Test public void testLoginErrorDisplayed() throws Exception { onView(withId(R.id.loginEdit)).perform(typeText("")); onView(withId(R.id.logInButton)).perform(click()); onView(withId(R.id.loginInputLayout)).check(matches(withInputError(R.string.error))); }
Метод withInputError – это статический метод для создания нашего объекта Matcher, который проверит, что ошибка, отображаемая в TextInputLayout, соответствует переданному идентификатору ресурса.
Как создать такой Matcher? Изначально, Matcher – это интерфейс, но его не нужно реализовывать напрямую, вместо этого следует либо наследоваться от класса BaseMatcher, либо использовать еще более продвинутый вариант с наследованием TypeSafeMatcher, как мы и поступим.
Создадим свой класс, который будет наследоваться от TypeSafeMatcher, и определим в нем статический метод withInputError, чтобы удобно использовать его в дальнейшем:
public class InputLayoutErrorMatcher extends TypeSafeMatcher<View> { @StringRes private final int mErrorTextId; private InputLayoutErrorMatcher(@StringRes int errorTextId) { mErrorTextId = errorTextId; } @NonNull public static InputLayoutErrorMatcher withInputError(@StringRes int errorTextId) { return new InputLayoutErrorMatcher(errorTextId); } //... }
И сейчас осталось реализовать два метода. Первый метод является главным и непосредственно определяет, выполняется ли проверяемое условие:
@Override protected boolean matchesSafely(View item) { if (!(item instanceof TextInputLayout)) { return false; } String expectedError = item.getResources().getString(mErrorTextId); TextInputLayout layout = (TextInputLayout) item; return TextUtils.equals(expectedError, layout.getError()); }
В этом методе мы всего лишь получаем ожидаемую строку ошибки, строку ошибки, которая отображается в TextInputLayout, и сравниваем их.
И второй метод, который нам нужно реализовать при наследовании от TypeSafeMatcher – это вспомогательный метод для логирования describeTo. Когда вы получите ошибку при сравнении, Espresso выведет вам информацию о том, какие данные ожидались, а какие были получены. Такая информация поможет вам при отладке теста, поэтому методом describeTo не нужно пренебрегать. Реализуем его:
@Override public void describeTo(Description description) { description.appendText("with error: " + mErrorTextId); }
Здесь нужно сделать небольшое замечание, что такой код выведет в лог ошибки только идентификатор строкового ресурса, что не очень информативно. Поэтому для удобства отладки лучше сохранить ожидаемый текст, который мы получили при проверке, в поле классе и выводить в лог ошибки его. Но здесь мы опускаем это для того, чтобы не усложнять пример.
Таким образом, мы написали свой Matcher и убедились, что такими средствами можно очень удобно проверять состояние любых View и любые свойства. При этом мы полностью сохраняем парадигму работы Espresso.
Мы рассмотрели написание простых тестов на работу с UI-элементами, а теперь пора перейти к рассмотрению тестов на различные сценарии, связанные с серверными запросами, то есть сценарии удачной и неудачной авторизации.
Конечно, как и во всех других видах тестов, для выполнения UI-тестов также стоит использовать моки для серверных запросов. Мы уже знаем все способы подмены серверных запросов в тестах, любой из допустим для UI-тестов. Но мы будем пользоваться наиболее удобными средствами, а именно product flavors и подменой ответа на уровне OkHttp.
Нужно добавить еще один небольшой момент. Подмена ответов при запросе выполняется почти мгновенно, но обычно запросы выполняются достаточно долго. Чтобы работа приложения на моках была максимально приближена к реальности (чего мы и хотим достичь), нужно добавить искусственную задержку в MockingInterctor:
@Override public Response intercept(Chain chain) throws IOException { Request request = chain.request(); String path = request.url().encodedPath(); if (mHandlers.shouldIntercept(path)) { Response response = mHandlers.proceed(request, path); int stubDelay = 500 + mRandom.nextInt(2500); SystemClock.sleep(stubDelay); return response; } return chain.proceed(request); }
По умолчанию у нас всегда проходит успешная авторизация, поэтому мы можем ввести любые непустые данные и нажать на кнопку входа:
@Test public void testSuccessAuth() throws Exception { onView(withId(R.id.loginEdit)).perform(typeText("login")); closeSoftKeyboard(); onView(withId(R.id.passwordEdit)).perform(typeText("pass")); closeSoftKeyboard(); onView(withId(R.id.logInButton)).perform(click()); }
Единственный вопрос состоит в том, как проверить то, что была запущена какая-то Activity. В рамках Unit-тестов мы проверяли, что был вызван нужный метод. Но для данного случая такой подход неправильный, так как мы тестируем без знания реализации классов. Можно проверять, что открылся экран списка репозиториев по наличию каких-то данных на этом экран, но это тоже не очень правильно, так как мы тестируем только отдельный экран, а не все экраны вместе. К счастью, есть другой способ. В Android для запуска Activity используются Intent-ы. Espresso предоставляет возможность отслеживать запуск Activity через Intent, а также мокать такие вызовы. Для работы с Intent-ами в Espresso нужно подключить библиотеку в gradle:
androidTestCompile ‘com.android.support.test.espresso:espresso-intents:2.2.2’
И настроить методы с аннотациями Before и After:
@Before public void setUp() throws Exception { Intents.init(); } @After public void tearDown() throws Exception { Intents.release(); }
Теперь мы можем проверить, что после нажатия кнопки входа происходит успешная авторизация и открывается экран списка репозиториев:
@Test public void testSuccessAuth() throws Exception { onView(withId(R.id.loginEdit)).perform(typeText("login")); closeSoftKeyboard(); onView(withId(R.id.passwordEdit)).perform(typeText("pass")); closeSoftKeyboard(); onView(withId(R.id.logInButton)).perform(click()); Intents.intended(hasComponent(RepositoriesActivity.class.getName())); }
Аналогичным образом проверяем, что в случае неудачной авторизации в TextInputLayout пользователю отображается ошибка:
@Test public void testErrorAuth() throws Exception { RepositoryProvider.provideKeyValueStorage().saveToken(ERROR); onView(withId(R.id.loginEdit)).perform(typeText("login")); closeSoftKeyboard(); onView(withId(R.id.passwordEdit)).perform(typeText("pass")); closeSoftKeyboard(); onView(withId(R.id.logInButton)).perform(click()); onView(withId(R.id.loginInputLayout)).check(matches(withInputError(R.string.error))); }
Таким образом, мы протестировали различные сценарии экрана авторизации с точки зрения UI. Теперь мы можем не только убедиться, что логика этого экрана работает корректно, но также и то, что приложение запускается и работает так, как мы ожидаем.
Для закрепления навыков UI-тестирования напишем тесты для экрана списка репозиториев. На этом экране нам придется разобраться с еще несколькими вопросами, поэтому его тестирование также надо рассмотреть.
Напомним, что на этом экране отображается список элементов в RecyclerView. И это первый важный момент – мы работаем со списком элементов, поэтому нам придется использовать другие методы для поиска и взаимодействия со View.
Начнем с простого – проверим, что данные при старте загружаются и корректно отображаются, то есть отображается не пустой список:
@Test public void testRecyclerViewDisplayed() throws Exception { onView(withId(R.id.empty)).check(matches(not(isDisplayed()))); onView(withId(R.id.recyclerView)).check(matches(isDisplayed())); }
Далее, мы можем добавить действия скролла для этого списка, чтобы проверить, что при скролле RecyclerView ведет себя ожидаемо:
@Test public void testScrollRecyclerView() throws Exception { onView(withId(R.id.recyclerView)) .perform(scrollToPosition(15)) .perform(scrollToPosition(8)) .perform(scrollToPosition(1)) .perform(scrollToPosition(2)) .perform(scrollToPosition(10)) .perform(scrollToPosition(19)); }
Теперь проверим, что при нажатии на элемент списка открывается экран списка коммитов этого репозитория. Мы уже знаем, как проверять, что открывается определенный экран, а для нажатия на элемент списка используется специальный метод actionOnItemWithPosition:
@Test public void testClickOnItem() throws Exception { onView(withId(R.id.recyclerView)) .perform(actionOnItemAtPosition(14, click())); Intents.intended(hasComponent(CommitsActivity.class.getName())); }
Последняя ситуация, которую мы должны протестировать на этом экране, – это проверка отображения текста об ошибке, когда мы не смогли получить данные. Здесь есть тонкий момент – еще перед стартом тестового метода Activity запускается, а значит, начинает грузиться список репозиториев. И загрузка начнется до того, как мы в тестовом методе подставим ошибку в SharedPreferences.
Поэтому наши тесты придется немного модифицировать. Изменим ActivityTestRule так, чтобы по умолчанию Activity не запускалась. Тогда мы сможем запускать ее вручную и, соответственно, делать это в нужный момент, например, после сохранения ошибки вместо токена:
@Rule public final ActivityTestRule<RepositoriesActivity> mActivityRule = new ActivityTestRule<>(RepositoriesActivity.class, false, false); Создадим метод, который позволит вручную запускать Activity: private void launchActivity() { Context context = InstrumentationRegistry.getContext(); Intent intent = new Intent(context, RepositoriesActivity.class); mActivityRule.launchActivity(intent); }
Теперь в тестовом методе мы сначала сохраним в качестве токена текст error (чтобы MockingInterceptor вернул ошибку), а уже потом запустим Activity:
@Test public void testErrorDisplayed() throws Exception { RepositoryProvider.provideKeyValueStorage().saveToken(ERROR); launchActivity(); onView(withId(R.id.empty)).check(matches(isDisplayed())); onView(withId(R.id.recyclerView)).check(matches(not(isDisplayed()))); onView(withId(R.id.empty)).check(matches(withText(R.string.empty_repositories))); }
На этом мы завершаем тестирование экрана списка репозиториев и вообще тему UI-тестирования. Разумеется, мы рассмотрели далеко не все возможности Espresso, но даже их хватает для того, чтобы написать много различных тестов и уменьшить число рутинных ручных проверок.
Теперь мы умеем тестировать приложение на самых разных уровнях самыми разными способами и можем создавать качественные и протестированные приложения!
Дополнительно – Dagger 2
Мы рассмотрели много примеров реализации принципов IoC и DI в Android, однако у них есть и свои проблемы. Класс делегата (Presenter) часто зависит от многих других компонент приложения, например, от репозитория, от локального хранилища и других. Мы обсуждали, что эти зависимости можно внедрять через конструктор, что делает контракт класса более явным и понятным. Однако тогда нам пришлось бы инициализировать слишком много объектов для создания делегата. Поэтому мы выносили какие-то зависимости в специальные классы, которые предоставляли нужную реализацию зависимостей, а также обеспечивали возможность подмены зависимостей. Но в таком случае нам тяжело понять, что какой-то делегат использует зависимость от репозитория или локального хранилища, что делает код более связным.
В идеальном случае мы бы хотели передавать все зависимости в конструктор, чтобы понимать, от чего зависит тот или иной делегат, а также иметь возможность не инициализировать каждую зависимость вручную. И для этого существует немало библиотек, которые мы уже перечисляли. Но, без сомнения, самой популярной библиотекой для реализации DI в Android является Dagger 2.
Мы рассмотрим основы использования этой библиотеки, чтобы понять, какую выгоду она может дать. Для этого сразу перейдем к примеру. Допустим, мы отказались от использования статических методов для получения зависимостей, и все зависимости передаем через конструктор. Тогда наш Presenter может выглядеть следующим образом:
private final GithubRepository mRepository; private final KeyValueStorage mKeyValueStorage; private final LifecycleHandler mLifecycleHandler; private final AuthView mAuthView; public AuthPresenter(@NonNull GithubRepository repository, @NonNull KeyValueStorage keyValueStorage, @NonNull LifecycleHandler lifecycleHandler, @NonNull AuthView authView) { mRepository = repository; mKeyValueStorage = keyValueStorage; mLifecycleHandler = lifecycleHandler; mAuthView = authView; }
Мы можем создать экземпляр этого Presenter-а, явным образом проинициализировав объекты для репозитория и локального хранилища. Dagger 2 позволяет сделать это проще, нам нужно добавить пару аннотаций и вызвать метод для внедрения зависимостей:
@Inject GithubRepository mRepository; @Inject KeyValueStorage mKeyValueStorage; //... @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); //... AppDelegate.getAppComponent().injectAuthActivity(this); mPresenter = new AuthPresenter(mRepository, mKeyValueStorage, lifecycleHandler, this); mPresenter.init(); }
Здесь мы нигде не создаем объекты mRepository и mKeyValueStorage (репозиторий и локальное хранилище соответственно), а только объявляем их полями класса и помечаем аннотацией @Inject. И после этого мы позволяем Dagger 2 инициализировать эти зависимости за нас. Такой подход очень удобен в плане того, что мы не создаем объекты явным образом и не думаем о том, какую именно реализацию мы должны применить. Внедрение таких зависимостей выполняется на более высоком уровне абстракции, что позволяет удобно разделять сами зависимости и их использование.
Разумеется, для того, чтобы такой код работал, требуется написать еще некоторые фрагменты кода, поэтому перейдем к рассмотрению основных возможностей этой библиотеки.
Dagger 2 – это библиотека, позволяющая вам гибко управлять зависимостями различных модулей приложения, а также подменять эти зависимости. При этом у Dagger 2 есть немало преимуществ, которые и делают эту библиотеку наиболее популярным средством реализации DI в Android:
- Кодогенерация вместо рефлексии. Разумеется, это вечный спор, однако в случае внедрения зависимостей кодогенерация имеет много плюсов:
- Ошибки в зависимостях обнаруживаются еще на этапе компиляции, а не в рантайме, к тому же их проще отследить.
- Нет проблем, связанных с ошибками при обфускации.
- Код, который генерирует Dagger 2, можно всегда посмотреть и понять.
- Кодогенерация всегда быстрее рефлексии.
- Простая настройка зависимостей даже для сложного приложения.
- Возможность создания глобальных и локальных синглтонов.
- Малый размер библиотеки.
Одним из ключевых элементов Dagger 2 являются аннотации, которые и определяют основные сущности библиотеки:
- @Inject – аннотация, указывающая, что в этом месте нужно внедрить зависимость. Этой аннотацией могут быть помечены поля класса, конструкторы и методы.
- @Provides – аннотация, которой помечаются модули, предоставляющие зависимости (то есть возвращающие какие-то объекты).
- @Module – аннотация для класса, в котором определен набор методов с аннотацией @Provider. То есть этой аннотацией помечается некий поставщик зависимостей.
- @Component – этой аннотацией помечается интерфейс, который связывает модули с непосредственно частями, которые запрашивают зависимости.
- @Scope – аннотация, которой помечаются другие аннотации для создания глобальных и локальных зависимостей. Стандартной аннотацией этого типа является @Singleton, означающая, что зависимость должна быть создана только один раз.
Чтобы понять, как использовать эти аннотации и какое значение они имеют, давайте продолжим разбираться с примером.
Использование одной аннотации мы уже видели – аннотация @Inject для полей репозитория и локального хранилища:
@Inject GithubRepository mRepository; @Inject KeyValueStorage mKeyValueStorage;
Это аннотация показывает, что мы хотим внедрить зависимость для репозитория и для локального хранилища. Разумеется, чтобы Dagger 2 мог внедрить такие зависимости (попросту говоря, подставить в поле значение), где-то должен быть метод, возвращающий нужные объекты, то есть метод с аннотацией @Provides:
@Provides KeyValueStorage provideKeyValueStorage() { return new HawkKeyValueStorage(); }
Следующий момент – объект локального хранилища мы вполне можем иметь в одном экземпляре, и в этом Dagger нам поможет очень простым образом – нужно всего лишь добавить аннотацию @Singleton к методу:
@Provides @Singleton KeyValueStorage provideKeyValueStorage() { return new HawkKeyValueStorage(); }
Все зависимости должны находиться в модулях, то есть такие методы должны быть определены в классе с аннотацией @Module:
@Module public class DataModule { @Provides @Singleton KeyValueStorage provideKeyValueStorage() { return new HawkKeyValueStorage(); } }
Модули нужны для того, чтобы Dagger мог строить граф зависимостей (определять порядок внедрения зависимостей, искать нужные зависимости и проверять наличие ошибок).
Продолжим развивать модуль DataModule. В этом модуле мы определим все зависимости, которые относятся к слою данных, то есть репозиторий, клиент OkHttp, базу данных, сервис ретрофита и другие. При этом все эти зависимости будут глобальными, так как нам достаточно иметь их в одном экземпляре.
Создадим зависимость для клиента OkHttp:
@Provides @Singleton OkHttpClient provideOkHttpClient() { return new OkHttpClient.Builder() .addInterceptor(LoggingInterceptor.create()) .addInterceptor(ApiKeyInterceptor.create()) .build(); }
И для объекта Retrofit, чтобы дальше создавать сервисы:
@Provides @Singleton Retrofit provideRetrofit(@NonNull OkHttpClient client) { return new Retrofit.Builder() .baseUrl(BuildConfig.API_ENDPOINT) .client(client) .addConverterFactory(GsonConverterFactory.create()) .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) .build(); }
Здесь есть очень важный момент, который также показывает всю мощь Dagger 2. Сетевой клиент для создания объекта Retrofit мы передаем в параметре для метода provideRetrofit. И когда Dagger будет создавать зависимости, он поймет, что для создания объекта Retrofit ему нужен объект OkHttpClient, и будет искать его в зависимостях. И, если Dagger не сможет найти нужную зависимость, произойдет ошибка на этапе компиляции. Более того, Dagger может таким образом искать зависимости на нескольких уровнях и для нескольких параметров. Например, так будут выглядеть методы для создания зависимости для сервиса Retrofit и репозитория:
@Provides @Singleton GithubRepository provideGithubRepository( @NonNull GithubService githubService, @NonNull KeyValueStorage keyValueStorage) { return new DefaultGithubRepository(githubService, keyValueStorage); } @Provides @Singleton GithubService provideGithubService(@NonNull Retrofit retrofit) { return retrofit.create(GithubService.class); }
Здесь Dagger попытается создать зависимость GithubRepository. Для этого ему нужны две другие зависимости: GithubService и KeyValueStorage. Он находит обе этих зависимости в методах provideGithubService и provideKeyValueStorage соответственно. Метод provideKeyValueStorage не имеет других зависимостей, поэтому он создает этот объект без дальнейших поисков. Метод provideGithubService зависит от объекта Retrofit, который создается с помощью метода provideRetrofit, который в свою очередь зависит от объекта OkHttpClient. Примерно на середине пути мы легко можем потерять последовательность изложения, а вот Dagger с этим справляется, и нам нужно только описать зависимости.
Теперь перейдем к последней части – связь между объектами, в которые нужно внедрить зависимости и модулями зависимостей. Для этого служат интерфейсы, которые помечаются аннотацией @Component. Создадим такой интерфейс, который будет внедрять зависимости во все наши Activity:
@Singleton @Component(modules = {DataModule.class}) public interface AppComponent { void injectAuthActivity(AuthActivity authActivity); void injectRepositoriesActivity(RepositoriesActivity repositoriesActivity); void injectWalkthroughActivity(WalkthroughActivity walkthroughActivity); }
Интерфейс помечается аннотацией @Component. В качестве параметра этой аннотации передается список модулей, которые нужны для внедрения зависимостей. Мы знаем, что во всех Activity для данного компонента мы будем использовать только DataModule (для внедрения репозитория и локального хранилища), поэтому указываем только этот модуль. Если бы мы использовали несколько модулей, то их можно было бы перечислить через запятую.
После объявления такого компонента нужно выполнить сборку проекта, чтобы Dagger 2 сгенерировал все классы. В данном случае нас интересует сгенерированный класс DaggerAppComponent, который позволяет создать экземпляр AppComponent и внедрять зависимости. Поскольку мы будем использовать этот компонент везде, можно сохранить ссылку на него в классе Application:
public class AppDelegate extends Application { private static AppComponent sAppComponent; @Override public void onCreate() { super.onCreate(); sAppComponent = DaggerAppComponent.builder() .dataModule(new DataModule()) .build(); } @NonNull public static AppComponent getAppComponent() { return sAppComponent; } }
При создании объекта AppComponent с помощью сгенерированного класса DaggerAppComponent нужно обратить внимание, что мы вручную задаем все модули зависимостей. Это очень мощная возможность, которая позволяет в любой момент подменять зависимости – нужно всего лишь подставить другой модуль. Такая возможность удобна как для изменения поведения во время работы приложения, так и для тестирования.
И теперь мы, наконец, можем внедрить все зависимости:
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); //... AppDelegate.getAppComponent().injectAuthActivity(this); //... }
Здесь Dagger поймет, что ему нужно проинициализировать все поля с аннотацией @Inject и найдет все зависимости, которые ему для этого нужны.
Важным вопросом является также вопрос масштабирования системы. Когда у нас простые экраны и зависимости, мы вполне можем обойтись одним модулем и одним компонентом. В более сложном случае нужно разделять зависимости по разным модулям и использовать разные компоненты с разным жизненным циклом. Можно использовать модули и подкомпоненты (@Subcomponent) для отдельных экранов, чтобы создавать зависимости для Presenter-ов и других элементов конкретного экрана.
Конечно, невозможно полностью обсудить все возможности и способы использования такой библиотеки как Dagger 2 в рамках небольшой дополнительной лекции. Мы рассмотрели лишь базовые принципы и идеи, которые помогают понять, как можно применять эту библиотеку для управления зависимостями в рамках изучаемой архитектуры.
Практика
- Написать UI-тесты для WalkthroughActivity (скачать проект)
- Проверить смену текстов при свайпах и нажатии на кнопку
- Проверить открытие экрана авторизации
Ссылки и полезные ресурсы
- Приложение из репозитория:
- GithubUITests – приложение с примерами написания UI-тестов и практическим заданием.
- GithubDagger – приложение с примером использования Dagger 2 для реализации принципов DI.
- Документация по инструментальным тестам.
- Различные предложения, как замокать ответ сервера в OkHttp.
- Использование product flavors для тестов.
- Тестовый сервер в OkHttp и пример его использования.
- Введение в тестирование с Espresso.
- Документация по Espresso.
- Выступление по Espresso с Google I/O 2016.
- Фреймворк Robotium для тестирования.
- Фреймворк UI Automator.
- MonkeyRunner.
- Документация по Dagger 2, и как его использовать для тестов.
- Хорошая статья по основам Dagger 2 на хабре.
- Статья про Dagger 2 от Фернандо Цехаса.
- Статья про Dagger 2 на хабре.
/lektsiya-8-po-arhitekture-android-data-binding-mvvm[:]