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



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

    Содержание:


    Введение


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

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


    Диаграмма классов


    Все исходники вы можете найти на 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


    Сами объекты для внедрения 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) тестированием.

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

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

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



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



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

    Инструменты и фреймворки повышают удобство написания и поддержки тестов. 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) и посмотрим на результаты:



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

    Model

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

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


    Тестовый модуль для 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:



    Presenter

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

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


    С мапперами все достаточно просто. Считываем 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:



    View

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

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


    Пример тестирования фрагмента
    @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);
    }
    


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



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


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

    UPDATE
    Построение Android приложений шаг за шагом, часть третья
    Rambler Group
    81,00
    Компания
    Поделиться публикацией

    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое