FlaUI на практике: поиск элементов, умные ожидания и борьба с динамическим UI

от автора

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

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

Содержание

  1. Как мы ищем элементы с помощью FlaUI — Стратегии приоритетов локаторов

  2. Наши подходы к ожиданию элементов — WaitExtensions и умные ожидания

  3. Работа с разными типами UI элементов — TypeExtensions и безопасные взаимодействия

  4. Обработка динамических элементов — Лучшие практики для нестабильного UI

Как мы ищем элементы с помощью FlaUI

Поиск элементов — это фундамент любого UI-автотеста. Во FlaUI это делается через древо автоматизации (UI Automation Tree), которое строится из всех видимых элементов интерфейса. Наша задача — найти нужную «ветку» или «лист» в этом дереве.

Базовый инструмент: FindFirstDescendant и FindAllDescendants

Это два основных метода для поиска. Они вызываются у любого элемента-контейнера (обычно окна) и принимают условие поиска (Condition).

// Находим первый элемент с указанным AutomationId var textBox = window.FindFirstDescendant(cf => cf.ByAutomationId("UserIdTextBox"));  // Находим ВСЕ кнопки на форме var allButtons = window.FindAllDescendants(cf => cf.ByControlType(ControlType.Button)); 

По каким признакам мы ищем? (Стратегия приоритетов)

Мы используем несколько свойств элементов, выстраивая стратегию от самого надежного к менее надежному.

1. ByAutomationId (Идеальный вариант)

  • Что это: Уникальный идентификатор, который разработчик присваивает элементу в коде (например, в WPF — свойство x:Name или AutomationProperties.AutomationId).

  • Почему это лучший способ: Он почти всегда уникален в пределах окна и не меняется при локализации или смене текста.

  • Когда использовать: Всегда в первую очередь! Это ваш главный и самый стабильный локатор.

  • Пример из нашего кода: Мы используем это как основной способ поиска в классе MainWindowLocators.

    // UiAutoTests/Locators/MainWindowLocators.cs public TextBox UserIdTextBox => FindFirst("UserIdTextBox").AsTextBox(); public TextBox UserLastNameTextBox => FindFirst("UserLastNameTextBox").AsTextBox(); 

2. ByName / ByText (Хороший вариант, но с оговорками)

  • Что это: Текст, который пользователь видит на элементе (Заголовок кнопки, текст метки и т.д.).

  • Почему нужно быть осторожным: Текст может меняться (локализация, редизайн), он может быть не уникальным (несколько кнопок «ОК» в разных местах).

  • Когда использовать: Когда у элемента нет AutomationId, но его текст статичен и уникален в контексте.

3. ByControlType (Самый ненадежный вариант)

  • Что это: Тип элемента (Кнопка, Текстовое поле, Чекбокс). FlaUI определяет его через свойство ControlType.

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

  • Когда использовать: Только в комбинации с другими условиями или когда нужно найти все элементы одного типа.

4. ByClassName (Для системных и сложных элементов)

  • Что это: Внутреннее имя класса элемента, которое присваивает операционная система или фреймворк.

  • Почему это специфично: Эти имена часто generic-ские (например, «Button», «Edit») и одинаковы для всех элементов одного типа.

  • Когда использовать: Для сложных кастомных контролов, где разработчик не выставил AutomationId. Например, для элементов стандартного календаря WPF.

  • Пример из нашего кода: Мы используем этот способ для поиска ячеек календаря, у которых нет стабильных AutomationId.

    // UiAutoTests/Locators/MainWindowLocators.cs public AutomationElement CalendarDayButton => FindFirstByClassName("CalendarDayButton"); public AutomationElement[] CalendarDayButtons => FindAllByClassName("CalendarDayButton"); 

Комбинирование условий

Часто один признак недостаточен. FlaUI позволяет комбинировать условия с помощью методов And и Or.

// Найти элемент, который является либо кнопкой, либо текстовым полем var orCondition = cf.ByControlType(ControlType.Button).Or(cf.ByControlType(ControlType.Edit)); 

Наша практика: Класс-локатор

Мы не используем поиск напрямую в тестах. Вместо этого мы инкапсулируем всю логику поиска в отдельные классы-локаторы. Это делает код тестов чище, а изменение локатора в одном месте автоматически применяется ко всем тестам.

// Вместо этого в тесте (ПЛОХО): var textBox = window.FindFirstDescendant(cf => cf.ByAutomationId("UserIdTextBox")).AsTextBox();  // Мы делаем так (ХОРОШО): // 1. Локатор знает, как найти элемент public TextBox UserIdTextBox => FindFirst("UserIdTextBox").AsTextBox();  // 2. В тесте мы просто используем свойство var textBox = _locators.UserIdTextBox; 

Золотая стратегия поиска элементов выглядит так:

  • Всегда стараться использовать ByAutomationId. Это требует договоренности с разработчиками, но окупается стабильностью тестов на 100%. Это наш основной метод, как видно в MainWindowLocators.

  • Если AutomationId нет, пробовать ByName, если текст статичен и уникален.

  • Для системных и кастомных компонентов (как календарь) использовать ByClassName.

  • Избегать «хрупких» локаторов, основанных только на тексте или позиции элемента.

  • Создание классов-локаторов.


Наши подходы к ожиданию элементов

Ожидание — это искусство дать приложению достаточно времени отреагировать, но не ждать дольше необходимого. Наша философия: явно ждать нужного состояния элемента, а не просто его наличия.

Мы отказались от «жестких» пауз Thread.Sleep() в пользу умных ожиданий, основанных на условиях.

Проблема «жестких» пауз:

// ПЛОХО: Хрупко и неэффективно element.Click(); Thread.Sleep(3000); // Ждем 3 секунды ВСЕГДА, даже если элемент появился через 100мс DoNextAction(); 

Наше решение: Умные ожидания с помощью Retry-логики

В основе нашей системы лежит механизм повторных попыток (Retry), который периодически проверяет условие, пока оно не станет истинным (или не истечет таймаут).

Базовые строительные блоки (Live в нашем коде):

// UiAutoTests/Extensions/WaitExtensions.cs public static class WaitExtensions {     private const int DefaultTimeout = 5000; // 5 секунд по умолчанию      /// <summary>     /// Ожидание появления элемента     /// </summary>     public static bool WaitUntilExists(this AutomationElement parent, Func<AutomationElement> findFunc, int timeoutMs = DefaultTimeout)     {         _logger.Info($"Ожидание появления элемента");         // Retry.WhileNull будет вызывать findFunc с заданным интервалом, пока тот не вернет не null         var result = Retry.WhileNull(findFunc, TimeSpan.FromMilliseconds(timeoutMs));         return result.Result != null;     }      /// <summary>     /// Ожидание кликабельности элемента     /// </summary>     public static bool WaitUntilClickable(this AutomationElement element, int timeoutMs = DefaultTimeout)     {         _logger.Info($"Ожидание кликабельности элемента: {element.Properties.AutomationId}");         // Ждем не просто появления, а нужного состояния для взаимодействия!         return Retry.WhileFalse(             () => element?.IsEnabled == true && element?.IsOffscreen == false, // Условие: включен и видим на экране             TimeSpan.FromMilliseconds(timeoutMs)).Success;     } } 

Типы ожиданий, которые мы используем:

1. Ожидание общего состояния элемента (самые частые):

  • WaitUntilEnabled / WaitUntilClickable: Элемент доступен для взаимодействия.

  • WaitUntilDisabled: Элемент заблокирован (например, кнопка «Сохранить» пока форма не валидна).

  • WaitUntilVisible: Элемент не скрыт и находится на экране.

2. Ожидание конкретных значений и текста:

  • WaitUntilTextAppears: Ожидание появления определенного текста.

    // Ожидаем, что в текстовом поле появится текст "Успешно!" element.WaitUntilTextAppears("Успешно!"); 
  • WaitUntilValueChanged: Ожидание изменения значения (для полей ввода, прогресс-баров).

3. Ожидание исчезновения элементов:

  • WaitUntilNotExists / WaitUntilDisappears: Элемент был, а теперь его нет (исчезло модальное окно, пропал лоадер).

    // Ждем, когда исчезнет индикатор загрузки loadingIndicator.WaitUntilDisappears(); 

Где мы применяем ожидания? В слое Type Extensions!

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

Как это работает на практике:

// UiAutoTests/Extensions/TextBoxExtensions.cs public static void EnterText(this TextBox textBox, string inputText) {     var element = textBox.EnsureTextBox(); // 1. Убедились, что это текстбокс     if (!element.WaitUntilEnabled(5000))    // 2. Подождали, пока он станет доступен         throw new TimeoutException($"TextBox {element.AutomationId} не стал активным");      element.Focus();     element.Text = inputText; // 3. Совершили действие     _logger.Info($"Введен текст: {inputText}"); }  // UiAutoTests/Extensions/ButtonExtensions.cs public static void ClickButton(this Button button, int timeoutMs = 5000) {     var element = button.EnsureButton(); // 1. Убедились, что это кнопка     if (!element.WaitUntilClickable(timeoutMs)) // 2. Подождали, пока можно будет кликнуть         throw new TimeoutException($"Кнопка {element.AutomationId} не стала кликабельной");      button.Invoke(); // 3. Совершили действие     _logger.Info($"[{button.AutomationId}] is Invoked"); } 

Итог по ожиданиям:

  1. Мы явно ждем состояния, а не просто наличия. IsEnabled && !IsOffscreen — наша магия.

  2. Мы встраиваем ожидание в само действие. Это избавляет тесты от кусков кода с ожиданиями.

  3. Мы используем централизованную Retry-логику. Легко настроить таймауты и интервалы для всего проекта.

  4. Мы логируем каждое ожидание. Это критически важно для отладки падающих тестов.

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

// Чистый и стабильный тест _mainWindowController     .EnterLogin("user") // внутри есть ожидание     .EnterPassword("pass") // внутри есть ожидание     .ClickLogin() // внутри есть ожидание     .AssertWelcomeMessage(); // и здесь тоже есть ожидание 

Наша система ожиданий — это не просто замена Thread.Sleep. Это инструмент прогнозирования поведения приложения.
Мы не ждем произвольное время, а точно знаем, какого состояния должен достичь элемент, чтобы тест мог продолжить работу.
Это, в сочетании с детальным логированием и визуальной диагностикой, превращает процесс отладки из рутины в быстрое и даже приятное занятие.


Работа с разными типами UI элементов

Одна из главных сильных сторон FlaUI — это единый API для работы с разными технологиями (WinForms, WPF, UWP) и типами элементов. Наша задача — использовать эту силу, создав универсальные и безопасные методы взаимодействия.

Наш главный принцип: «Один элемент — один метод расширения»

Мы не используем «голые» вызовы типа element.AsTextBox().Enter("text") прямо в тестах. Вместо этого мы создали для каждого типа элементов свой класс расширений с проверенными и стабильными методами.

Базовый паттерн: Ensure + Wait + Act

Каждое наше взаимодействие с элементом строится по этой схеме:

  1. Ensure — убедиться, что элемент действительно того типа, который мы ожидаем.

  2. Wait — дождаться его готовности к взаимодействию.

  3. Act — выполнить действие.

// UiAutoTests/Extensions/TextBoxExtensions.cs public static void EnterText(this TextBox textBox, string text, int timeoutMs = 5000) {     // 1. ENSURE     var element = textBox.EnsureTextBox();     // 2. WAIT     if (!element.WaitUntilEnabled(timeoutMs))         throw new TimeoutException($"TextBox {element.AutomationId} не стал активным");     // 3. ACT     element.Focus();     element.Text = text; } 

Примеры работы с ключевыми элементами

1. Текстовые поля (TextBox)
Особенности: Нужно управлять фокусом, очищать перед вводом, обрабатывать многострочный текст.

// Очистка и ввод текста public static void EnterText(this TextBox textBox, string text) { ... }  // Ожидание конкретного текста в поле (для валидаций) public static bool WaitForText(this TextBox textBox, string expectedText, int timeoutMs = 5000) {     return Retry.WhileFalse(         () => textBox.Text == expectedText,         TimeSpan.FromMilliseconds(timeoutMs)).Success; } 

2. Сложные элементы: DataGridView (Таблица)
Особенности: Самая сложная работа — поиск по строке и колонке.

// Поиск строки по значению в конкретной колонке public static DataGridViewRow FindRowByCellValue(this DataGridView grid, string columnName, string value) {     return grid.Rows.FirstOrDefault(row =>          row.Cells[columnName].Value == value); }  // Клик по ячейке с определенным значением public static void ClickCellWithValue(this DataGridView grid, string columnName, string value) {     var row = grid.FindRowByCellValue(columnName, value);     row?.Cells[columnName].Click(); } 

Универсальные методы для любых элементов

Мы также создали методы, которые работают для любого элемента:

// Скриншот конкретного элемента public static void CaptureElementScreenshot(this AutomationElement element, string fileName) {     var screenshot = element.Capture();     screenshot.ToFile(fileName); }  // Получение всех свойств элемента (для отладки) public static string GetElementInfo(this AutomationElement element) {     return $"Id: {element.Properties.AutomationId}, Name: {element.Properties.Name}, Type: {element.ControlType}"; } 

Итог подхода:

  1. Инкапсуляция сложности: Вся низкоуровневая работа с FlaUI спрятана в методах расширений.

  2. Безопасность: Каждое действие начинается с проверки типа и состояния элемента.

  3. Переиспользование: Написанный один раз метод для работы с таблицей используется в десятках тестов.

  4. Читаемость: Тесты выглядят как последовательность бизнес-действий:

    // Вместо непонятного кода: grid.Rows[3].Cells["Name"].Click();  // Мы пишем ясный сценарий: grid.ClickCellWithValue("Name", "Иван Иванов"); 

Итог: Почему разделение на классы — это важно

Представьте, что ваш фреймворк — это кухня ресторана.

  • Одна куча всего (отсутствие разделения): Ножи, овощи, сырое мясо, готовые блюда и грязная посуда валяются на одном столе. Приготовить одно блюдо можно, но готовить каждый день, масштабироваться и соблюдать санитарию — невозможно.

  • Разделенная кухня (ваш подход): Есть зона для нарезки, зона для готовки, мойка, холодильник. Каждый инструмент и продукт на своем месте. Шеф-повар (тест) не чистит рыбу, он просто дает команды («приготовить это») и получает готовые компоненты.

Конкретные плюсы разделения:

1. Для Разработки (Прямо сейчас)

  • Скорость: не нужно каждый раз вспоминать, как работать с ComboBox во FlaUI. Вы просто вызываете SelectItemByText — это в 10 раз быстрее.

  • Безопасность: методы Ensure не дадут вам случайно попытаться кликнуть по текстовому полю как по кнопке. Ошибки отлавливаются на этапе написания теста.

  • Поиск: где искать логику для чекбокса? В CheckBoxExtensions. Все очевидно и предсказуемо.

2. Для Тестирования (Стабильность)

  • Централизованный контроль: если в приложении изменилось поведение (например, все элементы теперь дольше становятся активными), вам нужно поменять всего одно число в параметре по умолчанию в WaitExtensions, а не 100500 Thread.Sleep по всему коду.

  • Надежность: отработанная и протестированная логика ожиданий гарантированно применяется к каждому действию. Вы не зависите от внимательности автора теста.

3. Для Поддержки (Через 6 месяцев)

  • Читаемость: новый человек в команде смотрит на тест и сразу понимает, что он делает: EnterLogin, ClickSubmit. Ему не нужно разбираться в дебрях FlaUI.

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

  • Отладка: если падает ClickButton, вы точно знаете, где искать проблему — в ButtonExtensions. Вы изолировали проблему до одного маленького метода.

4. Для Масштабирования (Когда проект растет)

  • Добавление нового функционала: вам нужно протестировать новый слайдер? Вы создаете SliderExtensions с методами SetSliderValue, GetSliderValue. И все тесты сразу получают доступ к этому стабильному методу.

  • Переиспользование: написанный метод для поиска строки в таблице (FindRowInGrid) становится доступен всем. Не нужно его копировать.


Обработка динамических элементов: Лучшие практики

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

Почему Thread.Sleep — это антипаттерн?

// ПЛОХО: Хрупкий и неэффективный подход Thread.Sleep(5000); // Ждем 5 секунд всегда element.Click();    // Внезапно элемент может быть еще не готов 

Проблемы:

  • Потеря времени: Если элемент появился через 100мс, мы теряем 4.9 секунды

  • Ненадежность: Если элемент не появился за 5 секунд — тест падает

  • Непредсказуемость: В разных окружениях нужны разные таймауты

Правильный подход: Умные ожидания состояний

Основная идея: ждать не время, а конкретное состояние элемента.

// ХОРОШО: Ждем конкретного состояния public static bool WaitUntilClickable(this AutomationElement element, int timeoutMs = 5000) {     return Retry.WhileFalse(         () => element?.IsEnabled == true && element?.IsOffscreen == false,         TimeSpan.FromMilliseconds(timeoutMs)).Success; }  // Использование: if (element.WaitUntilClickable(5000)) {     element.Click(); // Гарантированно безопасный клик } 

Паттерн: «Ожидание → Действие → Верификация»

Этот паттерн делает тесты стабильными и предсказуемыми:

// 1. ОЖИДАНИЕ: Ждем, пока элемент станет готов progressBar.WaitUntilValueIs(targetValue);  // 2. ДЕЙСТВИЕ: Выполняем операцию (опционально) dataGrid.GetRowCount();  // 3. ВЕРИФИКАЦИЯ: Проверяем результат Assert.That(actualCount, Is.EqualTo(expectedCount)); 

Типовые сценарии и их решения

Сценарий 1: Ожидание завершения длительной операции

// Ждем заполнения прогресс-бара public void WaitForGenerationComplete(int expectedCount) {     var progressBar = _locators.UserGenerationProgressBar;     progressBar.WaitUntilValueIs(expectedCount); // Ждем конкретного значения } 

Сценарий 2: Работа с асинхронно подгружаемыми данными

// Ждем появления данных в таблице public int GetValidRowCount() {     var dataGrid = _locators.UsersCollectionDataGrid;          // Ждем, пока таблица не будет готова     if (dataGrid.WaitUntilEnabled(10000))     {         return dataGrid.GetRowCount(); // Только теперь получаем данные     }          throw new TimeoutException("Таблица не загрузилась за отведенное время"); } 

Сценарий 3: Заполнение формы с валидацией

// Последовательное заполнение с ожиданиями public void FillFormSafely(FormData data) {     // Каждое действие ждет готовности элемента     SetUserId(data.UserId);        // WaitUntilEnabled внутри     SetLastName(data.LastName);    // WaitUntilEnabled внутри     SetFirstName(data.FirstName);  // WaitUntilEnabled внутри          // Ждем применения валидации     WaitForValidation();          // Только теперь пытаемся отправить     ClickSubmitButton();           // WaitUntilClickable внутри } 

Ключевые принципы обработки динамики

Принцип 1: Явное лучше неявного
Всегда явно указывайте, чего вы ждете:

// Вместо неявного ожидания: Thread.Sleep(1000);  // Используйте явное: element.WaitUntilTextAppears("Завершено"); 

Принцип 2: Локальность ожиданий
Ожидайте минимально необходимого состояния:

// Вместо ожидания всей страницы: WaitForPageLoad();  // Ожидайте конкретный элемент: submitButton.WaitUntilClickable(); 

Принцип 3: Детальное логирование
Логируйте процесс ожидания для отладки:

public static bool WaitUntilClickable(this AutomationElement element, int timeoutMs = 5000) {     _logger.Info($"Ожидание кликабельности: {element.Properties.AutomationId}");     // ... логика ожидания     _logger.Info($"Элемент стал кликабельным: {success}");     return success; } 

Практические рекомендации

  1. Начинайте с простых ожиданий: WaitUntilEnabled, WaitUntilVisible

  2. Добавляйте сложные условия постепенно: комбинируйте несколько проверок

  3. Тестируйте на медленных окружениях: увеличивайте таймауты для production-сред

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

  5. Анализируйте логи: смотрите, какие ожидания занимают больше всего времени

Обработка динамических элементов — это не магия, а система правильных подходов:

  • Ждите состояний, а не времени

  • Используйте явные проверки вместо предположений

  • Логируйте процесс ожидания для отладки

  • Адаптируйте таймауты под конкретные сценарии

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


Заключение: Путь от хаоса к системе

Начиная работу с UI-автотестами, многие проходят через одни и те же этапы:

  1. Хаос: Thread.Sleep() повсюду, хрупкие локаторы, тесты падают от любого чиха

  2. Осознание: Понимание, что нужны ожидания состояний, а не временные паузы

  3. Система: Построение архитектуры с четким разделением ответственности

  4. Мастерство: Создание стабильных тестов, которые работают даже в сложных условиях

Главный вывод: Написание UI-автотестов — это не про «кликнуть и проверить». Это про проектирование надежной системы, которая:

  • Знает что искать (Locators)

  • Умеет ждать нужного состояния (WaitExtensions)

  • Безопасно взаимодействует (TypeExtensions)

  • Собирается в читаемые сценарии (Controllers)

  • Предсказуемо работает даже с динамическим UI

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

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

В следующих статьях мы подробно разберем:

Организация тестов

  • Как устроены наши тестовые сценарии

  • Работа с тестовыми данными

  • Обработка ошибок в тестах

  • Как мы пишем стабильные тесты

Логирование

  • Настройка и использование NLog

  • Как мы логируем действия в тестах

  • Структура наших логов

  • Анализ результатов тестов

Удачи в создании стабильных и надежных автотестов!


Традиционные советы о которых не просили

  • Начинайте с малого — внедряйте паттерны постепенно, не пытайтесь переписать все сразу. Возьмите один самый «хрупкий» тест и превратите его в эталонный.

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

  • Логируйте ВСЁ — каждый клик, каждое ожидание, каждый результат. Когда тест упадет через месяц, вы скажете себе спасибо.

  • Не изобретайте велосипед — смотрите как устроены успешные open-source проекты (вроде Selenium PageObjects), многое можно адаптировать под FlaUI.

💡 А если серьезно:
Автоматизация — это магия, превращающая рутину в искусство. Иногда нужно посмеяться над абсурдом ситуации («опять этот элемент не найден!»), чтобы сохранить здравый рассудок.

Подписывайтесь — вместе мы превратим эти мучения в удовольствие! Ну или хотя бы в менее болезненный опыт.

**Мы тут надолго... как тот ваш тест, который никак не дождётся элемента!** 🫠


Полезные ресурсы

  1. UIAutomationTestKit на GitHub

  2. Документация FlaUI

  3. Предыдущая статья UI-автотесты: как правильно организовать код и не сойти с ума


ссылка на оригинал статьи https://habr.com/ru/articles/943650/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *