Pull to refresh

Шаблоны проектирования при разработке под Android. Часть 2 — MVP и Unit tests. Путь Джедая

Reading time 9 min
Views 41K
По началу я хотел только кратко рассказать что такое MVP, но кратко не получилось. Поэтому я выделил этот кусок в отдельную статью, которая мало относится к Android, но очень важна для понимания MVP и модульных тестов. Обещанные же статьи никуда не денутся.

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

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


Что такое Model View Presenter (MVP)


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

Вроде понятно что должен делать тест: надо проверить, что после того как пользователь увидел форму, заполнил поля правильными данными и нажал «Сохранить», в обработчике этой кнопки, данные с формы проверились и попали в базу данных, или выдалось бы сообщение об ошибке пользователю.
Все примеры, которые есть в документациях для разных языков построены именно по этому простому принципу. Поэтому это весьма распространенное проектное решение.

Но тут появляется первая проблема, поскольку логика по проверке правильности полей и сохранению их в БД лежит в обработчике кнопки «Сохранить». Как в тесте проверить нажатие кнопки «Сохранить» на форме? Ведь в тесте нельзя создать экземпляр класса «форма», чаще всего потому что у этого класса нет публичного конструктора, другими словами форма создается специальными классами, которых в тестах нет, и которые, в свою очередь, тоже нельзя создать.

MVP позволяет красиво решить упомянутую проблему. Суть в том, что надо перенести весь код из обработчика кнопки сохранить в другой класс, который можно будет легко создавать. Этот класс называет presenter. А в форме, которая теперь называется View или представление, в обработчике кнопки «сохранить» останется только одна строчка, которая вызовет одноименный метод в presenter-е.
Обычно presenter создается в конструкторе представления и presenter-у передается ссылка на представление.

Далее в тесте мы создаем экземпляр presenter-а и имитируем вызов метода «сохранить» у presenter-а. Дальше мы ожидаем, что presenter заберет из представления значения полей формы, проверит их и передаст их в БД. Получается что presenter-у в тесте все равно нужна форма, да и еще БД в виде какого-то класса для работы с БД.

Форму и класс для работы с БД мы в тесте создать не можем, но можем сделать следующий финт ушами: пусть presenter, при создании, получает ссылки не на представление и класс для работы с БД, на их интерфейсы. То есть надо создать интерфейсы, один для представления, а другой для БД. При обычной работе под этими интерфейсами будут скрываться настоящие предстваление и БД, а в тестах мы сделаем классы заглушки, которые тоже будут поддерживать эти интерфейсы. В тестах мы создадим заглушку для представления, которая будет возвращать фиксированные значения для полей формы. А заглушка для БД будет просто сохранять те значения, которые ей пришли, чтобы потом мы могли проверить эти значения.

И теперь в тесте:
1. Создаем класс заглушку для представления и прописываем какие значения полей она будет возвращать.
2. Создаем класс заглушку для БД, который просто сохранит значения переданные ему при вызове метода «SaveToDB»
3. Создаем presenter и передаем ему созданные заглушки.
4. Вызываем у presenter метод «сохранить»
5. проверяем что в заглушке БД появились нужные значения, иначе в presenter-е ошибка.

Итак, зачем нужен MVP и какая от него польза.
MVP это шаблон проектирования, который позволяет отделить код с бизнес-логикой по обработке данных от кода с отображением данных на форме.
Бизнес-логика в presenter-е тестируется при каждом выпуске новой версии. Что позволяет говорить, что нет сложных ошибок в программе.
Код в представлении остается не протестирован, но эти ошибки легко обнаруживаются еще разработчиками, когда они видят, что кнопка «Сохранить» ничего не делает, и легко исправляются, поскольку сразу понятно, что в обработчике кнопки забыли вызвать метод presenter-а.

Внимательный читатель на этом месте воскликнет «Да вы охренели! Раньше у меня был один класс который умещался в одном файле и в нем было удобно все отлаживать, а теперь мне надо добавить класс presenter, интерфейсы, две заглушки да и еще сам тест писать надо! Это займет у меня в два раза больше времени. Если начальство прикажет, то я, так и быть сделаю пару тестов, после того как напишу код, но я снимаю с себя ответственность за срыв сроков»

«Успокойся, о юный падаван»


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

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

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

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

В качестве примера возьмем ту же форму, где надо ввести данные, нажать кнопку «Сохранить», проверить данные, если есть ошибки, то показать их пользователю, если нет, то отправить их в БД.

Создание представления и заготовки для presenter-а


Первым делом надо создать форму (представление), в случае Android это наследник класса Activity. Надо поместить на нее контролы для ввода значений, вывода сообщения об ошибке и кнопку «Сохранить» с обработчиком.

Дальше надо создать интерфейс для представления. Интерфейс нужен чтобы presenter мог выводить данные на форму и забирать данные из формы, поэтому в интерфейсе должны быть методы вроде setName(string name) и string getName(). Естественно, что представление должно реализовывать этот интерфейс и в методах должно просто перекладывать значение из аргумента в свойство Text используемого контрола или обратно в случае метода getName.

Дальше надо создать presenter, который на вход получает ссылку на экземпляр интерфейса. Пока что в конструкторе presenter-а просто проставим произвольные значения на форме, чтобы проверить что presenter имеет полный доступ к форме.
Так же создадим метод «сохранить», в котором в локальные переменные получим текущие значения из полей представления и поставим в него Break Point, чтобы убедиться, что этот метод вызывается из представления.

В конструкторе представления создадим экземплят presenter-а и передадим ему ссылку на это представление. В представлении в методе обработчике кнопки «Сохранить» просто проставим вызов соответствующего метода в presenter-е.

Вот пример представления
public class View extends Activity implements IView
{
	Presenter presenter;
	public View(Bundle savedInstanceState)
	{
		presenter = new Presenter(this);
	}

	public void setName(String name)
	{
		tvName.setText(name);
	}

	public void getName()
	{
		return tvName.getText();
	}

	public void onSave(View v)
	{
		presenter.onSave();
	}
}

Вот пример presenter-а

public class presenter
{
	IView view;
	public presenter(IView view)
	{
		this.view = view;
		view.setName("example name");
	}

	public onSave()
	{
		string name = view.getName();
	}
}

Код для первого этапа готов.
Надо запустить программу в отладчике и убедиться что при открытии формы отображается правильное название а если ввести новое название и нажать «Сохранить», то presenter получит правильное значение.

На первом этапе нет никаких модульных тестов и особой экономии времени тоже нет, но и затраты не велики.

«Закрой глаза, о юный падаван»


Вот теперь начинается самое интересное. Дело в том, что при дальнейшем написании кода нам не надо запускать приложение, что бы смотреть как работает форма. Весь код мы будет развивать и отлаживать в тестах. Мы будем писать код как бы с «закрытыми глазами», как настоящие джедаи.

Секрет в том, что надо дописывать presenter маленькими кусочками и для проверки этого кусочка дописывать кусочек теста.

Наполним presenter функционалом. при создании он должен прочитать название из БД и передать его в представление. А при сохранении получить название из предстваления и сохранить его в БД.

public class presenter
{
	IView view;
	IDataBase dataBase;
	int Id;
	public presenter(IView view, IDataBase dataBase)
	{
		this.view = view;
		this.dataBase = dataDase;
		id = view.getId();
		string name = dataBase.loadFromDB(id);
		view.setName(name);
	}

	public onSave()
	{
		string name = view.getName();
		dataBase.saveToDB(id, name);
	}
}

Понятно, что при этом надо добавить в интерфейс и в представление новый метод для получения ID, но пока что мы даже не будем «открывать глаза», то есть запускать приложение, чтобы проверить представление. Это сделаем потом, и если будет ошибка, мы ее в секунду исправим.

Итак мы написали кусочек представления и теперь напишем кусочек теста.

Те заглушки, про которые я писал в первой части, необязательно создавать в виде классов, которые поддерживают интерфейс. Если различные Mock Framework-и, которые позволяют за три строчки создать создать экземпляр класса, который поддерживает заданный интерфейс и даже возвращает нужные значения при вызове его методов. Эти заглушки называются имитациями (перевод слова Mock), потому что они гораздо навороченне чем просто заглушки.

Пример метода теста

public void testOpenAndSave()
{
	IView view = createMock(IView.class);	//создаем имитацию для представления
	IDataBase dataBase = CreateMock(IDataBase.class);//создаем имитацию для БД
	
	expect(view.getId()).andReturn(1); //Когда presenter спросит что прочитать из базы вернем id 1
	expect(dataBase.LoadFromDB(1)).andReturn("source name"); //Когда presenter попытается загрузить название, вернем "source name"
	expect(view.setName("source name")); //ожидаем что правильная строка попадает на форму
	
	expect(view.getName()).andReturn("destination name"); //Когда presenter  при сохранении спросит, что ввел пользователь, вернем "destination name".
	expect(dataBase.SaveToDB(1, "destination name")); //Ожидаем, что в БД уйдет именно это название.
	
	Presenter presenter = new Presenter(view, dataBase); //Передаем в presenter имитации для представления и базы данных
	presenter.Save();//Имитируем нажатие кнопки "Сохранить";

	//проверим, что все нужные методы были вызваны с нужными аргументами
	verify(view);
	verify(dataBase);
}

Вот только не надо стонать, что кода в тесте столько же сколько и в presenter-е. Оно того стоит.

Запускаем тест и видим что все хорошо, или делаем так чтобы все стало хорошо.

При таком процессе разработки тест не является чем-то чужеродным по отношению к коду. Тест это часть кода, которую должен выдать разработчик при реализации задачи. Тест нужен, чтобы убедиться, что написанный код работает правильно.

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

А вот и прибыль


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

Прибыль появляется пр дальнейшей разработке presenter-а.

Дальше надо добавить проверку данных с формы в метод «сохаранить». А в тесте надо проверить что правильные значения сохраняются в БД, а на неправильные выводится сообщение об ошибке.

Для этого добавляем необходимый код в presenter, чтобы проверить правильность названия и если оно неправильное, то вызываем метод setErrorText(), и не вызываем сохранение в БД.

Дальше просто копируем тест и правим имитацию для представления. Она теперь должна возвращать неправильное название при вызове getName() и ожидать вызов метода setErrorText() с правильным аргументом, таким образом будет проверено что и сообщение об ошибке правильное. А имитация для БД должна проверять что метод SaveToDB не был ни разу вызван, точнее вызван 0 раз.

Запускаем все тесты видим, новый тест работает нормально. А вот прошлый не проходит. Бъём себя по лбу за глупую ошибку и быстро исправляем ее.
И теперь оба теста работают.

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

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

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

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

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

Джедаем стать не просто.


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

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

Полагаю в интернете можно найти много таких приемчиков которые позволят вам стать настоящим мастер-джедаем.

Главное что вы уже встали на путь джедая и знаете как по нему идти :)

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


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

Articles