Построение 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 на примере DaggerDagger 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> {
@Inject
public RepoBranchesMapper() {
}
@Override
public List call(List branchDTOs) {
List 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 тестами, мы будем понимать проверку работы одного модуля окруженного моками. Взаимодействие нескольких реальных модулей будем проверять в интеграционных тестах.
Схема взаимодействия модулей:
Пример тестирования маппера (серые модули — не используются, зеленые — моки, синий — тестируемый модуль):
Инфраструктура
Инструменты и фреймворки повышают удобство написания и поддержки тестов. 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 следующие строки:
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> testSubscriber = new TestSubscriber<>();
apiInterface.getRepositories(TestConst.TEST_OWNER).subscribe(testSubscriber);
testSubscriber.assertNoErrors();
testSubscriber.assertValueCount(1);
List 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> testSubscriber = new TestSubscriber<>();
model.getRepoBranches(TestConst.TEST_OWNER, TestConst.TEST_REPO).subscribe(testSubscriber);
testSubscriber.assertNoErrors();
testSubscriber.assertValueCount(1);
List 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 captor = ArgumentCaptor.forClass(Subscription.class);
verify(repoListPresenter).addSubscription(captor.capture());
List 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.