Построение Android приложений шаг за шагом, часть вторая

в 12:52, , рубрики: android, mvp, rxjava, архитектура Android-приложений, архитектура приложений, разработка мобильных приложений, Разработка под android, Тестирование мобильных приложений

Построение Android приложений шаг за шагом, часть вторая - 1

В первой части статьи мы разработали приложение для работы с github, состоящее из двух экранов, разделенное по слоям с применением паттерна MVP. Мы использовали RxJava для упрощения взаимодействия с сервером и две модели данных для разных слоев. Во второй части мы внедрим Dagger 2, напишем unit тесты, посмотрим на MockWebServer, JaCoCo и Robolectric.

Содержание:

Введение

В первой части статьи мы в два этапа создали простое приложение для работы с github.

Условная схема приложения

Построение Android приложений шаг за шагом, часть вторая - 2

Диаграмма классов
Построение Android приложений шаг за шагом, часть вторая - 3

Все исходники вы можете найти на Github. Ветки в репозитории соответствуют шагам в статье: Step 3 Dependency injection — третий шаг, Step 4 Unit tests — четвертый шаг.

Шаг 3. Dependency Injection

Перед тем, как использовать Dagger 2, необходимо понять принцип Dependency injection (Внедрение зависимости).

Представим, что то у нас есть объект A, который включает объект B. Без использования DI мы должны создавать объект B в коде класса A. Например так:

public class A {
   B b;

   public A() {
       b = new B();
   }
}

Такой код сразу же нарушает SRP и DRP из принципов SOLID. Самым простым решением является передача объекта B в конструктор класса A, тем самым мы реализуем Dependency Injection “вручную”:

public class A {
   B b;

   public A(B b) {
       this.b = b;
   }
}

Обычно DI реализуется с помощью сторонних библиотек, где благодаря аннотациям, происходит автоматическая подстановка объекта.

public class A {
   @Inject
   B b;

   public A() {
       inject();
   }
}

Подробнее об этом механизме и его применении на Android можно прочитать в этой статье: Знакомимся с Dependency Injection на примере Dagger

Dagger 2

Dagger 2 — библиотека созданная Google для реализации DI. Ее основное преимущество в кодогенерации, т.е. все ошибки будут видны на этапе компиляции. На хабре есть хорошая статья про Dagger 2, также можно почитать официальную страницу или хорошую инструкцию на codepath

Для установки Dagger 2 необходимо отредактировать build.gradle:

build.gradle

apply plugin: 'com.android.application'
apply plugin: 'com.neenbedankt.android-apt'
 
dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile 'com.android.support:appcompat-v7:21.0.3'
 
    compile 'com.google.dagger:dagger:2.0-SNAPSHOT'
    apt 'com.google.dagger:dagger-compiler:2.0-SNAPSHOT'
    provided 'org.glassfish:javax.annotation:10.0-b28'
}

Также очень рекомендуется поставить плагин Dagger IntelliJ Plugin. Он поможет ориентироваться откуда и куда происходят инжекции.

Dagger IntelliJ Plugin

Построение Android приложений шаг за шагом, часть вторая - 4

Сами объекты для внедрения Dagger 2 берет из методов модулей (методы должны помечаться аннотацией Provides, модули — Module) или создает их с помощью конструктора класса аннотированного Inject. Например:

@Module
public class ModelModule {

   @Provides
   @Singleton
   ApiInterface provideApiInterface() {
       return ApiModule.getApiInterface();
   }
}

или

public class RepoBranchesMapper 

   @Inject
   public RepoBranchesMapper() {}
}

Поля для внедрения обозначаются аннотацией Inject:

@Inject
protected ApiInterface apiInterface;

Связываются эти две вещи с помощью компонентов (@Component). В них указывается откуда брать объекты и куда их внедрять (методы inject). Пример:

@Singleton
@Component(modules = {ModelModule.class})
public interface AppComponent {

   void inject(ModelImpl dataRepository);
}

Для работы Dagger 2 мы будем использовать один компонент (AppComponent) и 3 модуля для разных слоев (Model, Presentation, View).

AppComponent

@Singleton
@Component(modules = {ModelModule.class, PresenterModule.class, ViewModule.class})
public interface AppComponent {

   void inject(ModelImpl dataRepository);

   void inject(BasePresenter basePresenter);

   void inject(RepoListPresenter repoListPresenter);

   void inject(RepoInfoPresenter repoInfoPresenter);

   void inject(RepoInfoFragment repoInfoFragment);
}

Model

Для Model — слоя необходимо необходимо предоставлять ApiInterface и два Scheduler для управления потоками. Для Scheduler необходимо использовать аннотацию Named, чтобы Dagger разобрался с графом зависимостей.

ModelModule

@Provides
@Singleton
ApiInterface provideApiInterface() {
   return ApiModule.getApiInterface(Const.BASE_URL);
}

@Provides
@Singleton
@Named(Const.UI_THREAD)
Scheduler provideSchedulerUI() {
   return AndroidSchedulers.mainThread();
}

@Provides
@Singleton
@Named(Const.IO_THREAD)
Scheduler provideSchedulerIO() {
   return Schedulers.io();
}

Presenter

Для presenter слоя нам необходимо предоставлять Model и CompositeSubscription, а также мапперы. Model и CompositeSubscription будем предоставлять через модули, мапперы — с помощью аннотированного конструктора.

Presenter Module

public class PresenterModule {

   @Provides
   @Singleton
   Model provideDataRepository() {
       return new ModelImpl();
   }

   @Provides
   CompositeSubscription provideCompositeSubscription() {
       return new CompositeSubscription();
   }
}

Пример маппера с аннотированным конструктором

public class RepoBranchesMapper implements Func1<List<BranchDTO>, List<Branch>> {

   @Inject
   public RepoBranchesMapper() {
   }

   @Override
   public List<Branch> call(List<BranchDTO> branchDTOs) {
       List<Branch> branches = Observable.from(branchDTOs)
               .map(branchDTO -> new Branch(branchDTO.getName()))
               .toList()
               .toBlocking()
               .first();
       return branches;
   }
}

View

Со View слоем и внедрением презентеров ситуация сложнее. При создании презентера мы в конструкторе передаем интерфейс View. Соответственно, Dagger должен иметь ссылку на реализацию этого интерфейса, т.е на наш фрагмент. Можно пойти и другим путем, изменив интерфейс презентера и передавая ссылку на view в onCreate. Рассмотрим оба случая.

Передача ссылки на view.

У нас есть фрагмент RepoListFragment, реализующий интерфейс RepoListView,
и RepoListPresenter, принимающий на вход в конструкторе этот RepoListView. Нам необходимо внедрить RepoListPresenter в RepoListFragment. Для реализации такой схемы нам придется создать новый компонент и новый модуль, который в конструкторе будет принимать ссылку на наш интерфейс RepoListView. В этом модуле мы будем создавать презентер (с использованием ссылки на интрефейс RepoListView) и внедрять его в фрагмент.

Внедрение во фрагменте

@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
   DaggerViewComponent.builder()
           .viewDynamicModule(new ViewDynamicModule(this))
           .build()
           .inject(this);
}

Компонент

@Singleton
@Component(modules = {ViewDynamicModule.class})
public interface ViewComponent {

   void inject(RepoListFragment repoListFragment);
}

Модуль

@Module
public class ViewDynamicModule {

   RepoListView view;

   public ViewDynamicModule(RepoListView view) {
       this.view = view;
   }

   @Provides
   RepoListPresenter provideRepoListPresenter() {
       return new RepoListPresenter(view);
   }
}

В реальных приложениях у вас будет множество инжекций и модулей, поэтому создание различных компонентов для различных сущностей — отличная идея для предотвращения создания god object.

Изменение кода презентера.

Приведенный выше метод требует создания нескольких файлов и множества действий. В нашем случае, есть гораздо более простой способ, изменим конструктор и будем передавать ссылку на интерфейс в onCreate.
Код:

Внедрение во фрагменте

@Inject
RepoInfoPresenter presenter;

@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
   App.getComponent().inject(this);
   presenter.onCreate(this, getRepositoryVO());
}

Модуль

@Module
public class ViewModule {

   @Provides
   RepoInfoPresenter provideRepoInfoPresenter() {
       return new RepoInfoPresenter();
   }
}

Завершив внедрение Dagger 2, перейдем к тестированию приложения.

Шаг 4.Тестирование, Unit test

Тестирование давно стало неотъемлемой частью процесса разработки ПО.
Википедия выделяет множество видов тестирования, в первую очередь разберемся с модульным (unit) тестированием.

Модульное тестирование процесс в программировании, позволяющий проверить на корректность отдельные модули исходного кода программы.
Идея состоит в том, чтобы писать тесты для каждого нетривиального метода. Это позволяет достаточно быстро проверить, не привело ли очередное изменение кода к регрессии, то есть к появлению ошибок в уже оттестированных местах программы, а также облегчает обнаружение и устранение таких ошибок.

Написать полностью изолированные тесты у нас не получится, потому что все компоненты взаимодействуют друг с другом. Под unit тестами, мы будем понимать проверку работы одного модуля окруженного моками. Взаимодействие нескольких реальных модулей будем проверять в интеграционных тестах.

Схема взаимодействия модулей:

Построение Android приложений шаг за шагом, часть вторая - 5

Пример тестирования маппера (серые модули — не используются, зеленые — моки, синий — тестируемый модуль):

Построение Android приложений шаг за шагом, часть вторая - 6

Инфраструктура

Инструменты и фреймворки повышают удобство написания и поддержки тестов. CI сервер, который не даст вам сделать merge при красных тестах, резко уменьшает шансы неожиданной поломки тестов в master branch. Автоматический запуск тестов и ночные сборки помогают выявить проблемы на самом раннем этапе. Этот принцип получил название fail fast.
Про тестовое окружение вы можете почитать в статье Тестирование на Android: Robolectric + Jenkins + JaСoСo. В дальнейшем мы будем использовать Robolecric для написания тестов, mockito для создания моков и JaСoСo для проверки покрытия кода тестами.

Паттерн MVP позволяет быстро и эффективно писать тесты на наш код. С помощью Dagger 2 мы сможем подменить настоящие объекты на тестовые моки, изолировав код от внешнего мира. Для этого используем тестовый компонент с тестовыми модулями. Подмена компонента происходит в тестовом application, который мы задаем с помощью аннотации Config(application = TestApplication.class) в базовом тестовом классе.

JaCoCo Code Coverage

Перед началом работы, нужно определить какие методы тестировать и как считать процент покрытия тестами. Для этого используем библиотеку JaCoCo, которая генерирует отчеты по результатам выполнения тестов.
Современная Android Studio поддерживает code coverage из коробки или можно настроить его, добавив в build.gradle следующие строки:

build.gradle

apply plugin: 'jacoco'

jacoco {
   toolVersion = "0.7.1.201405082137"
}

def coverageSourceDirs = [
       '../app/src/main/java'
]

task jacocoTestReport(type: JacocoReport, dependsOn: "testDebugUnitTest") {
   group = "Reporting"

   description = "Generate Jacoco coverage reports"

   classDirectories = fileTree(
           dir: '../app/build/intermediates/classes/debug',
           excludes: ['**/R.class',
                      '**/R$*.class',
                      '**/*$ViewInjector*.*',
                      '**/*$ViewBinder*.*',   //DI
                      '**/*_MembersInjector*.*',  //DI
                      '**/*_Factory*.*',  //DI
                      '**/testrx/model/dto/*.*', //dto model
                      '**/testrx/presenter/vo/*.*', //vo model
                      '**/testrx/other/**',
                      '**/BuildConfig.*',
                      '**/Manifest*.*',
                      '**/Lambda$*.class',
                      '**/Lambda.class',
                      '**/*Lambda.class',
                      '**/*Lambda*.class']
   )

   additionalSourceDirs = files(coverageSourceDirs)
   sourceDirectories = files(coverageSourceDirs)
   executionData = files('../app/build/jacoco/testDebugUnitTest.exec')

   reports {
       xml.enabled = true
       html.enabled = true
   }
}

Обратите внимание на исключенные классы: мы удалили все что связано с Dagger 2 и нашими моделями DTO и VO.

Запустим jacoco (gradlew jacocoTestReport) и посмотрим на результаты:

Построение Android приложений шаг за шагом, часть вторая - 7

Сейчас у нас процент покрытия идеально совпадает с нашим количеством тестов, т.е 0% =) Давайте исправим эту ситуацию!

Model

В model слое нам необходимо проверить правильность настройки retrofit (ApiInterface), корректность создания клиента и работу ModelImpl.
Компоненты должны проверяться изолированно, поэтому для проверки нам нужно эмулировать сервер, в этом нам поможет MockWebServer. Настраиваем ответы сервера и проверяем запросы retrofit.

Схема Model слоя, классы требующие тестирования помечены красным

Построение Android приложений шаг за шагом, часть вторая - 8

Тестовый модуль для Dagger 2

@Module
public class ModelTestModule {

   @Provides
   @Singleton
   ApiInterface provideApiInterface() {
       return mock(ApiInterface.class);
   }

   @Provides
   @Singleton
   @Named(Const.UI_THREAD)
   Scheduler provideSchedulerUI() {
       return Schedulers.immediate();
   }

   @Provides
   @Singleton
   @Named(Const.IO_THREAD)
   Scheduler provideSchedulerIO() {
       return Schedulers.immediate();
   }
}

Примеры тестов

public class ApiInterfaceTest extends BaseTest {

   private MockWebServer server;
   private ApiInterface apiInterface;

   @Before
   public void setUp() throws Exception {
       super.setUp();
       server = new MockWebServer();
       server.start();
       final Dispatcher dispatcher = new Dispatcher() {

           @Override
           public MockResponse dispatch(RecordedRequest request) throws InterruptedException {

               if (request.getPath().equals("/users/" + TestConst.TEST_OWNER + "/repos")) {
                   return new MockResponse().setResponseCode(200)
                           .setBody(testUtils.readString("json/repos"));
               } else if (request.getPath().equals("/repos/" + TestConst.TEST_OWNER + "/" + TestConst.TEST_REPO + "/branches")) {
                   return new MockResponse().setResponseCode(200)
                           .setBody(testUtils.readString("json/branches"));
               } else if (request.getPath().equals("/repos/" + TestConst.TEST_OWNER + "/" + TestConst.TEST_REPO + "/contributors")) {
                   return new MockResponse().setResponseCode(200)
                           .setBody(testUtils.readString("json/contributors"));
               }
               return new MockResponse().setResponseCode(404);
           }
       };

       server.setDispatcher(dispatcher);
       HttpUrl baseUrl = server.url("/");
       apiInterface = ApiModule.getApiInterface(baseUrl.toString());
   }


   @Test
   public void testGetRepositories() throws Exception {

       TestSubscriber<List<RepositoryDTO>> testSubscriber = new TestSubscriber<>();
       apiInterface.getRepositories(TestConst.TEST_OWNER).subscribe(testSubscriber);

       testSubscriber.assertNoErrors();
       testSubscriber.assertValueCount(1);

       List<RepositoryDTO> actual = testSubscriber.getOnNextEvents().get(0);

       assertEquals(7, actual.size());
       assertEquals("Android-Rate", actual.get(0).getName());
       assertEquals("andrey7mel/Android-Rate", actual.get(0).getFullName());
       assertEquals(26314692, actual.get(0).getId());
   }

  @After
    public void tearDown() throws Exception {
        server.shutdown();
    }
}

Для проверки модели мокаем ApiInterface и проверяем корректность работы.

Пример тестов для ModelImpl

@Test
public void testGetRepoBranches() {

   BranchDTO[] branchDTOs = testUtils.getGson().fromJson(testUtils.readString("json/branches"), BranchDTO[].class);

   when(apiInterface.getBranches(TestConst.TEST_OWNER, TestConst.TEST_REPO)).thenReturn(Observable.just(Arrays.asList(branchDTOs)));

   TestSubscriber<List<BranchDTO>> testSubscriber = new TestSubscriber<>();
   model.getRepoBranches(TestConst.TEST_OWNER, TestConst.TEST_REPO).subscribe(testSubscriber);

   testSubscriber.assertNoErrors();
   testSubscriber.assertValueCount(1);

   List<BranchDTO> actual = testSubscriber.getOnNextEvents().get(0);

   assertEquals(3, actual.size());
   assertEquals("QuickStart", actual.get(0).getName());
   assertEquals("94870e23f1cfafe7201bf82985b61188f650b245", actual.get(0).getCommit().getSha());
}

Проверим покрытие в Jacoco:

Построение Android приложений шаг за шагом, часть вторая - 9

Presenter

В presenter слое нам необходимо протестировать работу мапперов и работу презентеров.

Схема Presenter слоя, классы требующие тестирования помечены красным

Построение Android приложений шаг за шагом, часть вторая - 10

С мапперами все достаточно просто. Считываем json из файлов, преобразуем и проверяем.
С презентерами — мокаем model и проверяем вызовы необходимых методов у view. Также необходимо проверить корректность onSubscribe и onStop, для этого перехватываем подписку (Subscription) и проверяем isUnsubscribed

Пример тестов в presenter слое

    @Before
    public void setUp() throws Exception {
        super.setUp();
        component.inject(this);

        activityCallback = mock(ActivityCallback.class);

        mockView = mock(RepoListView.class);
        repoListPresenter = new RepoListPresenter(mockView, activityCallback);

        doAnswer(invocation -> Observable.just(repositoryDTOs))
                .when(model)
                .getRepoList(TestConst.TEST_OWNER);

        doAnswer(invocation -> TestConst.TEST_OWNER)
                .when(mockView)
                .getUserName();
    }


    @Test
    public void testLoadData() {
        repoListPresenter.onCreateView(null);
        repoListPresenter.onSearchButtonClick();
        repoListPresenter.onStop();

        verify(mockView).showRepoList(repoList);
    }

    @Test
    public void testSubscribe() {
        repoListPresenter = spy(new RepoListPresenter(mockView, activityCallback)); //for ArgumentCaptor
        repoListPresenter.onCreateView(null);
        repoListPresenter.onSearchButtonClick();
        repoListPresenter.onStop();

        ArgumentCaptor<Subscription> captor = ArgumentCaptor.forClass(Subscription.class);
        verify(repoListPresenter).addSubscription(captor.capture());
        List<Subscription> subscriptions = captor.getAllValues();
        assertEquals(1, subscriptions.size());
        assertTrue(subscriptions.get(0).isUnsubscribed());
    }

Смотрим изменение в JaCoCo:

Построение Android приложений шаг за шагом, часть вторая - 11

View

При тестирование View слоя, нам необходимо проверить только вызовы методов жизненного цикла презентера из фрагмента. Вся логика содержится в презентерах.

Схема View слоя, классы требующие тестирования помечены красным

Построение Android приложений шаг за шагом, часть вторая - 12

Пример тестирования фрагмента

@Test
public void testOnCreateViewWithBundle() {
   repoInfoFragment.onCreateView(LayoutInflater.from(activity), (ViewGroup) activity.findViewById(R.id.container), bundle);
   verify(repoInfoPresenter).onCreateView(bundle);
}

@Test
public void testOnStop() {
   repoInfoFragment.onStop();
   verify(repoInfoPresenter).onStop();
}

@Test
public void testOnSaveInstanceState() {
   repoInfoFragment.onSaveInstanceState(null);
   verify(repoInfoPresenter).onSaveInstanceState(null);
}

Финальное покрытие тестами:

Построение Android приложений шаг за шагом, часть вторая - 13

Заключение или to be continued…

Во второй части статьи мы рассмотрели внедрение Dagger 2 и покрыли код unit тестами. Благодаря использованию MVP и подмене инжекций мы смогли быстро написать тесты на все части приложения. Весь код доступен на github. Статья написана при активном участии nnesterov. В следующей части рассмотрим интеграционное и функциональное тестирование, а также поговорим про TDD.

Автор: Rambler&Co

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js