Pull to refresh

Шаблоны проектирования при разработке под Android. Часть 3 — Пользовательский интерфейс, тестирование, AndroidMock

Reading time 11 min
Views 13K
В прошлой статье я рассказал, что такое MVP и как надо организовать процесс разработки приложений с использованием MVP. Теперь же я покажу как я разрабатывал свой T-Alarm.

Сначала я сделал представление и presenter, как описано в прошлой статье.

Представление (View)


Естественно, что мое представление это наследник класса Activity, точнее RoboActivity, что это такое я вкратце сейчас расскажу. Ниже показан очень характерный кусок исходников для окна редактирования настроек будильника:

public class AlarmEdit
			extends RoboActivity
			implements IAlarmEdit
{
	@InjectView(R.id.ae_et_AlarmName) EditText etName;
	@Inject AlarmEditPresenter presenter;

	@InjectView(R.id.ae_btn_Save)	Button btnSave;
	@InjectView(R.id.ae_btn_Cancel)	Button btnCancel;

	@Override
	public void onCreate(Bundle savedInstanceState)
	{
		super.onCreate(savedInstanceState);
		setContentView(R.layout.alarm_edit);
		
		presenter.onCreate(this);
		
		btnSave.setOnClickListener(this);
		btnCancel.setOnClickListener(this);
	}

	@Override
	public void onClick(View btn)
	{
		AlarmEdit.ClickSourceItem clickItem = AlarmEdit.ClickSourceItem.CANCEL; 
		
		switch (btn.getId())
		{
		case R.id.ae_btn_Save: 
			clickItem = AlarmEdit.ClickSourceItem.SAVE;
			break;
		case R.id.ae_btn_Cancel:
			clickItem = AlarmEdit.ClickSourceItem.CANCEL;
			break;
		}

		presenter.onClick(clickItem);
	}

	@Override
	public String getAlarmName()
	{
		return etName.getText().toString();
	}
	@Override
	public void setAlarmName(String alarmName)
	{
		etName.setText(alarmName);
	}

	@Override
	public Bundle getViewBundle()
	{
		return getIntent().getExtras();
	}
}

Итак, приступим к рассмотрению этого примера.
Начнем с того, что такое RoboActivity и зачем она здесь нужна. RoboActivity входит в состав RoboGuice. RoboGuice это Dependency Injection Framework для Android.
подробнее с RoboGuice можно ознакомиться на сайте проекта:
code.google.com/p/roboguice/wiki/SimpleExample?tm=6

В этом классе я использую RoboGuice чтобы избежать в методе onCreate занудств вроде:
etName = (EditText) findViewById(R.id.ae_et_AlarmName); 

RoboGuice делает это все за меня при создании активности обрабатывая строки с тегами Inject..., например:
@InjectView(R.id.ae_et_AlarmName) EditText etName;

Я даю название ресурсам по следующей схеме:
<сокр. название активности>_<сокр. название типа контрола>_<Название контрола>
Так проще ориентировать среди большой кучи идентификаторов ресурсов, которые из всего приложения свалены в одно пространство имен «R.id».

Также я создаю здесь Presenter с помощью RoboGuice через следующее объявление:
@Inject AlarmEditPresenter presenter;

На самом деле у RoboGuice есть еще несколько применений, которые я покажу дальше.

Таким образом на момент выполнения метода onCreate() все поля в моем классе уже проинициализированны и мне не надо отдельно заморачиваться с их инициализацией, мой код выглядит симпатичнее.

В методе onCreate() я только регистрирую текущий класс как OnClickListener и передаю в presenter ссылку на этот класс представления, чтобы в дальнейшем presenter взял на себя управление свойствами представления. Благодаря чему и получается Inversion of Control, когда бизнес-логика в presenter-е управляет представлением.
Я это делаю для тестирования бизнес-логики, которая сосредоточена в отдельном классе presenter, который слабо зависит от других звеньев приложения. Дальше я покажу как при тестировании я выдираю presenter из окружения программы, подключаю вместо других компонент различные заглушки и имитации и провожу тесты над presenter-ом.

В методе onClick() показано что все команды, которые представление получает от пользователя передаются в presenter. Здесь я только преобразую идентификаторы кнопок к командам, чтобы при отладки видеть названия команд, а не числа соответствующие ресурсам в классе R.id, да и вообще не стоит привносить в presenter всякие штуки из представления.

Здесь представление напрямую общается с presenter-ом, без использования интерфейса. Я сделал так потому, что у меня нет необходимости заменять presenter имитацией. Я не тестирую представление в модульных тестах.

Методы setAlarmName() и getAlarmName() используются presenter-ом, чтобы выводить и получать название будильника в окно редактирования.

Метод getViewBundle() нужен чтобы получить ID редактируемого будильника, который я передаю через Intent. Сам будильник достается из модели, которую загружает репозиторий, при этом в модель загружаются сразу все будильники, потому что их не много. Модель загружается еще в первой активности программы, там есть список будильников и мне надо уже тогда иметь модель со всеми будильниками.
Благодаря использованию Roboguice у меня есть Singleton модели. То есть во всем приложении у меня один экземпляр модели и мне не надо передавать его из активности в активность. Presenter каждой активности получает модель из Roboguice:
@Inject IAlarmListModel alarmListModel;

Поскольку Singleton реализован не через статический класс, а через RoboGuice, я могу заменять модель в тестах на нужную мне имитацию.

Код, представленный здесь и в интерфейсе это и есть весь дополнительный код, который мне пришлось написать воспользовавшись MVP. Весь остальной код, который находится в presenter-е надо написать в любом случае, потому что он содержит необходимую бизнес-логику.
Другими словами, плата за MVP составляет несколько строк маппинга, это не много по сравнению с тем, что мы получаем при использовании MVP.

Интефрейс IAlarmEdit


Интерфейс предельно прост и просто содержит методы для того, чтобы presenter мог общаться с представлением, а так же чтобы заменить представление имитацией в тестах.
public interface IAlarmEdit
{
	public enum ClickSourceItem
	{
		SAVE,
		CANCEL
	}

	public abstract String getAlarmName();
	public abstract void setAlarmName(String alarmName);
	public abstract Bundle getViewBundle()
}


Как устроен Presenter


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

Поэтому в presenter-е есть экземпляр класса IModelLoader, который асинхронно вызывает репозиторий и передает ему модель для сохранения. Подробнее про асинхронное чтение/загрузку я напишу в следующих статьях.
public class AlarmEditPresenter
					implements IModelReciver
{
	@Inject public		IModelLoader		modelSaver;

	private int id;
	private AlarmEdit view;

	public void onCreate(IAlarmEdit alarmEdit)
	{
		//Сохраним ссылку на предстваление, чтобы пользоваться
		//ей в дальнейшем
		this.view = alarmEdit;
		
		//Получаем id будильника для редактирования
		Bundle bundle = view.getViewBundle();
		id = bundle.getInt(MainScreenPresenter.BUNDLE_ARG_ALARM_ID);

		//Бремм будильник на редактирование, при этом создается клон
		//редактируемого будильника, чтобы в случае отмены редактирования
		//Можно было просто его выбросить.
		alarmListModel.takeAlarmForEdit(id);

		//Выводим название будильника на форму
		view.setAlarmName(alarmListModel.getEditingAlarm().getName());

		//Инициализируем сохранятель модели, который асинхронно
		//вызывает репозиторий для сохранения изменений
		modelSaver.setReciver(this);
	}

	public void onClick(AlarmEdit.ClickSourceItem clickItem)
	{
		switch(clickItem)
		{
		case SAVE:
			//Получаем то, что ввел пользователь
			alarmListModel.getEditingAlarm().setName(view.getAlarmName());

			//Сохраняем клон будльиника в модели
			alarmListModel.updateAlarm(alarmListModel.getEditingAlarm());

			//Вызвам асинхронное сохранение модели.
			modelSaver.saveModel();
			
			//Здесь не закрываем активность.
			//Это будет сделано после сохранения модели.

			break;
		case CANCEL:
			//Просто закрываем окно без сохранения результата.
			view.setResult(Activity.RESULT_CANCELED);
			view.finish();
			break;
		}
	}

	@Override
	public void update(String event) 
	{
		if(event.equals(IModelLoader.EVENT_ALARM_MODEL_SAVE))
		{
			//получили сигнал, что модель сохранена.
			//Закрываем активность.
			view.setResult(Activity.RESULT_OK);
			view.finish();
		}
	}
}

На самом деле в этом presenter-е у меня еще много кода, но ничего нового с точки зрения MVP в нем нет, поэтому я его не стал приводить.

Отмечу только, что в presenter-е есть еще и такой член класса:
@Inject private IDateHolder dateHolder;

IDateHolder мне нужен для того, чтобы в тестах имитировать нужное мне время и локаль а в реальном приложении Roboguice подставляет класс, который возвращает системное время и локаль.
В частности, на главном окне есть текущее время — это строка, которая выводит время в текущей локали. У меня есть тесты, в которых я имитирую нужное мне время и локаль и проверяю, что в представление ушла строка в правильной локали. Благодаря этим тестам я уверен в том, что моя программа правильно работает в разных локалях.

Тест Presenter-а


Благодаря тому, что в presenter-е все связи с остальными компонентами осуществляются через интерфейсы, я могу имитировать эти интерфейсы в тестах. Чем я сейчас и займусь.

В этом тесте у меня есть задача: проверить AlarmEditPresenter. Мне надо проверить, что он забирает ID из формы, берет нужный будильник из модели и правильно выводит его название в контрол редактирования.
public class TestAlarmEditPresenter extends RoboUnitTestCase<TestApplication>
{
	public class AlarmEditModule extends AbstractAndroidModule 
	{
		//Вот здаесь мы готовим имитации для тестов presenter-а
		//Когда в тесте RoboGuice будет подставлять в presenter нужные
		//экземпляры он будет их брать в соответствии с этим объявлением
		@Override
		protected void configure() 
		{
			bind(AlarmEditPresenter.class);
			bind(IAlarmListModel.class)
					.toProvider(AlarmListModelProvider.class);
		}
	}
	
	private IAlarmListModel	model;
	
	@Override
	protected void setUp() throws Exception 
	{
		TestApplication.alarmTestModule = new AlarmEditModule();
		super.setUp();

		model = AndroidMock.createNiceMock(IAlarmListModel.class);
		AlarmListModelProvider.alarmListModel = model;

	}
	
	//Инициализация перед каждым тестом
	private void initializeTest()
	{
		AndroidMock.reset(model);
	}
	
	
	/**
	 * Проверка что при открытии окна данные правильно отобразятся во View
	 * @throws Exception
	 */
	@MediumTest
	public void testAlarmEditOpen() throws Exception
	{
		initializeTest();
		
		AlarmItem alarm = new AlarmItem();
		alarm.setName("Test Name");

		//Ожидается, что будильник 2 будет склонирован для редактирования
		model.takeAlarmForEdit(2);
		//Возвращаем подготовленный будильник, когда его запросит presenter
		AndroidMock.expect(model.getEditingAlarm()).andStubReturn(alarm);
		AndroidMock.replay(model);

		//Имитируем что активность получила Intent в котором 
		//указано, что надо редактировать будильник 2
		Bundle bundle = new Bundle();
		bundle.putInt(MainScreenPresenter.BUNDLE_ARG_ALARM_ID, 2);
		
		AlarmEdit view = AndroidMock.createMock(AlarmEdit.class);
		AndroidMock.expect(view.getViewBundle()).andStubReturn(bundle);
		//Ожидаем название которое должно уйти во View
		view.setAlarmName("Test Name");
		AndroidMock.replay(view);
		
		//Созадем тестируемый presenter
		AlarmEditPresenter presenter = getInjector().getInstance(AlarmEditPresenter.class);
		//Открываем форму
		presenter.onCreate(view);

		//Проверим, что в предстваление ушли правильные строки, как и ожидались
		AndroidMock.verify(view);
		//Проверяем, что нужный будильник был взят на редактирование
		AndroidMock.verify(model);
	}
}

По поводу вложенного класса AlarmEditModule и его метода configure() читайте в следующих статьях там я подробнее рассмотрю использование RoboGuice.

В методе setUp() я создаю имитацию и кладу ее в провайдер. Вообще зачем нужен провайдер: с его помощью я решаю следующую задачу. При инициализации полей помеченных атрибутом Inject надо подставить необходимые имитации. RoboGuice по типу поля найдет, что надо использовать такой-то провайдер (см. метод configure()), через который и подставит нужную имитацию.

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

В самом же тесте я сначала готовлю имитации:
Создаю будильник и говорю имитации модели, что если presenter просит редактируемый будильник, то верни ему этот подготовленный будильник:
AndroidMock.expect(view.getViewBundle()).andStubReturn(bundle);

Так же я указываю, что ожидаю определенных вызовов:
//Ожидается, что будильник 2 будет склонирован для редактирования
model.takeAlarmForEdit(2);
.....
//Ожидаем название которое должно уйти во View
view.setAlarmName("Test Name");

После этого, через RoboGuice создается экземпляр класса AlarmEditPresenter. Обязательно надо создавать через RoboGuice, чтобы он заполнить все поля помеченные атрибутом Inject нужными значениями.
Дальше я вызываю метод presenter.onCreate(view) в котором и происходит загрузка будильника и передача его названия в представление.

И в конце концов я проверяю, что presenter сделал все нужные вызовы:

//Проверим, что в предстваление ушли правильные строки, как и ожидались
AndroidMock.verify(view);
//Проверяем, что нужный будильник был взят на редактирование
AndroidMock.verify(model);

Чтобы прояснить оствшиеся детали, я расскажу про:

AndroidMock


Это Framework для создания имитаций. Почитать про него подробнее можно здесь:
code.google.com/p/android-mock

Здесь я поясню что же вообще такое AndroidMock и какие особенности его использования я обнаружил в своем проекте.

Все Mock Framework-и во всех языках предназначены для следующего:

Во-первых, сделать имитацию заданного интерфейса или класса. Имитация это экземпляр интерфейса или класса и ее можно передавать во все методы, где требуется экземпляр оригинального интерфейса или класса. У имитации сразу после создания есть все поля, хоть и со значениями 0 или null в зависимости от типа, а так же можно вызвать любой метод, который тоже 0 или null в зависимости от типа.

Если не важно что вызывается в имитации, или имитация просто не используется в тесте, но тестируемый класс без имитации не создается, то в таком случае в одну строчку создается пустая имитация и передается в создаваемый класс.

Во-вторых. Для имитации можно в одну строку задать, что если произойдет вызов такого-то метода, то верни такое-то значение. В приведенном выше примере это строчка:
AndroidMock.expect(view.getViewBundle()).andStubReturn(bundle);

Обычно эта возможность имитаций используется, когда класс, реализующий интерфейс еще не готов, или когда метод готов, но чтобы он правильно сработал надо его долго готовить. И чтобы не заморачиваться с подготовкой данных для метода, который, обычно, даже не является предметом теста, проще просто имитировать возвращаемый им результат.
Кстати если бы у меня ожидалось два вызова, и они должны были бы вернуть разные результаты я бы написал:
AndroidMock.expect(view.getViewBundle()).andReturn(bundle1);
AndroidMock.expect(view.getViewBundle()).andReturn(bundle2);

В-третьих. После выполнения теста для имитации можно проверить, что были выполнены все нужные вызовы, а не нужных не было.
В моем примере это строка:
AndroidMock.verify(view);

Она проверяет. что все ожидания, которые были описаны до строки
AndroidMock.replay(view);

выполнились.

Надо сказать что проверяется не только факт вызова метода, но то, что метод вызван с правильными аргументами:
AndroidMock.expect(view.getViewBundle(1)).andReturn(bundle1);
AndroidMock.expect(view.getViewBundle(2)).andReturn(bundle2);

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

При создании имитации есть три метода:
AndroidMock.createNiceMock() — Создает слабую имитацию. При проверке не будет учитываться то, были вызваны лишние методы. Будет проверяться только то, что были вызваны только объявленные методы. Обычно используется когда надо отследить что были вызваны правильные методы, а если были лишние то не страшно.
В тех случаях, когда имитация нужна только для создания класса, как раз используют слабую имитацию и даже не вызывают проверку после теста.

AndroidMock.createMock() — Создает обычную имитацию. Для проверки того, что все нужные методы были вызваны, а ненужные методы не были вызваны. При этом будет проверятся количество вызовов. Обычно одна строка при описании ожиданий это один вызов. Но если использовать метод andStubReturn(), то это много вызовов. Так же можно казать конкретное количество вызовов: andReturn().times(3);

AndroidMock.createStrcitMock() — Создает строгую имитацию. в таком случае проверяется даже порядок вызовов методов.

Подробную справочную информацию вы найдете на страницах проектов AndroidMock и EasyMock.

К сожалению AndroidMock обладает серьезными недостатками.
Объявлено, что он может имитировать встроенные классы, например MediaPlayer, но это было давно и с последними версиями Android SDK это не работает.
Приходится делать свой интерфейс, который содержит все нужные методы из основного класса и делать как-бы View, который содержит экземпляр реального MediaPlayer и мапит все вызовы из интерфейса на методы реального класса.
В тестах я имитирую созданный интерфейс.

Поэтому я собираюсь поискать другой Framework для создания имитаций.

Читайте в других статьях


Введение
MVP и Unit tests. Путь Джедая
— Пользовательский интерфейс, тестирование, AndroidMock
Сохранение данных. Domain Model, Repository, Singleton и BDD
— Реализация серверной части, RoboGuice, тестирование
— Небольшие задачи, настройки, логирование, ProGuard
Tags:
Hubs:
+6
Comments 2
Comments Comments 2

Articles