Продолжаем серию статей про автоматизацию десктопных приложений. В первой части мы разбирали основы автоматизации.
Во второй мы сосредоточимся на практике: поиске элементов во FlaUI, умных ожиданиях, безопасной работе с контролами и приемах для динамического UI. Все примеры — из нашего UIAutomationTestKit.
Если в первой статье мы обсуждали «зачем» автоматизировать тестирование, то сегодня поговорим о том — как сделать так, чтобы ваши тесты были не только рабочими,
но и стабильными, поддерживаемыми и масштабируемыми.
Содержание
-
Как мы ищем элементы с помощью FlaUI — Стратегии приоритетов локаторов
-
Наши подходы к ожиданию элементов — WaitExtensions и умные ожидания
-
Работа с разными типами UI элементов — TypeExtensions и безопасные взаимодействия
-
Обработка динамических элементов — Лучшие практики для нестабильного 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"); }
Итог по ожиданиям:
-
Мы явно ждем состояния, а не просто наличия.
IsEnabled && !IsOffscreen— наша магия. -
Мы встраиваем ожидание в само действие. Это избавляет тесты от кусков кода с ожиданиями.
-
Мы используем централизованную
Retry-логику. Легко настроить таймауты и интервалы для всего проекта. -
Мы логируем каждое ожидание. Это критически важно для отладки падающих тестов.
Благодаря этому подходу, наш тестовый сценарий остается чистым и читаемым, а вся сложная логика синхронизации спрятана под капотом во вспомогательных методах.
// Чистый и стабильный тест _mainWindowController .EnterLogin("user") // внутри есть ожидание .EnterPassword("pass") // внутри есть ожидание .ClickLogin() // внутри есть ожидание .AssertWelcomeMessage(); // и здесь тоже есть ожидание
Наша система ожиданий — это не просто замена Thread.Sleep. Это инструмент прогнозирования поведения приложения.
Мы не ждем произвольное время, а точно знаем, какого состояния должен достичь элемент, чтобы тест мог продолжить работу.
Это, в сочетании с детальным логированием и визуальной диагностикой, превращает процесс отладки из рутины в быстрое и даже приятное занятие.
Работа с разными типами UI элементов
Одна из главных сильных сторон FlaUI — это единый API для работы с разными технологиями (WinForms, WPF, UWP) и типами элементов. Наша задача — использовать эту силу, создав универсальные и безопасные методы взаимодействия.
Наш главный принцип: «Один элемент — один метод расширения»
Мы не используем «голые» вызовы типа element.AsTextBox().Enter("text") прямо в тестах. Вместо этого мы создали для каждого типа элементов свой класс расширений с проверенными и стабильными методами.
Базовый паттерн: Ensure + Wait + Act
Каждое наше взаимодействие с элементом строится по этой схеме:
-
Ensure — убедиться, что элемент действительно того типа, который мы ожидаем.
-
Wait — дождаться его готовности к взаимодействию.
-
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}"; }
Итог подхода:
-
Инкапсуляция сложности: Вся низкоуровневая работа с FlaUI спрятана в методах расширений.
-
Безопасность: Каждое действие начинается с проверки типа и состояния элемента.
-
Переиспользование: Написанный один раз метод для работы с таблицей используется в десятках тестов.
-
Читаемость: Тесты выглядят как последовательность бизнес-действий:
// Вместо непонятного кода: grid.Rows[3].Cells["Name"].Click(); // Мы пишем ясный сценарий: grid.ClickCellWithValue("Name", "Иван Иванов");
Итог: Почему разделение на классы — это важно
Представьте, что ваш фреймворк — это кухня ресторана.
-
Одна куча всего (отсутствие разделения): Ножи, овощи, сырое мясо, готовые блюда и грязная посуда валяются на одном столе. Приготовить одно блюдо можно, но готовить каждый день, масштабироваться и соблюдать санитарию — невозможно.
-
Разделенная кухня (ваш подход): Есть зона для нарезки, зона для готовки, мойка, холодильник. Каждый инструмент и продукт на своем месте. Шеф-повар (тест) не чистит рыбу, он просто дает команды («приготовить это») и получает готовые компоненты.
Конкретные плюсы разделения:
1. Для Разработки (Прямо сейчас)
-
Скорость: не нужно каждый раз вспоминать, как работать с
ComboBoxво FlaUI. Вы просто вызываетеSelectItemByText— это в 10 раз быстрее. -
Безопасность: методы
Ensureне дадут вам случайно попытаться кликнуть по текстовому полю как по кнопке. Ошибки отлавливаются на этапе написания теста. -
Поиск: где искать логику для чекбокса? В
CheckBoxExtensions. Все очевидно и предсказуемо.
2. Для Тестирования (Стабильность)
-
Централизованный контроль: если в приложении изменилось поведение (например, все элементы теперь дольше становятся активными), вам нужно поменять всего одно число в параметре по умолчанию в
WaitExtensions, а не 100500Thread.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; }
Практические рекомендации
-
Начинайте с простых ожиданий:
WaitUntilEnabled,WaitUntilVisible -
Добавляйте сложные условия постепенно: комбинируйте несколько проверок
-
Тестируйте на медленных окружениях: увеличивайте таймауты для production-сред
-
Используйте разные стратегии: для разных типов элементов нужны разные подходы
-
Анализируйте логи: смотрите, какие ожидания занимают больше всего времени
Обработка динамических элементов — это не магия, а система правильных подходов:
-
✅ Ждите состояний, а не времени
-
✅ Используйте явные проверки вместо предположений
-
✅ Логируйте процесс ожидания для отладки
-
✅ Адаптируйте таймауты под конкретные сценарии
Эти принципы помогут вам создавать стабильные и надежные тесты, которые работают даже в самых сложных динамических сценариях. Помните: хороший тест не просто выполняет действия, а интеллектуально ожидает нужных состояний системы.
Заключение: Путь от хаоса к системе
Начиная работу с UI-автотестами, многие проходят через одни и те же этапы:
-
Хаос:
Thread.Sleep()повсюду, хрупкие локаторы, тесты падают от любого чиха -
Осознание: Понимание, что нужны ожидания состояний, а не временные паузы
-
Система: Построение архитектуры с четким разделением ответственности
-
Мастерство: Создание стабильных тестов, которые работают даже в сложных условиях
Главный вывод: Написание UI-автотестов — это не про «кликнуть и проверить». Это про проектирование надежной системы, которая:
-
Знает что искать (
Locators) -
Умеет ждать нужного состояния (
WaitExtensions) -
Безопасно взаимодействует (
TypeExtensions) -
Собирается в читаемые сценарии (
Controllers) -
Предсказуемо работает даже с динамическим UI
Ваша цель — не просто написать тест, а создать саморегулирующуюся систему, которая адаптируется к изменениям и предоставляет точную обратную связь о состоянии приложения.
Этот подход требует первоначальных инвестиций в архитектуру, но окупается: вы получаете не набор хрупких скриптов, а надежный фундамент для автоматизации, который масштабируется вместе с вашим проектом.
В следующих статьях мы подробно разберем:
Организация тестов
-
Как устроены наши тестовые сценарии
-
Работа с тестовыми данными
-
Обработка ошибок в тестах
-
Как мы пишем стабильные тесты
Логирование
-
Настройка и использование NLog
-
Как мы логируем действия в тестах
-
Структура наших логов
-
Анализ результатов тестов
Удачи в создании стабильных и надежных автотестов!
Традиционные советы о которых не просили
-
Начинайте с малого — внедряйте паттерны постепенно, не пытайтесь переписать все сразу. Возьмите один самый «хрупкий» тест и превратите его в эталонный.
-
Сначала пишите WaitExtensions, потом тесты — создайте библиотеку ожиданий прежде чем начинать писать много тестов. Это окупится в десятки раз.
-
Логируйте ВСЁ — каждый клик, каждое ожидание, каждый результат. Когда тест упадет через месяц, вы скажете себе спасибо.
-
Не изобретайте велосипед — смотрите как устроены успешные open-source проекты (вроде Selenium PageObjects), многое можно адаптировать под FlaUI.
💡 А если серьезно:
Автоматизация — это магия, превращающая рутину в искусство. Иногда нужно посмеяться над абсурдом ситуации («опять этот элемент не найден!»), чтобы сохранить здравый рассудок.
Подписывайтесь — вместе мы превратим эти мучения в удовольствие! Ну или хотя бы в менее болезненный опыт.
**Мы тут надолго... как тот ваш тест, который никак не дождётся элемента!** 🫠
Полезные ресурсы
ссылка на оригинал статьи https://habr.com/ru/articles/943650/
Добавить комментарий