Меня зовут Сергей, и я инженер автоматизации тестирования.

В этой заметке я поделюсь опытом автоматизации тестирования специфичных сквозных (E2E) сценариев, с которыми мне пришлось столкнуться.

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


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

Фреймворк построен на Java + Maven + JUnit + Serenity (без использования BDD-части). В качестве CI используется TeamCity.

Проект содержит тесты разных уровней:

  1. Интеграционные. Проверяют API Rest-методы.

  2. Системные. Проверяют конфигурацию приложения и как данные "растекаются" по базам.

  3. UI. Проверяют UI интерфейс в Google Chrome.

Для полного счастья не хватает только тестов для проверки E2E сценариев. Далее мы поговорим об их внедрении. 

Что за сценарии? 

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

Есть завод. Он огромен - в нем сотни цехов. Сквозь эти цеха идёт конвейер, на котором собирают роботов. На ленте они расположены на равном расстоянии друг от друга. Когда один робот покидает цех на ленте, другой в него поступает.

Роботы очень сложные и нужные, поэтому их качеству уделяется особое внимание. В каждом из цехов есть свой отдел технического контроля (ОТК). При этом все ОТК используют единую для завода Систему Контроля Качества (СКК). СКК хранит, рассчитывает и отображает сотни тысяч метрик, снятых с роботов. Эти метрики нужны для анализа и выявления брака.

Теперь представим такую ситуацию. В ОТК цеха N работает Лентяй. И вот Система сообщает ему, что очередной робот, с большой долей вероятности, имеет брак. Лентяй нажимает в интерфейсе Системы кнопку "Открыть инцидент" и неспешно идет к конвейеру, на котором находится дефектный робот. Идет он очень медленно, так как знает, что если лента увезет робота в другой цех, то это перестанет быть его проблемой (другой цех - другой ОТК). Так и случилось - робот перемещен в цех N+1, и Лентяй может так же неспешно возвращаться назад.

Чтобы из-за подобных лентяев не случилось восстание машин, в Системе есть функция, которая автоматически закрывает инцидент, взятый в работу в цехе N, и открывает дублирующий, но уже в цехе N+1. Вот подобные сценарии и должны быть покрыты автоматическими тестами.

Как на самом деле выглядит процесс.

В этой статье я сильно упростил бизнес-сценарий. В реальном мире он выглядит следующим образом:

Загрузить данные -> Пройти расчет -> Проверить наличие подозрения на брак -> Открыть инцидент в цехе N -> Изменить данные -> Загрузить данные -> Пройти расчет -> Проверить, что инцидент в цехе N закрылся с нужным статусом -> Проверить, что инцидент в цехе N+1 появился -> Открыть инцидент в цехе N+1 -> Закрыть инцидент в цехе N+1 -> Изменить данные -> Загрузить данные -> Пройти расчет -> Проверить, что инцидент в цехе N+1 по прежнему закрыт -> Проверить, что инцидент в цехе N+2 не открыт.

Описывать всю рутину этого сценария в статье считаю нецелесообразным.

Казалось бы - ну, и что тут такого? Что сложного-то? А сложность тут в том, что каждый раз, когда робот покидает цех, запускается сбор и анализ показателей ВСЕХ роботов во ВСЕХ цехах. Занимает этот процесс около часа. Таким образом, подобный сценарий очень долго воспроизводить. В этом и специфичность. Таких сценариев сотни и некоторые требуют нескольких итераций расчетов. Если подойти к решению этой проблемы "в лоб", то исполнение всех тестов займет колоссальное время. Отсюда главная задача - добиться разумного времени исполнения регрессионного набора E2E-тестов.

Подходы к решению задачи

Семафор 

Сначала я задумался над использование семафора. В его качестве мог бы выступить сервлет для Selenoid (в нем исполняются Web UI тесты). Этот сервлет должен получать и хранить состояния исполняющихся тестов, чтобы:

1. Точно знать сколько тестов начало исполнение.

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

3. Сколько из них провалилось до этого шага. Чтобы не ждать их, и позволить запустить другие тесты в освободившихся слотах.

4. Когда все тесты дошли до шага расчетов - запускать эти расчеты.

5. Сообщать тестам успешность расчетов. Тесты или продолжатся (если расчеты прошли успешно) или провалятся. 

Семафор (англ. semaphore) — примитив синхронизации работы процессов и потоков, в основе которого лежит счётчик, над которым можно производить две атомарные операции: увеличение и уменьшение значения на единицу, при этом операция уменьшения для нулевого значения счетчика является блокирующейся. 

Вариант довольно хороший и позволит значительно сократить время выполнения всех тестов, за счёт уменьшения количества запусков расчётов. Помимо этого можно будет гибко управлять ресурсами, ведь всегда есть возможность увеличить/уменьшить количество слотов Selenoid. И, что немаловажно, из-за того, что тест не прерывается на время расчетов, будет единый файл с отчетом.

У меня есть опыт написания подобного сервлета-семафора. На одном из прошлых проектов было нужно запускать большое количество Appium-тестов на реальных устройствах параллельно. Десяток-другой устройств разных производителей был подключен к Selenium Grid в тестовой лаборатории заказчика. Количество и состав устройств, как и тестов, постоянно менялся. Было невозможно ими строго управлять.

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

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

  1. Будет необходимо поддерживать не один проект, а два, с собственными дефектами. Куда без них?

  2. Архитектура тестового окружения будет более сложной. А значит менее стабильной.

  3. Запуск тестов нужно будет "обвязывать" логикой работы с сервлетом.

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

Из-за перечисленных выше изъянов, я решил продолжить поиск подхода.

"Этапные" тесты 

Сквозные сценарии можно условно разделить на этапы, которые прерываются процессами расчетов. То есть один тест разделить на несколько. Это же противоречит принципу независимости тестов! Да, противоречит. Но появится возможность параллельно выполнять тесты одного этапа, по завершении которого запускать расчет. И этот расчет будет общим для всех тестов. Это не усложнит (почти) архитектуру тестов, не появится (почти) необходимость управлять сложным процессом исполнения тестов. Всего-то нужно минимизировать сайд-эффекты нарушения принципа:

  1. Появляется необходимость формирования тестов в особые наборы.

  2. Появляется необходимость передачи данных между тестами.

  3. Появляется необходимость отслеживания результатов исполнения "связанных" тестов.

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

Решение 

Формирование тестовых наборов. 

Благодаря наличию аннотации @WithTag в Serenity, есть возможность гибко управлять составом тестового набора, который будет исполняться. Таким образом нам достаточно использовать признаки: stage и level, чтобы составить необходимый набор. Также необходимо дать тесту понятное название, которое будет отображаться в отчете.

@Test
@WithTags({
    @WithTag("stage:one"),
	@WithTag("level:e2e")
})
@Title("Инциденты: Инцидент переоткрывается, когда робот покинул цех. (1/2)").
public void incident_close_open_when_robot_leave_workshop_stage_one() {
    ...
	Здесь выполняются методы, которые находят необходимое сообщение, о выявлении брака.
	...
	А здесь, открывается инцидент в цехе N.
	...
}
@Test
@WithTags({
    @WithTag("stage:two"),
	@WithTag("level:e2e")
})
@Title("Инциденты: Инцидент переоткрывается, когда робот покинул цех. (2/2)").
public void incident_close_open_when_robot_leave_workshop_stage_two() {
    ...
	Здесь выполняется проверка того, что инцидент из теста первого этапа закрыт.
	...
	А здесь проверяется то, что открылся дублирующий инцидент в цехе N+1.
	...
}

Теперь можно начать настройку pipeline на сервере TeamCity:

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

mvn verify -Dtags="stage:one level:e2e"

2. Шаг запуска расчетов.

3. Шаг запуска тестов и генерации отчета для второго этапа:

mvn verify -Dtags="stage:two level:e2e"

4. Так как есть несколько отчетов, необходимо сказать TeamCity, что бы он не обращал на это внимание

echo "##teamcity[importData type='surefire' path='target/surefire-reports/*.xml' parseOutOfDate='true']"

5. Собрать несколько отчетов в один:

mvn serenity:aggregate 

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

Передача данных между тестами

На просторах Всемирной сети есть open source проект команды Dizitart под названием Nitrite. Это весьма удобная noSQL база данных написанная на Java. Для решения задачи она подходит как нельзя лучше:

  1. Данные она хранит в едином файле на диске. Есть возможность хранить данные и в памяти, но не наша цель.

  2. Легко подключается к проекту.

  3. Позволяет хранить как документы, так и объекты.

  4. Достаточно быстрая и легковесная.

У неё есть и другие приятные особенности, но перечисленного вполне достаточно для выбора в её пользу. 

Добавляем зависимость в pom.xml:  

<dependency>
    <groupId>org.dizitart</groupId>
	<artifactId>nitrite</artifactId>
	<version>3.4.4</version>
</dependency>

Создаем класс NitriteDB:

public class NitriteDB {

    private static final ThreadLocal<Nitrite> db = new ThreadLocal<>();
  
	public static Nitrite getDB() {
	    if (db.get() == null) {
		    NitriteDB.setDB(Nitrite.builder()
			    .filePath(ConfigProvider.getConfigProvider().getDbName()))
				    .openOrCreate());
		}
		return db.get();
	}
	
	public static void setDB(Nitrite nitrite) {
	    db.set(nitrite);
	}

}

 И... всё. Теперь есть возможность обращаться к БД из любого участка кода.

Добавим сохранение данных в тест первого этапа:

@Test
@WithTags({
    @WithTag("stage:one"),
	@WithTag("level:e2e"),
	@WithTag("testId:42")
})
@Title("Инциденты: Инцидент переоткрывается, когда робот покинул цех. (1/2)").
public void incident_close_open_when_robot_leave_workshop_stage_one() {
    ...
	Здесь выполняются методы, которые находят необходимое сообщение, о выявлении брака.
	...
	А здесь, открывается инцидент в цехе N.
	...
	Сохраняем данные (используем ключ-значение) в документ:
	
	Document document = createDocument(someKey, someValue);
	document.putAll(someData);
	
	Записываем документ в базу:
	
	E2eDB.getDB().getCollection(getTestCollection()).insert(document);
	...
}

Тут нужно пояснить что делает метод getTestCollection(). Этот метод возвращает некое уникальное значение, по которому тесты других этапом смогут "опознать" коллекцию в базе. В моем случае, я добавляю еще одну метку на тест: testId. Использовать id теста удобно — он одинаков для всех "этапных" тестов, так как изначальный сценарий (который был раздроблен на этапы) имеет единственный id. Также по указанному id будет удобно группировать результаты в Serenity-отчете.

Сам метод имеет следующий код:

private String getTestCollection() {
    return Objects.requireNonNull(StepEventBus.getEventBus().getBaseStepListener()
	    .latestTestOutcome().get().getTags().stream()
		.filter(t -> t.getType().equals(testId))
		.findFirst().orElse(null)).getName();
}

StepEventBus — класс в Serenity, который позволяет получать метаданные исполняемого теста.

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

@Test
@WithTags({
    @WithTag("stage:two"),
	@WithTag("level:e2e")
})
@Title("Инциденты: Инцидент переоткрывается, когда робот покинул цех. (2/2)").
public void incident_close_open_when_robot_leave_workshop_stage_two() {
    ...
	Получаем данные теста первого этапа:
	
	Map<String, Object> stageOneData = E2eDB.getDB()
	    .getCollection(getTestCollection()).find(eq(someKey, someValue))	
    ...
	Здесь выполняется проверка того, что инцидент из теста первого этапа закрыт.
	...
	А здесь проверяется то, что открылся дублирующий инцидент в цехе N+1.
	...
}

Данные успешно передаются между тестами. Для того, чтобы добавить их в артефакты тестирования, необходимо дополнить pipeline конфигурации TeamCity. В моем случае, файл с БД выгружается в Artifactory. Я всегда могу взять этот файл, и посмотреть на каких данных тесты исполнялись.

Успех? Почти. Нужно добавить отслеживание результатов исполнения "связанных" тестов.

Контроль исполнения тестов 

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

  • Этот тест точно не пройдет успешно и отчет о тестировании станет "краснее", чем мог бы. Этим будет вводить в заблуждение о количестве неуспешных проверок.

  • Он займет лишний слот в Selenoid.

  • Он увеличит общее время прохождения тестирования. Как минимум ему потребуется время на запуск WebDriver'а.

Почему бы не Ignore'ровать этот тест?

1. Создадим интерфейс

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
@Inherited
public @interface IgnoreIfFailed {
    String testName() default "";
}

2. Напишем реализацию

public class IgnoreIfFailedRule implements TestRule {
    @Override
	public Statement apply(Statement base, Description description) {
	    return new IgnorableStatement(base, description);
	}
	
	private static class IgnorableStatement extends Statement {
	    private final Statement base;
		private final Description description;
		
		public IgnorableStatement(Statement base, Description description) {
		    this.base = base;
			this.description = description;
		}
		
		@Override
		public void evaluate() throws Throwable {
		    IgnoreIfFailed annotation = description.getAnnotation(IgnoreIfFailed.class);
			
			if (annotation != null) {
			    Document storedResult = Iterables.getLast(
				        E2eDB.getDB().getCollection("testResults")
					    .find(eq("testCaseSignature", annotation.testName())).toList()
						);
			
			if (storedResult != null) {
			    if (!(boolean) storedResult.get("isSuccess")) {
				    StepEventBus.getEventBus().assumptionViolated();
					StepEventBus.getEventBus().testIgnored();
					throw new AssumptionViolatedException(
					    "Тест проигнорирован из-за того, что предыдущий связанный тест провалился. "
					);
				}
			}
			}
			base.evaluate();
		}
	}
}

3. Дополним базовый тестовый класс

Запись результатов теста в БД, после их исполнения:

 @AfterClass
public static void tearDown() {
    saveTestResult();
}

public static void saveTestResult() {
    for (TestOutcome testCase : StepEventBus.getEventBus().getBaseStepListener().getTestOutcomes()) {
	    Document document = createDocument("testCaseSignature", testCase.getName());
		document.put("isSuccess", testCase.isSuccess());
		E2eDB.getDB().getCollection("testResults").insert(document);
	}
}

Инициализация объекта, с Junit-аннотацией Rule:

@Rule
public final IgnoreIfFailedRule ignoreIfFailedRule = new IgnoreIfFailedRule();

Добавим аннотацию к тесту второго этапа

@IgnoreIfFailed(testName = "incident_close_open_when_robot_leave_workshop_stage_one")
@Test
@WithTags({
    @WithTag("stage:two"),
	@WithTag("level:e2e")
})
@Title("Инциденты: Инцидент переоткрывается, когда робот покинул цех. (2/2)").
public void incident_close_open_when_robot_leave_workshop_stage_two() {
    ...
	Получаем данные теста первого этапа:
	
	Map<String, Object> stageOneData = E2eDB.getDB()
	    .getCollection(getTestCollection()).find(eq(someKey, someValue))	
    ...
	Здесь выполняется проверка того, что инцидент из теста первого этапа закрыт.
	...
	А здесь проверяется то, что открылся дублирующий инцидент в цехе N+1.
	...
}

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

И на этом всё. Вот теперь "успех". 

Заключение 

Таким способом я добился результата, который меня вполне устроил. Да, я нарушил (каюсь!) важный принцип тест-дизайна - тесты должны быть независимыми. Но что же важнее: соблюдение правил или конечный результат? На мой взгляд второе, если последствия нарушения сведены к минимуму. А они сведены:

  • Тесты имеют строгую понятную структуру.

  • Тесты объединены в соответствующие наборы.

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

  • Провал исполнения одного теста не влечет провал другого.

  • Результирующий отчет прост в анализе. 

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

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