Доброго времени суток, читатели хабра! Сегодня мы вместе с вами потестируем Recyclerview на Android: на мой взгляд, эта тема довольно интересна.



Что такое Recyclerview? Это компонент, с помощью которого создаются списки. Каждый список можно прокручивать, добавлять в него элементы, удалять их или изменять. Элементом выступает любая функциональная единица. Например, составляем список пользователей с полем для ввода комментария и кнопкой. Как только комментарий введён и кнопка нажата, он отправляется на сервер. Теперь система может модернизировать или удалить элемент.
Элементы могут содержать много контролов (таких, как кнопки), поэтому все возможности элементов необходимо покрывать тестами. Сейчас я поделюсь с вами полезными утилитами для Recyclerview.

Пример


В качестве примера возьмём простое приложение, отображающее список животных. Данные списка представляют собой массив однотипных объектов. Итак, что мы видим?

[
...
{
"type": "PREDATOR",
"name": "Тигр",
"image_url": "https://www.myplanet-ua.com/wp-content/uploads/2017/05/%D0%B2%D0%B8%D0%B4%D1%8B-%D1%82%D0%B8%D0%B3%D1%80%D0%BE%D0%B2.jpg"
},
{
"type": "HERBIVORE",
"name": "Суслик",
"image_url": "https://cs2.livemaster.ru/storage/b2/40/b9d72365ffc02131ea60420cdc0s--kukly-i-igrushki-suslik-bejli-igrushka-iz-shersti.jpg"
},
....
]

Элемент состоит из:

  1. Разновидности животного — PREDATOR (хищник) и HERBIVORE (травоядное). В зависимости от неё животному присваивается та или иная иконка.
  2. Названия животного (тигр, суслик и т.д.).
  3. Ссылки на изображение животного.

Пользователи видят следующее:


При нажатии на кнопку «Удалить» картинка пропадает из списка. При нажатии на само животное открывается более подробное его описание.

Переходим к тестированию:

  1. Посмотрим, как происходит удаление элемента.
  2. Проверим, соответствует ли иконка разновидности животного (зелёный смайлик – травоядное, красный смайлик – хищник).
  3. Проверим, соответствует ли название заданному.

Для удобства помечаю тестируемые области на изображении ниже:



Итак, начинаем написание вспомогательных классов.

DrawableMatcher


Первым важным вспомогательным классом будет проверка на соответствие изображения с локальным ресурсом. Соответствие поля type (ответ от сервера) и названия локального ресурса следующее:

PREDATOR — ic_sentiment_very_dissatisfied_red_24dp
HERBIVORE — ic_sentiment_very_satisfied_green_24dp

Пример: в нашей задаче мы уверены, что капибара – травоядное животное, и поэтому в элементе imageView с идентификатором ivAnimalType, должно устанавливаться значение ic_sentiment_very_satisfied_green_24dp.

По сути, необходимо проверить соответствие установленного ресурса в зависимости от разновидности животного. Так как для этого я не нашел стандартных средств, пришлось написать свой Matcher, который проверяет свойства background у элемента imageView на соответствие с переданным идентификатором.

Небольшая оговорка


Matcher чаще всего используется для проверки специфичных свойств пользовательского представления на соответствие передаваемых параметров.

Пример: мы написали собственное пользовательское представление, изображающее дверь. Она может находиться в двух состояниях: открытом и закрытом. Для проверки манипуляций с дверью мы можем написать собственный Matcher. Условившись, что после того, как ручка повернута, дверь из закрытого состояния переходит в открытое, в нашем тесте мы дергаем ручку и, применяя собственный matcher, проверяем, что она действительно открыта.

Для написания подобного рода matcher’ов я использую стандартный класс пакета org.hamcrest — TypeSafeMatcher. Более подробно с ним можно ознакомиться тут

Данный класс является generic классом с типом пользовательского представления (imageView, view или другие). Он предоставляет 2 основных метода:

  1. matchesSafely — корневой метод, в котором проходит проверка соответствия. Сюда параметром передается объект типа generic класса.
  2. describeTo — функция для возможности объекта описывать самого себя. Вызывается при ненайденном соответствии и, желательно, содержит название того, что мы пытаемся найти, и то, что мы встретили по факту.

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

public DrawableMatcher(final int resourceId) {
    super(View.class);
    mResDrawableId = resourceId;
}

Метод описания очень удобен при отладке теста. Например, при несоответствии изображений он выводит сообщение о том, что установленный ресурс не соответствует проверяемому, и даёт какие-либо данные обоих ресурсов.

@Override
public void describeTo(Description description) {
	description.appendText("with drawable from resource id: ");
	description.appendValue(mResDrawableId);
	if (resourceName != null) {
		description.appendText("[");
		description.appendText(resourceName);
		description.appendText("]");
	}
}

В корневом методе проверки соответствия происходит проверка стандартного метода sameAs класса Bitmap. То есть, мы создаем bitmap по переданному идентификатору и сравниваем его с установленным в поле background.

protected boolean matchesSafely(final View target) {
	if (!(target instanceof ImageView)) {
		return false;
	}
	final ImageView imageView = (ImageView) target;
	if (mResDrawableId < 0) {
		return imageView.getBackground() == null;
	}
	final Resources resources = target.getContext().getResources();
	final Drawable expectedDrawable = resources.getDrawable(mResDrawableId);
	resourceName = resources.getResourceEntryName(mResDrawableId);
	if (expectedDrawable == null) {
		return false;
	}
	final Bitmap bitmap = getBitmap(imageView.getBackground())
	final Bitmap otherBitmap = getBitmap(expectedDrawable);
	return bitmap.sameAs(otherBitmap);
}

Вот и всё, что касается проверки соответствия изображения заданному ресурсу.

События внутренних элементов


Так как, согласно заданию, нам потребуется нажимать на кнопку “удалить”, необходимо написать дополнительную реализацию ViewAction. Мне было удобно сделать некий класс утилит под названием CustomRecyclerViewActions. Он содержит много статических методов, которые возвращают реализации ViewAction. В нашем примере мы будем использовать clickChildViewWithId.
Для начала давайте рассмотрим интерфейс ViewAction более подробно. Он состоит из следующих методов:

  1. Matcher getConstraints() — некие предусловия выполнения. Например, может потребоваться, чтобы элемент, над которым будет совершаться действие, являлся видимым.
  2. String getDescription() — описание действия вида. Требуется для создания удобного для чтения отображения сообщений в лог.
  3. void perform(UiController uiController, View view) — непосредственно само действие, которое мы будем выполнять.

Итак, давайте вернемся к написанию метода clickChildViewWithId. В параметры я передаю идентификатор пользовательского представления, над которым я буду выполнять событие click. Полную реализацию этого метода можно посмотреть
тут.
public static ViewAction clickChildViewWithId(final int id) {
	return new ViewAction() {
			
		@Override
		public Matcher<View> getConstraints() {
			return null;
		}
			
		@Override
		public String getDescription() {
			return "Нажатие на элемент по специальному id";
		}
			
		@Override
		public void perform(final UiController uiController, 
						final View view) {

			final View v = view.findViewById(id);
			v.performClick();
		}
	};
	}


Проверка количества элементов внутри адаптера


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

Интерфейс ViewAssertion представляет собой один метод:
void check(View view, NoMatchingViewException noViewFoundException);

Рассмотрим метод check более детально. Этот метод проверки пользовательских утверждений принимает 2 параметра:

  1. View — элемент пользовательского представления, над которым будет производиться утверждение (например, assertThat(0,is(view.getId())); для проверки идентификатора).
  2. NoMatchingViewException – исключение, возникающее при ситуации, когда данный сопоставитель не является элементом иерархии представлений, проще говоря — это не View. Такое исключение содержит сведения о представлении и соответствии, что очень удобно при отладке.

Реализация данного класса несложна. Полный код класса вы можете посмотреть
тут.
public class RecyclerViewItemCountAssertion implements ViewAssertion {
		
	private final int mExpectedCount;
		
	public RecyclerViewItemCountAssertion(final int expectedCount) {
		mExpectedCount = expectedCount;
	}
		
	@Override
	public void check(final View view, 
		 final NoMatchingViewException noViewFoundException) {

		if (noViewFoundException != null) {
			throw noViewFoundException;
		}
		final RecyclerView recyclerView = (RecyclerView) view;
		final RecyclerView.Adapter adapter = 
					recyclerView.getAdapter();
		assertThat(adapter.getItemCount(), is(expectedCount));
	}
}


Проверка внутренних представлений элемента


Переходим к изучению класса проверки элементов внутри списка. Этот класс я назвал RecyclerViewItemSpecificityView. Конструктор класса принимает 2 параметра: идентификатор элемента и Matcher. С идентификатором все более-менее понятно: этот элемент мы будем впоследствии проверять. А на вопрос, что именно мы собираемся проверять, отвечает второй параметр. Перейдём к примеру с животными.

Нам требуется проверить, что 6-ой элемент списка — это “капибара”, травоядное. Для того, чтобы убедиться в этом, нужно проверить поле tvName на соответствие текста “капибара”. Как можно достучаться именно до 6-го элемента? В пакете espresso.contrib есть класс RecyclerViewActions. Он помогает при тестировании RecyclerView, содержит много разных полезных методов, которые отлично комбинируются со стандартными методами библиотеки espresso. Это позволяет достичь большего процента покрытия. К сожалению, RecyclerViewUtils не поддерживает всего спектра функционала. К примеру, нельзя проверить непосредственно элементы внутри карточки списка в явном виде.

Рассмотрим класс RecyclerViewItemSpecificityView. Он является реализацией интерфейса ViewAssertion. Соответственно, действует он по той же схеме, что и RecyclerViewItemCountAssertion, но имеет иное назначение.

Конструктор класса RecyclerViewItemSpecificityView, как говорилось ранее, принимает 2 параметра и представляет собой следующую конструкцию:

public RecyclerViewItemSpecificityView(final int specificallyId, final Matcher<View> matcher) {
	mSpecificallyId = specificallyId;
	mMatcher = matcher;
}

Метод check при этом происходит так:

  1. Поиск соответствующего элемента по идентификатору mSpecificallyId. Сюда мы будем передавать идентификаторы элемента Recyclerview.
  2. Проверка соответствия mMatcher. Эта проверка — основная задача класса.
  3. Формирование удобочитаемых выводов при отсутствии соответствия.

@Override
public void check(final View view, final NoMatchingViewException noViewFoundException) {
	final StringDescription description = new StringDescription();
	description.appendText("'");
	mMatcher.describeTo(description);
	final ViewGroup itemRoot = (ViewGroup) view;
	final View typeIUsage = itemRoot.findViewById(mSpecificallyId);
	if (noViewFoundException != null) {
		description.appendText(
			String.format(
				"' check could not be performed because view with id '%s' was not found.\n",
				mSpecificallyId));
		Log.e("RecyclerViewItemSpecificityView", description.toString());
		throw noViewFoundException;
	} else {
		description.appendText("' doesn't match the selected view.");
		assertThat(description.toString(), typeIUsage, mMatcher);
	}
}

“Ну, так все-таки — это капибара?”


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

onView(withId(R.id.rvAnimals))
.perform(scrollToPosition(capybaraPosition))
.check(new RecyclerViewItemSpecificityView(R.id.tvName, withText("Капибара")))
.check(new RecyclerViewItemSpecificityView(R.id.ivAnimalType, new DrawableMatcher(R.drawable.ic_sentiment_very_satisfied_black_24dp)));

ScrollToPosition является методом класса RecyclerViewActions. Он нужен для прокручивания списка до выбранной позиции. Если элемент списка не виден, то и тестировать его не получится. Далее мы проверяем, что элемент, до которого мы прокрутили список, в поле tvName содержит строку «Капибара». Также тестируемый элемент является “травоядным”, поэтому мы должны проверить, что иконка (ivAnimalType) соответствует ic_sentiment_very_satisfied_black_24dp.

Теперь напишем тест на удаление элемента. Я думаю, вы уже догадались, как мы можем использовать статический метод clickChildViewWithId класса CustomRecyclerViewActions и проверить, что количество элементов уменьшилось с помощью RecyclerViewItemCountAssertion.

onView(withId(R.id.rvAnimals)).check(new RecyclerViewItemCountAssertion(8));
onView(withId(R.id.rvAnimals)).perform(
RecyclerViewActions.actionOnItemAtPosition(0, MyRecyclerViewActions.clickChildViewWithId(R.id.btnRemove)));
onView(withId(R.id.rvAnimals)).check(new RecyclerViewItemCountAssertion(7));

Я специально использовал метод actionOnItemAtPosition класса RecyclerViewActions. Он пролистывает список до текущей позиции и тем самым дает нам возможность манипулировать элементом списка.

Заключение


Тестирование — очень важный процесс.
“Задача не может быть завершена, пока она не покрыта тестом хотя бы на 70%”
— так говорил мне начальник, когда я только знакомился с чудесным миром программирования. На мой взгляд, важный критерий тестирования — область покрытия. В первую очередь — проверка основной функциональности тестируемой части программного продукта, затем — попытки введения приложения в некие аварийные ситуации. Это повышает качество программного продукта, его устойчивость и самое главное — понимание. Да и чего греха таить: если программа покрыта тестами, спится куда спокойнее.

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

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