Параллельное выполнение тестов

Тесты, написанные с использованием gs-automation, часто запускаются не по одному, а параллельно — несколькими одновременно работающими воркерами Gradle в рамках одного тестового прогона. Каждый воркер поднимает свой собственный экземпляр веб-браузера и свою сессию клиента, но все они работают с одним и тем же сервером приложений Global3 и одной и той же базой данных.

Большинство тестов от этого никак не страдают — они открывают собственные формы и работают только с теми данными, которые сами же создают или читают. Но есть класс сценариев, в которых параллельные, никак не связанные друг с другом тесты, могут случайно “столкнуться” на одной и той же записи. Эта глава рассказывает, откуда берётся такое столкновение и как писать тесты, устойчивые к нему.

Монопольная блокировка редактирования

Прикладной код Global3 может защищать записи справочников от одновременного редактирования разными пользователями: как только запись открывается на редактирование, сервер накладывает на неё монопольную блокировку. Если кто-то другой попытается отредактировать ту же самую запись, пока блокировка не снята, он получит от сервера ошибку, например, с текстом вида:

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

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

Attention

Блокировка проверяется только при редактировании уже существующей записи. Создание новой записи блокировку не проверяет и никогда не приводит к описанной выше ошибке — на этом факте строятся оба решения, рассмотренные ниже.

Принцип: не завязывайтесь на общую запись

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

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

Карточное представление: открывайте новую запись

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

Открытие карточки на создание новой записи вместо редактирования существующей
application.mainForm().mainMenu().itemByCaption("Формы", "Справочник с MDI-карточкой").click();
Form listForm = application.waitMdiForm("gtk-ru.bitec.app.gs3.qa.form.open_card_type.Gs3_QAReferenceMdiCard", "List");

// "Создать" вместо "Редактировать" -- открывается новая, никому больше не видимая запись
listForm.mainSelection().layout().frame().toolbar().buttonByCaption("Создать").click();
Form cardForm = application.waitMdiForm("gtk-ru.bitec.app.gs3.qa.form.open_card_type.Gs3_QAReferenceMdiCard", "Card");

// ... проверки, не зависящие от конкретной записи ...

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

Note

После отказа от сохранения такой карточки (StandardOperations.CLOSE_FORM_CANCEL) сервер может, в зависимости от выборки, показать диалог подтверждения отказа от несохранённых изменений. Его нужно обработать, как описано в Объект диалога:

Закрытие карточки новой записи с возможным диалогом подтверждения
cardForm.mainSelection().layout().frame().toolbar().button(StandardOperations.CLOSE_FORM_CANCEL).click();
try {
   application.waitMsgDialog().no();
} catch (ElementNotFoundException ignored) {
   // диалог не появился -- отказывать не от чего, несохранённая запись и так пропадёт
}
cardForm.waitClosing();

Табличное представление: заведите собственную изолированную запись

Приём с открытием на создание годится не всегда: например, тест может проверять поведение редакторов ячеек таблицы, а для этого нужна именно сохранённая запись. В таком случае решение — не переиспользовать чужую “тестовую” запись, а завести перед тестом свою собственную, работать только с ней и удалить её после теста.

Такую подготовку и очистку данных удобно возложить на прикладной код в виде операции и выполнять её аннотациями @Oper/@Jexl, как описано в главе Аннотации. Операция создания должна возвращать идентификатор созданной записи, чтобы тест мог её затем найти и, по завершении, удалить.

Создание собственной изолированной записи перед тестами
public class GridEditorsTest extends GsAutomationJUnitEnvironment {
   private static final String LIST_FORM = "gtk-ru.bitec.app.gs3.qa.controls.Gs3_QaEditorsTest#List";
   private Form listForm;
   private Long isolatedRecordId;

   @Override
   protected void onOperationExecuted(String methodName, String formName, String selectionName,
                                       String operationName, Object result) {
      if ("qa_createIsolatedRecord".equals(operationName)) {
         isolatedRecordId = Long.valueOf((String) result);
      }
   }

   @BeforeAll
   @Oper(name = "qa_createIsolatedRecord", form = LIST_FORM)
   public void beforeAll() {
      application.mainForm().mainMenu().itemByCaption("Редакторы", "В списке").click();
      listForm = application.waitMdiForm(LIST_FORM);
   }
}

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

Поиск своей изолированной записи по содержимому строки
Grid grid = listForm.mainSelection().layout().frame().view().cast();
String expectedCaption = "isolated-" + isolatedRecordId;

Row foundRow = null;
for (Row row : grid.getRows()) {
   if (expectedCaption.equals(row.getCellByName("SCAPTION").value())) {
      foundRow = row;
      break;
   }
}
Assertions.assertNotNull(foundRow, "Изолированная запись не найдена в таблице");

По окончании теста запись нужно удалить тем же образом, каким она была создана — кликом по найденной строке и кнопке StandardOperations.DELETE на панели инструментов.

Attention

Кнопка панели инструментов действует на строку, реально выделенную кликом, а не на ту, что была сфокусирована на сервере, например, методом Selection#locate(Map). Поэтому перед кликом по кнопке DELETE найденную строку нужно сначала кликнуть:

Удаление найденной изолированной записи
foundRow.click();
frame.toolbar().button(StandardOperations.DELETE).click();
frame.toolbar().button(StandardOperations.SAVEFORM).click();

Note

Метод Grid#getSelectedRowVisibleIndex(), удобный для определения текущей выделенной клиентом строки, не всегда надёжно отражает результат серверного locate() — особенно после переоткрытия формы или при нескольких одновременно работающих сессиях. Для поиска именно своей записи среди строк таблицы надёжнее сканирование содержимого строк, как показано выше, а не сочетание locate() + getSelectedRowVisibleIndex().

See also