Статьи

Асинхронное тестирование приложений Swing


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

Как уже много раз говорили, тестирование является одним из наиболее важных действий для успеха проекта. Это, вероятно, даже в большей степени относится к настольным приложениям, чем к обычным веб-приложениям, поскольку у них более сложное поведение и интерактивность с пользователем. Действительно, многие приложения Web 2.0 тоже становятся богатыми на интерактивность, но большинство из них не имеют сложных асинхронных моделей, либо из-за ограничений технологии, либо потому, что они сделаны простыми по назначению. В конце концов, Интернет по-прежнему моделируется на основе сетевых транзакций, даже если вы используете AJAX; это означает, что у вас все еще есть много точек для тестирования интеграции, используя канал связи между клиентом и сервером.

Вместо этого рассмотрим настольное приложение на основе Swing со следующим поведением:

  1. у вас есть проводник файловой системы и вы выбираете папки
  2. при каждом выборе приложение сканирует папки (возможно, рекурсивно) для поиска содержимого носителя
  3. После завершения выбора в программе просмотра появляются эскизы.

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

Теперь предположим, что мы хотим написать интеграционный тест высокого уровня для этого сценария. Точкой исследования является только пользовательский интерфейс: вы делаете выбор и хотите утверждать, что «через некоторое время» была обновлена ​​другая часть пользовательского интерфейса. Мы могли бы подумать об этом наброске тестового кода:

package it.tidalwave.bluemarine.filesystemexplorer.test;

public class FolderSelectionTest extends AutomatedTest
{
private static final int THUMBNAIL_SELECTION_TIMEOUT = 4000;

private FileSystemExplorerTestHelper f;
private ThumbnailViewerTestHelper t;

...

@Before
public void prepare()
{
f = new FileSystemExplorerTestHelper();
t = new ThumbnailViewerTestHelper();
}

@After
public void shutDown()
{
f.dispose();
t.dispose();
}

@Test
public void run()
throws Exception
{
activate(f.fileSystemExplorerTopComponent);
f.resetSelection();

selectNode(f.upperExplorer, f.view.findUpperNodeByPath(BaseTestSet.getPath()));
select(f.cbSubfolders, true);
// WAIT FOR THE COMPUTATION TO COMPLETE

assertActivated(f.fileSystemExplorerTopComponent.getClass().getName());
assertOpened(t.thumbnailViewerTopComponent.getClass().getName());
ThumbnailViewerTestHelper.assertShownFiles(t.thumbnailListView, BaseTestSet.getPath(), BaseTestSet.getAllFiles());
...
}

...
}

На самом деле это реальный код из тестов BlueMarine, и я думаю, что он довольно читабелен (по крайней мере, если вы немного знакомы с основными понятиями платформы NetBeans). Он активирует TopComponent, сбрасывает выбор папки, выбирает узел в проводнике, устанавливает флажок (cbSubfolders — флажок, который включает рекурсивное сканирование) и утверждает, что TopComponent для выбора файлов все еще активен (= он получил фокус) , программа просмотра миниатюр TopComponent открывается, и ее представление заполняется всеми файлами тестового набора. TestHelpers — это функциональные классы, которые взаимодействуют с некоторыми частями классов пользовательского интерфейса, предлагая ссылки, которые указывают на соответствующие компоненты пользовательского интерфейса и конкретные методы утверждения.

Различные методы в теле теста статически импортируются из служебного класса и выполняют надлежащие манипуляции с пользовательским интерфейсом в потоке EDT — это общее решение многих связанных с Swing сред тестирования.

В настоящий момент blueMarine не использует ни одного из существующих, таких как Abbot и Costello или Jemmy / Jelly. Это по историческим причинам (первоначальные тесты были написаны несколько лет назад, до преобразования в NetBeans, хотя большинство из них были «потеряны» при преобразовании), и в конечном итоге они будут сходиться к стандартной среде, используемой NetBeans. Но это не относится к проблеме, о которой я говорю.

Трудным моментом является то, что «ПОДОЖДИТЕ, ЧТОБЫ ВЫПОЛНИТЬ ВЫЧИСЛЕНИЕ». Как это реализовать? Простое решение может быть задержкой соответствующей длины, но это действительно плохая идея; во-первых, это излишне замедлит тесты; во-вторых, это помешает вам измерить производительность во время тестирования; в-третьих, вы скоро обнаружите, что рано или поздно определенный прогон теста будет занимать гораздо больше времени, чем ожидалось (например, из-за того, что операционная система меняет память), и тест не пройдёт.

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

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

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

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

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

    @Test
public void run()
throws Exception
{
activate(f.fileSystemExplorerTopComponent);
f.resetSelection();

selectNode(f.upperExplorer, f.view.findUpperNodeByPath(BaseTestSet.getPath()));
final Waitable selectionChanged = t.thumbnailViewSelectionChanged();
select(f.cbSubfolders, true);
selectionChanged.waitForOccurrences(2, DEFAULT_TIMEOUT);

assertActivated(f.fileSystemExplorerTopComponent.getClass().getName());
assertOpened(t.thumbnailViewerTopComponent.getClass().getName());
ThumbnailViewerTestHelper.assertShownFiles(t.thumbnailListView, BaseTestSet.getPath(), BaseTestSet.getAllFiles());
...
}

 Waitable заключает в себе логику для обнаружения того, что определенное событие было инициировано. TestHelpers предоставляет фабричные методы для различных Waitable, представляющих ряд интересных событий, которые вы, возможно, захотите подождать. В этом конкретном случае, selectionChanged Waitable прослушивает изменения в компоненте представления, который отображает эскизы. Тайм-аут здесь довольно большой и полезен только для предотвращения неопределенного затягивания теста в случае, если что-то идет не так.

Пока все просто. Сложность, с которой я столкнулся, заключается в том, что blueMarine может генерировать несколько событий даже в простых случаях. Например, каждый выбор обычно заканчивается двумя событиями: сначала все виды уведомляются с особым пустым результатом, который означает «Пожалуйста, подождите, выполняется поиск» (и в то же время сразу очищает результаты предыдущего поиска), затем с последующим реальным результатом. Вот почему я добавил параметр для указания того, что вы, возможно, захотите несколько раз ожидать одного и того же события (в данном случае 2). Любое событие имеет прогрессивный счетчик; когда вы вызываете фабричный метод Waitable, текущее значение счетчика копируется в Waitable (например, 10), а когда вызывается waitForOccurences (2, …), вещь ждет, пока счетчик достигнет 10 + 2 = 12. вы’в состоянии ждать определенного количества событий, начиная с известной точки.

Но этого решения было недостаточно. Рассмотрим приведенный выше пример: операции selectNode (…) и select (f.cbSubFolders, true) * могут * инициировать сканирование, если они изменяют текущее состояние пользовательского интерфейса (т. Е. Указанный узел еще не был выбран, или флажок уже был отмечен); они не будут в противоположном случае. Поскольку тест должен быть воспроизводимым, в самом начале теста вы уверены, что такое исходное состояние; Вы не через несколько шагов после начала. Пожалуйста, имейте в виду, что это НЕ модульные тесты, которые обычно довольно простые, но интеграционные тесты: они имитируют реальное взаимодействие пользователя с приложением, и они могут быть сложными и довольно длинными.

Необходимость учитывать предварительные условия каждый раз, когда вы кодируете ожидание завершения процесса, приводила к огромным разочарованиям, поскольку даже незначительные изменения приводили к разрыву кода. Кроме того, расширенная асинхронность приложения делает ситуацию еще хуже: иногда сканирование, инициированное предыдущими вызовами, начинается с большой задержкой, в конечном итоге * после * создания Waitable. Это, конечно, нарушает механизм счетчиков. Кроме того, иногда ожидающие операции отменяются, потому что заменяются другими; в других случаях им разрешено закончить до того, как они будут отменены. Подводя итог, можно сказать, что количество событий любого события может меняться очень сложным и неожиданным образом. Странные вещи обычно происходят во время всплесков, например, когда компьютер занят другими задачами, таким образом, в очень непредсказуемых сценариях.

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

Когда я трачу слишком много времени на решение проблемы, и меня не устраивает элегантность решения, я думаю, что пришло время остановиться и подумать о чем-то другом. Поэтому я отменил все последние изменения и посмотрел в другом направлении. Когда вы чувствуете, что попадаете в ловушку сложности, хорошим подходом является попытка думать как можно ближе к реальному миру. Что происходит в моем сценарии? Хорошо:

1a. Я нажимаю кнопку
1b. последовательность вещей происходит
1с. результат доступен
2a. Я нажимаю другую кнопку
2b. другая последовательность вещей происходит
2с. другой результат становится доступным,

и в конце концов последовательности 1 * и 2 * могут быть смешаны следующим образом:

1a. Я нажимаю кнопку
1b. последовательность вещей происходит
2a. Я нажимаю другую кнопку
2b. другая последовательность вещей происходит
1с. результат становится доступным
2с. другой результат доступен

Тем не менее, глядя на приведенный выше псевдокод, мы можем отслеживать происходящее благодаря идентификаторам 1 * и 2 *. То есть мы «пометили» различные последовательности операций. Поскольку любая операция представляет собой последовательность потоков, почему бы нам просто не отметить их? Бинго.

Посмотрите на следующий код:

    @Test
public void run()
throws Exception
{
activate(f.fileSystemExplorerTopComponent);
f.resetSelection();

assertActivated(f.fileSystemExplorerTopComponent.getClass().getName());
select(f.cbSubfolders, false);
final Tag tag1 = selectNode(f.upperExplorer, f.view.findUpperNodeByPath(BaseTestSet.getPath()));
t.thumbnailSelectionChanged().waitForNextOccurrence(tag1, THUMBNAIL_SELECTION_TIMEOUT);
delay(200); // Give time for the AWT thread to work so changes go to the UI

assertActivated(f.fileSystemExplorerTopComponent.getClass().getName());
assertOpened(t.thumbnailViewerTopComponent.getClass().getName());
...
}

Теперь дело в том, что каждый метод, который инициирует взаимодействие с пользовательским интерфейсом, создает новый экземпляр Tag. Тег прикрепляется к потоку EDT (посредством ThreadLocal) перед вызовом Swing и распространяется на связанные потоки (например, обычно EDT запускает фоновый поток с помощью java.util.concurrency.Executor и, когда все готово). результат снова передается в EDT для обновления пользовательского интерфейса). Это означает, что вместо ожидания исчисляемых событий мы ожидаем появления событий с тегами; в последнем примере кода waitForNextOccurrence (tag1, …) блокирует, пока указанное событие не произойдет в правильно помеченном треде.

Сложность состоит в том, как распространять тег от потока к потоку, но в итоге я заставил его работать без особых хлопот. Самая раздражающая часть — это собственный код вместо EventQueue.invokeLater (), но я думаю, что очередь событий AWT настраивается, и я мог бы позволить тегам работать со стандартными вызовами методов.

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