Продолжаем серию статей про автоматизацию десктопных приложений. В первой части мы разбирали основы автоматизации.
Во второй мы сосредоточимся на практике: поиске элементов во 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.
? А если серьезно:
Автоматизация - это магия, превращающая рутину в искусство. Иногда нужно посмеяться над абсурдом ситуации ("опять этот элемент не найден!"), чтобы сохранить здравый рассудок.
Подписывайтесь - вместе мы превратим эти мучения в удовольствие! Ну или хотя бы в менее болезненный опыт.
**Мы тут надолго... как тот ваш тест, который никак не дождётся элемента!** ?
AleksSharkov
Привет, на FlaUI.WebDriver пробовали переход? Там подобие XPath прикрутили