В первой части статьи мы разработали приложение для работы с 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:
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 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).
@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 разобрался с графом зависимостей.
@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 будем предоставлять через модули, мапперы — с помощью аннотированного конструктора.
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
C 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 тестами, мы будем понимать проверку работы одного модуля окруженного моками. Взаимодействие нескольких реальных модулей будем проверять в интеграционных тестах.
Схема взаимодействия модулей:
Пример тестирования маппера (серые модули — не используются, зеленые — моки, синий — тестируемый модуль):
Инструменты и фреймворки повышают удобство написания и поддержки тестов. 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, которая генерирует отчеты по результатам выполнения тестов.
Современная Android Studio поддерживает code coverage из коробки или можно настроить его, добавив в 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.
@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 и проверяем корректность работы.
@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 слое нам необходимо протестировать работу мапперов и работу презентеров.
С мапперами все достаточно просто. Считываем json из файлов, преобразуем и проверяем.
С презентерами — мокаем model и проверяем вызовы необходимых методов у view. Также необходимо проверить корректность onSubscribe и onStop, для этого перехватываем подписку (Subscription) и проверяем isUnsubscribed
@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 слоя, нам необходимо проверить только вызовы методов жизненного цикла презентера из фрагмента. Вся логика содержится в презентерах.
@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.
ссылка на оригинал статьи https://habrahabr.ru/post/277343/
Добавить комментарий