Продолжаем серию статей про автоматизацию десктопных приложений. В первой части мы разбирали основы автоматизации, во второй - работу с элементами и ожиданиями.
В этой статье сосредоточимся на организации тестов: как структурировать тестовые сценарии, работать с данными, обрабатывать ошибки и писать стабильные тесты.
Если предыдущие статьи были про "как найти элемент" и "как с ним взаимодействовать", то сейчас поговорим о том, как организовать тесты так, чтобы они были читаемыми, поддерживаемыми и надежными.
Содержание
Работа с тестовыми данными - JSON, параметризация и Data-Driven Testing
Обработка ошибок в тестах - Try-Catch, логирование и восстановление
Как мы пишем стабильные тесты - Retry, изоляция и best practices
Как устроены наши тестовые сценарии
Философия: Тест как пользователь
Когда тесты перестают быть помощниками и превращаются в головную боль - это тревожный сигнал.
Каждый клик в UI превращается в загадку: почему тест падает? где искать ошибку?
В этот момент и начинается путь от хаоса к системе.
Наша цель - превратить тесты из набора действий в живую документацию поведения приложения.
Чтобы любой разработчик, открыв тест, понимал не "что делает код", а что именно делает пользователь и зачем. Тогда тест становится не просто проверкой, а частью логики продукта.
// ПЛОХО: Технический подход [Test] public void Test_TechnicalApproach() { var window = Application.Launch("MyApp.exe"); var textBox = window.FindFirstDescendant(cf => cf.ByAutomationId("username")); textBox.AsTextBox().Enter("user"); var button = window.FindFirstDescendant(cf => cf.ByAutomationId("login")); button.AsButton().Click(); // ... еще 50 строк технического кода } // ХОРОШО: Пользовательский подход [Test] public void Test_UserApproach() { _mainWindowController .EnterLogin("user") .EnterPassword("pass") .ClickLogin() .AssertWelcomeMessage(); }
Почему это важно:
Такой стиль делает тесты не только чище, но и долговечнее.
Когда тест описывает сценарий пользователя, его проще читать, поддерживать и адаптировать под изменения интерфейса.
Мы перестаём чинить тесты "по ощущениям" и начинаем понимать их логику.
Структура тестового класса
Когда подход понятен, важно структурировать тесты так, чтобы они были логичными не только внутри, но и на уровне всей архитектуры проекта.
Мы организуем тесты по принципу "один класс = одна функциональная область":
// UiAutoTests/Tests/UIAutomationTests/UserRegistrationTests.cs [TestFixture] public class UserRegistrationTests : InitializeBaseTest { private MainWindowController _mainWindowController; private string _testName; [SetUp] public void Setup() { _testName = TestContext.CurrentContext.Test.Name; _mainWindowController = new MainWindowController(_window); } [Test] public void Test1_ValidUserRegistration() { // Arrange - подготовка данных var userData = new UserRegistrationData { UserId = "testuser001", LastName = "Иванов", FirstName = "Иван" }; // Act - выполнение действий _mainWindowController .SetUserData(userData) .ClickRegistrationButton(); // Assert - проверка результата _mainWindowController.AssertRegistrationSuccess(); } }
Зачем так делать:
Разделение тестов по областям помогает локализовать ошибки.
Если падают тесты регистрации, мы сразу знаем, где искать проблему - в регистрационном модуле, а не по всему проекту.
Такое разделение экономит часы отладки и делает поведение тестов предсказуемым.
Принципы именования тестов
Сложно поддерживать сотни тестов, если их названия ничего не говорят. Поэтому мы выработали простое и рабочее правило:
Test{Номер}_{ОписаниеОжидаемогоРезультата}
[Test] public void Test1_ValidUserRegistration() { } // Успешная регистрация [Test] public void Test2_InvalidEmailFormat() { } // Неверный формат email [Test] public void Test3_EmptyRequiredFields() { } // Пустые обязательные поля [Test] public void Test4_DuplicateUserRegistration() { } // Дублирование пользователя [Test] public void Test5_RegistrationWithSpecialCharacters() { } // Специальные символы
Почему такая схема:
Номер - позволяет контролировать порядок выполнения
Описание - сразу понятно, что тестируем
Единообразие - легко найти нужный тест
Правильное имя теста - это мини-документация.
Когда тест упал, по названию сразу видно, что именно не работает, это кажется мелочью, но в больших проектах с сотнями тестов хорошее именование экономит часы.
Так тесты превращаются в самоописательные сценарии, а лог тестов - в живую документацию.
Группировка тестов по категориям
[TestFixture] [Category("Smoke")] // Критически важные тесты public class CriticalPathTests : InitializeBaseTest { [Test] public void Test1_ApplicationStartup() { } [Test] public void Test2_UserLogin() { } } [TestFixture] [Category("Regression")] // Полный набор проверок public class FullRegressionTests : InitializeBaseTest { // Все тесты функциональности } [TestFixture] [Category("Performance")] // Тесты производительности public class PerformanceTests : InitializeBaseTest { [Test] public void Test1_LoadTimeUnder5Seconds() { } }
Почему это важно:
Группировка позволяет гибко управлять запуском тестов:
— Запускать только smoke-тесты перед релизом
— Делать nightly regression
— Отдельно анализировать performance-сценариию.
Это повышает скорость обратной связи и снижает время тестового цикла.
В итоге у нас появляется гибкий инструмент управления автотестами —
от быстрого Smoke-запуска до полной Regression-сессии.
Паттерн AAA (Arrange-Act-Assert)
Каждый тест должен четко разделяться на три части:
[Test] public void Test1_UserRegistrationWithValidData() { // ARRANGE - Подготовка var userData = TestDataFactory.CreateValidUser(); _mainWindowController.ClearForm(); // ACT - Действие _mainWindowController .SetUserData(userData) .ClickRegistrationButton(); // ASSERT - Проверка _mainWindowController .AssertRegistrationSuccess() .AssertUserInList(userData); }
Почему это важно:
Читаемость - сразу понятно, что происходит
Отладка - легко найти, где проблема
Поддержка - простое добавление новых проверок
AAA делает тест не просто структурным, а похожим на короткий рассказ.
Когда тест написан в формате AAA, он становится универсальным - его можно читать, дополнять, расширять без страха что-то сломать.
Мы видим: кто герой (Arrange), что он делает (Act) и чем всё закончилось (Assert).
Такой формат понятен даже тем, кто не пишет код ежедневно - а значит, тесты становятся общим языком команды.
Каждый тест - это маленькая история о поведении пользователя.
Когда тесты структурированы, читаемы и понятны, они перестают быть просто ‘кодом для CI’.
Они становятся частью коллективных знаний команды о продукте.
Fluent API - Тесты как живая речь
Fluent API - это не просто "красивая цепочка методов". Это способ писать тесты так, чтобы они читались как естественная речь.
// ПЛОХО: Императивный стиль [Test] public void Test_ImperativeStyle() { _mainWindowController.SetUserId("user123"); _mainWindowController.SetLastName("Иванов"); _mainWindowController.SetFirstName("Иван"); _mainWindowController.ClickRegistrationButton(); _mainWindowController.AssertRegistrationSuccess(); } // ХОРОШО: Fluent стиль [Test] public void Test_FluentStyle() { _mainWindowController .SetUserId("user123") .SetLastName("Иванов") .SetFirstName("Иван") .ClickRegistrationButton() .AssertRegistrationSuccess(); }
Почему Fluent API меняет всё:
Читаемость как код документации
// Читается как инструкция пользователю _mainWindowController .OpenUserForm() .FillRequiredFields() .ValidateForm() .SubmitRegistration() .VerifySuccessMessage();
Легкость расширения
// Легко добавить новые шаги _mainWindowController .SetUserData(userData) .UploadAvatar("avatar.jpg") // Новый шаг .SetNotificationPreferences() // Еще один шаг .ClickRegistrationButton() .AssertRegistrationSuccess();
Гибкость комбинирования
// Можно создавать переиспользуемые блоки public MainWindowController RegisterValidUser() { return this .SetValidDataInUserForm() .ClickRegistrationButton() .AssertRegistrationSuccess(); } // И использовать их в разных тестах [Test] public void Test1_SimpleRegistration() => _mainWindowController.RegisterValidUser(); [Test] public void Test2_RegistrationWithEmail() => _mainWindowController .RegisterValidUser() .SetEmail("test@example.com") .VerifyEmailSent();
Создание Fluent API в контроллерах
// UiAutoTests/Controllers/MainWindowController.cs public class MainWindowController { private readonly AutomationElement _window; private readonly MainWindowLocators _locators; public MainWindowController(AutomationElement window) { _window = window; _locators = new MainWindowLocators(_window); } // Fluent методы для действий public MainWindowController SetUserId(string userId) { _locators.UserIdTextBox.EnterText(userId); return this; // Возвращаем this для цепочки } public MainWindowController SetLastName(string lastName) { _locators.UserLastNameTextBox.EnterText(lastName); return this; } public MainWindowController SetFirstName(string firstName) { _locators.UserFirstNameTextBox.EnterText(firstName); return this; } public MainWindowController ClickRegistrationButton() { _locators.RegistrationButton.ClickButton(); return this; } // Fluent методы для проверок public MainWindowController AssertRegistrationSuccess() { Assert.That(_locators.SuccessMessage.IsVisible(), Is.True); return this; } public MainWindowController AssertUserInList(UserRegistrationData userData) { var userInList = _locators.UsersDataGrid.FindRowByCellValue("UserId", userData.UserId); Assert.That(userInList, Is.Not.Null); return this; } }
Fluent API для сложных сценариев
// Сложный сценарий в Fluent стиле [Test] public void Test_ComplexUserRegistrationFlow() { _mainWindowController .OpenUserRegistrationForm() .SetPersonalData(TestDataFactory.CreateValidUser()) .SetContactInfo(TestDataFactory.CreateContactInfo()) .SetPreferences(TestDataFactory.CreateUserPreferences()) .ValidateForm() .SubmitRegistration() .WaitForConfirmation() .AssertRegistrationSuccess() .AssertWelcomeEmailSent() .AssertUserProfileCreated() .Logout(); }
Почему Fluent API критически важен:
Тесты как документация - любой разработчик понимает, что происходит
Легкость поддержки - добавить новый шаг = добавить один метод
Переиспользование - можно создавать библиотеки готовых сценариев
Отладка - легко понять, на каком шаге произошла ошибка
Fluent API превращает тесты из технических инструкций в живые сценарии пользователя.
Это не просто "красиво" - это фундамент для создания поддерживаемой и масштабируемой автоматизации.
Работа с тестовыми данными
Философия: тесты не должны зависеть от данных
Самая частая причина флейковых тестов - хаос в данных.
Если каждый тест генерирует свои случайные значения, кто-то меняет JSON вручную, а кто-то хардкодит имена прямо в коде,
в какой-то момент ты уже не знаешь, что именно проверяется.
Наша цель - управлять данными централизованно,
чтобы тесты были стабильными, воспроизводимыми и осмысленными.
Проблема: Данные разбросаны по коду
При изменении структуры данных нужно править десятки тестов.
Ошибки дублируются.
Невозможно гарантировать, что тесты используют корректные значения.
// ПЛОХО: Данные захардкожены в тестах [Test] public void Test1_Registration() { _mainWindowController .SetUserId("user123") .SetLastName("Петров") .SetFirstName("Петр") .ClickRegistration(); } [Test] public void Test2_Registration() { _mainWindowController .SetUserId("user456") // Другое значение, но та же логика .SetLastName("Сидоров") .SetFirstName("Сидор") .ClickRegistration(); }
Решение: Централизованное управление данными
1. Fluent фабрика тестовых данных
Зачем нужна Fluent фабрика:
Все данные создаются по единым правилам с возможностью гибкой настройки.
Если структура UserRegistrationData изменилась - достаточно обновить фабрику.
Код тестов остаётся чистым и лаконичным, а создание данных - интуитивным.
// UiAutoTests/TestCasesData/TestDataFactory.cs public static class TestDataFactory { // Базовый метод для создания пользователя public static UserRegistrationData CreateValidUser() { return new UserRegistrationData { UserId = GenerateUniqueId(), LastName = "Иванов", FirstName = "Иван", Email = "ivan@test.com" }; } // Fluent методы для настройки данных public static UserRegistrationDataBuilder CreateUser() { return new UserRegistrationDataBuilder(); } public static UserRegistrationData CreateInvalidUser() { return new UserRegistrationData { UserId = "", // Пустой ID LastName = "Тест", FirstName = "Тест", Email = "invalid-email" // Неверный формат }; } public static UserRegistrationData CreateUserWithSpecialCharacters() { return new UserRegistrationData { UserId = "user@#$%", LastName = "Тест-Тест", FirstName = "Тест'Тест", Email = "test+tag@domain.com" }; } private static string GenerateUniqueId() { return $"user_{DateTime.Now:yyyyMMdd_HHmmss}_{Random.Shared.Next(1000, 9999)}"; } } // Fluent Builder для гибкого создания данных public class UserRegistrationDataBuilder { private UserRegistrationData _userData; public UserRegistrationDataBuilder() { _userData = new UserRegistrationData { UserId = TestDataFactory.GenerateUniqueId(), LastName = "Тест", FirstName = "Тест", Email = "test@example.com" }; } public UserRegistrationDataBuilder WithId(string userId) { _userData.UserId = userId; return this; } public UserRegistrationDataBuilder WithName(string firstName, string lastName) { _userData.FirstName = firstName; _userData.LastName = lastName; return this; } public UserRegistrationDataBuilder WithEmail(string email) { _userData.Email = email; return this; } public UserRegistrationDataBuilder WithSpecialCharacters() { _userData.UserId = "user@#$%"; _userData.LastName = "Тест-Тест"; _userData.FirstName = "Тест'Тест"; return this; } public UserRegistrationData Build() { return _userData; } }
Использование Fluent фабрики в тестах:
// Простое создание var user = TestDataFactory.CreateValidUser(); // Fluent создание с настройкой var customUser = TestDataFactory .CreateUser() .WithName("Петр", "Петров") .WithEmail("petr@company.com") .Build(); // Сложный сценарий var specialUser = TestDataFactory .CreateUser() .WithId("special_user_001") .WithSpecialCharacters() .WithEmail("special+test@domain.com") .Build(); // Использование в тестах [Test] public void Test_RegistrationWithCustomUser() { var user = TestDataFactory .CreateUser() .WithName("Алексей", "Забродин") .WithEmail("alexey@test.com") .Build(); _mainWindowController .SetUserData(user) .ClickRegistrationButton() .AssertRegistrationSuccess(); }
2. JSON-конфигурация для сложных сценариев
Почему JSON полезен:
Данные можно редактировать без перекомпиляции.
Удобно хранить большие наборы кейсов.
Такой формат легко подключается к CI - можно передавать тестовые сценарии как внешние конфигурации.
// UiAutoTests/TestDataJson/registrationCases.json { "validCases": [ { "testName": "Standard User", "userId": "user001", "lastName": "Иванов", "firstName": "Иван", "email": "ivan@test.com", "expectedResult": "success" }, { "testName": "User with Middle Name", "userId": "user002", "lastName": "Петров-Сидоров", "firstName": "Петр", "email": "petr.sidorov@test.com", "expectedResult": "success" } ], "invalidCases": [ { "testName": "Empty User ID", "userId": "", "lastName": "Тест", "firstName": "Тест", "email": "test@test.com", "expectedResult": "validation_error" }, { "testName": "Invalid Email Format", "userId": "user003", "lastName": "Тест", "firstName": "Тест", "email": "not-an-email", "expectedResult": "validation_error" } ] }
3. Загрузка данных из JSON
Смысл:
Ты отделяешь “что тестировать” от “как тестировать”.
Теперь тестовый сценарий - это просто описание,
а код - механизм, который умеет его исполнять.
// UiAutoTests/TestCasesData/RegistrationCaseFromJson.cs public class RegistrationCaseFromJson { public string TestName { get; set; } public string UserId { get; set; } public string LastName { get; set; } public string FirstName { get; set; } public string Email { get; set; } public string ExpectedResult { get; set; } public UserRegistrationData ToUserData() { return new UserRegistrationData { UserId = UserId, LastName = LastName, FirstName = FirstName, Email = Email }; } } // UiAutoTests/TestCasesData/TestDataFromJson.cs public static class TestDataFromJson { public static List<RegistrationCaseFromJson> LoadValidCases() { var jsonPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestDataJson", "registrationCases.json"); var json = File.ReadAllText(jsonPath); var data = JsonSerializer.Deserialize<TestDataContainer>(json); return data.ValidCases; } public static List<RegistrationCaseFromJson> LoadInvalidCases() { var jsonPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestDataJson", "registrationCases.json"); var json = File.ReadAllText(jsonPath); var data = JsonSerializer.Deserialize<TestDataContainer>(json); return data.InvalidCases; } }
Параметризованные тесты
Зачем параметризация:
Один тестовый метод может проверить десятки комбинаций,
не дублируя логику.
Это снижает количество кода и повышает покрытие.
1. Простая параметризация с [Values]
[Test] public void Test_RegistrationWithDifferentUserIds([Values("user1", "user2", "user3")] string userId) { _mainWindowController .SetUserId(userId) .SetLastName("Тест") .SetFirstName("Тест") .ClickRegistrationButton() .AssertRegistrationSuccess(); }
2. Параметризация с [TestCase]
[TestCase("user1", "Иванов", "Иван", "ivan@test.com", "success")] [TestCase("", "Петров", "Петр", "petr@test.com", "validation_error")] [TestCase("user3", "Сидоров", "Сидор", "invalid-email", "validation_error")] public void Test_RegistrationWithTestCases(string userId, string lastName, string firstName, string email, string expectedResult) { _mainWindowController .SetUserId(userId) .SetLastName(lastName) .SetFirstName(firstName) .SetEmail(email) .ClickRegistrationButton(); if (expectedResult == "success") _mainWindowController.AssertRegistrationSuccess(); else _mainWindowController.AssertValidationError(); }
3. Параметризация с JSON-данными
Когда все тесты используют общие фабрики и источники данных,
ты получаешь мощную систему, где можно добавлять сценарии простым обновлением JSON.
[Test] [TestCaseSource(nameof(GetValidTestCases))] public void Test_ValidRegistrationFromJson(RegistrationCaseFromJson testCase) { _mainWindowController .SetUserData(testCase.ToUserData()) .ClickRegistrationButton() .AssertRegistrationSuccess(); } [Test] [TestCaseSource(nameof(GetInvalidTestCases))] public void Test_InvalidRegistrationFromJson(RegistrationCaseFromJson testCase) { _mainWindowController .SetUserData(testCase.ToUserData()) .ClickRegistrationButton() .AssertValidationError(); } private static IEnumerable<RegistrationCaseFromJson> GetValidTestCases() { return TestDataFromJson.LoadValidCases(); } private static IEnumerable<RegistrationCaseFromJson> GetInvalidTestCases() { return TestDataFromJson.LoadInvalidCases(); }
Управление состоянием данных
Проблема: Тесты влияют друг на друга через общие данные
// ПЛОХО: Тесты используют одни и те же данные [Test] public void Test1_RegisterUser() { /* регистрирует user001 */ } [Test] public void Test2_LoginUser() { /* пытается войти как user001 */ } // Может упасть, если Test1 не прошел
Решение: Изоляция данных между тестами
Почему изоляция критична:
Один тест не должен зависеть от результата другого.
Если данные пересекаются - появляются флейки, нестабильность и ложные алерты.
Изоляция делает каждый тест самостоятельным и надёжным.
[Test] public void Test1_RegisterUser() { var uniqueUser = TestDataFactory.CreateValidUser(); // Уникальные данные _mainWindowController .SetUserData(uniqueUser) .ClickRegistrationButton() .AssertRegistrationSuccess(); } [Test] public void Test2_LoginUser() { var loginUser = TestDataFactory.CreateValidUser(); // Свои уникальные данные _mainWindowController .SetLoginData(loginUser) .ClickLoginButton() .AssertLoginSuccess(); }
Вывод
Когда тестовые данные управляются централизованно,
проект становится устойчивее к изменениям и ошибок становится меньше.
Хорошие тестовые данные - это фундамент стабильной автоматизации.
А когда управление данными становится частью архитектуры —
тесты превращаются из набора скриптов в систему, которой можно доверять.
Обработка ошибок в тестах
Философия: Ошибка - это информация, а не конец света
Наш подход: каждая ошибка должна быть залогирована, проанализирована и, по возможности, обработана.
Fluent обработка ошибок
В тестах ошибки неизбежны. Важно воспринимать их не как конец света, а как источник информации. Fluent API позволяет создавать элегантную и читаемую обработку ошибок.
Почему Fluent API для обработки ошибок:
Код обработки ошибок становится читаемым и понятным;
Легко добавлять новые типы диагностики;
Обработка ошибок интегрируется в общий стиль тестов.
[Test] public void Test1_UserRegistrationWithFluentErrorHandling() { _mainWindowController .SetValidDataInUserForm() .AssertIsRegistrationButtonEnabled() .ClickRegistrationButton() .AssertRegistrationSuccess() .OnSuccess(() => _loggerHelper.LogCompletedResult(_testName, _reportService)) .OnFailure(exception => { _loggerHelper.LogFailedResult(_testName, exception, _reportService); CaptureScreenshotOnFailure(); throw; }); }
Fluent методы для обработки ошибок в контроллере:
// UiAutoTests/Controllers/MainWindowController.cs public class MainWindowController { public MainWindowController OnSuccess(Action successAction) { try { successAction?.Invoke(); } catch (Exception ex) { _logger.Error($"Ошибка в success callback: {ex.Message}"); } return this; } public MainWindowController OnFailure(Action<Exception> failureAction) { // Этот метод будет вызван при исключении в цепочке _failureHandler = failureAction; return this; } public MainWindowController WithRetry(int maxAttempts = 3) { _maxRetryAttempts = maxAttempts; return this; } public MainWindowController WithScreenshotOnFailure() { _captureScreenshotOnFailure = true; return this; } // Пример использования в тесте [Test] public void Test_RegistrationWithFluentErrorHandling() { _mainWindowController .WithRetry(3) .WithScreenshotOnFailure() .SetValidDataInUserForm() .ClickRegistrationButton() .AssertRegistrationSuccess() .OnSuccess(() => _logger.Info("Регистрация прошла успешно")) .OnFailure(ex => { _logger.Error($"Ошибка регистрации: {ex.Message}"); throw; }); } }
Структура обработки ошибок
В тестах ошибки неизбежны. Правильная обработка ошибок позволяет:
быстро понять, где и почему сломался тест;
собирать контекст для анализа багов;
повышать стабильность автотестов, не тратя время на угадывания.
[Test] public void Test1_UserRegistration() { try { // Основная логика теста _mainWindowController .SetValidDataInUserForm() .AssertIsRegistrationButtonEnabled() .ClickRegistrationButton() .AssertRegistrationSuccess(); // Логирование успеха _loggerHelper.LogCompletedResult(_testName, _reportService); } catch (Exception exception) { // Логирование ошибки с контекстом _loggerHelper.LogFailedResult(_testName, exception, _reportService); // Дополнительная диагностика CaptureScreenshotOnFailure(); // Проброс исключения для NUnit throw; } }
Централизованное логирование ошибок
// UiAutoTests/Helpers/LoggerHelper.cs public class LoggerHelper { private static readonly ILogger _logger = LogManager.GetCurrentClassLogger(); public void LogEnteringTheMethod() { _logger.Info($"Вход в метод: {GetCallerMethodName()}"); } public void LogCompletedResult(string testName, IReporter reportService) { _logger.Info($"Тест '{testName}' завершен успешно"); reportService.AddTestResult(testName, "PASSED", "Тест выполнен успешно"); } public void LogFailedResult(string testName, Exception exception, IReporter reportService) { _logger.Error(exception, $"Тест '{testName}' завершен с ошибкой: {exception.Message}"); // Детальная информация об ошибке _logger.Error($"Stack trace: {exception.StackTrace}"); // Контекст приложения на момент ошибки LogApplicationState(); reportService.AddTestResult(testName, "FAILED", exception.Message); } private void LogApplicationState() { try { var activeWindow = Application.GetMainWindow(); if (activeWindow != null) { _logger.Info($"Активное окно: {activeWindow.Title}"); _logger.Info($"Размер окна: {activeWindow.BoundingRectangle}"); } } catch (Exception ex) { _logger.Warn($"Не удалось получить состояние приложения: {ex.Message}"); } } }
Почему мы логируем всё:
Каждый тест может упасть через месяц или на CI-сервере, где нет возможности “посмотреть на экран”. Логи + скриншоты позволяют понять контекст без повторного запуска теста.
Создание скриншотов при ошибках
// UiAutoTests/Helpers/ScreenshotHelper.cs public static class ScreenshotHelper { public static void CaptureScreenshotOnFailure(string testName) { try { var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); var fileName = $"Screenshot_{testName}_{timestamp}.png"; var screenshotPath = Path.Combine(GetScreenshotsDirectory(), fileName); // Скриншот всего экрана var screenshot = ScreenCapture.CaptureScreen(); screenshot.ToFile(screenshotPath); _logger.Info($"Скриншот сохранен: {screenshotPath}"); } catch (Exception ex) { _logger.Error($"Не удалось создать скриншот: {ex.Message}"); } } public static void CaptureElementScreenshot(AutomationElement element, string testName) { try { var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); var fileName = $"Element_{testName}_{timestamp}.png"; var screenshotPath = Path.Combine(GetScreenshotsDirectory(), fileName); var elementScreenshot = element.Capture(); elementScreenshot.ToFile(screenshotPath); _logger.Info($"Скриншот элемента сохранен: {screenshotPath}"); } catch (Exception ex) { _logger.Error($"Не удалось создать скриншот элемента: {ex.Message}"); } } private static string GetScreenshotsDirectory() { var screenshotsDir = Path.Combine(TestContext.CurrentContext.TestDirectory, "Screenshots"); Directory.CreateDirectory(screenshotsDir); return screenshotsDir; } }
Зачем нужны скриншоты:
Скриншоты помогают визуально увидеть состояние UI в момент ошибки. Особенно полезно при проверке сложных интерфейсов: формы, таблицы, всплывающие окна.
Обработка специфических типов ошибок
Разные ошибки требуют разных подходов. Таймауты, отсутствующие элементы и ошибки проверок - это три типичных сценария, для которых мы делаем отдельную обработку.
Это ускоряет диагностику и уменьшает время на исправление багов.
[Test] public void Test1_UserRegistrationWithErrorHandling() { try { _mainWindowController .SetValidDataInUserForm() .ClickRegistrationButton() .AssertRegistrationSuccess(); } catch (TimeoutException ex) { // Специфическая обработка таймаутов _logger.Error($"Таймаут при выполнении теста: {ex.Message}"); CaptureScreenshotOnFailure("timeout"); throw new TestTimeoutException($"Тест превысил максимальное время выполнения: {ex.Message}"); } catch (ElementNotFoundException ex) { // Обработка отсутствующих элементов _logger.Error($"Элемент не найден: {ex.Message}"); LogAvailableElements(); // Логируем доступные элементы для отладки throw; } catch (AssertionException ex) { // Обработка ошибок проверок _logger.Error($"Ошибка проверки: {ex.Message}"); CaptureElementScreenshot(_mainWindowController.GetLastInteractedElement(), "assertion_failed"); throw; } catch (Exception ex) { // Общая обработка всех остальных ошибок _logger.Error($"Неожиданная ошибка: {ex.Message}"); CaptureScreenshotOnFailure("unexpected_error"); throw; } }
Retry-механизм для нестабильных тестов
Некоторые тесты нестабильны из-за асинхронных операций или задержек UI.
Вместо того чтобы паниковать, мы даем тесту шанс повториться несколько раз, при этом фиксируя все попытки и очищая состояние перед каждой новой попыткой.
[Test] [Retry(3)] // Повторить до 3 раз при неудаче public void Test1_UnstableOperation() { try { // Операция, которая может быть нестабильной _mainWindowController .PerformComplexOperation() .AssertOperationSuccess(); } catch (Exception ex) { _logger.Warn($"Попытка {TestContext.CurrentContext.CurrentRepeatCount} неудачна: {ex.Message}"); // Очистка состояния перед повтором _mainWindowController.ResetToInitialState(); throw; // NUnit автоматически повторит тест } }
Восстановление после ошибок
Иногда тест можно “подхватить” после неудачи. Восстановление позволяет:
не падать полностью при мелких сбоях;
проверять критически важные сценарии даже в нестабильной среде;
собирать информацию о потенциальных проблемах интерфейса или данных.
[Test] public void Test1_RegistrationWithRecovery() { try { _mainWindowController .SetValidDataInUserForm() .ClickRegistrationButton() .AssertRegistrationSuccess(); } catch (Exception ex) { _logger.Error($"Ошибка в основном сценарии: {ex.Message}"); // Попытка восстановления try { _logger.Info("Попытка восстановления..."); _mainWindowController.ResetForm(); // Повторная попытка с упрощенными данными _mainWindowController .SetMinimalValidData() .ClickRegistrationButton() .AssertRegistrationSuccess(); _logger.Info("Восстановление успешно"); } catch (Exception recoveryEx) { _logger.Error($"Восстановление не удалось: {recoveryEx.Message}"); throw; // Если восстановление не удалось, падаем } } }
Как мы пишем стабильные тесты
Принцип: Предсказуемость превыше скорости
Стабильный тест - это не тот, который работает быстро, а тот, который всегда дает одинаковый результат.
Цель стабильного теста - минимизировать «флейки», когда тест падает не из-за бага, а из-за условий среды или данных.
Fluent API для стабильных тестов
Fluent API не только делает тесты читаемыми, но и помогает создавать более стабильные тесты.
Цепочка методов позволяет легко добавлять проверки состояния, ожидания и восстановление после ошибок.
// Fluent подход к стабильному тесту [Test] public void Test_StableRegistrationWithFluentAPI() { _mainWindowController .WaitUntilApplicationReady() // Ожидание готовности .ClearForm() // Очистка состояния .SetValidDataInUserForm() // Заполнение данных .ValidateForm() // Проверка валидности .ClickRegistrationButton() // Действие .WaitForConfirmation() // Ожидание результата .AssertRegistrationSuccess() // Проверка успеха .AssertUserInList() // Дополнительная проверка .CleanupAfterTest(); // Очистка после теста }
Преимущества Fluent API для стабильности:
Явные ожидания - каждый шаг может включать проверки готовности
Легкое восстановление - можно добавить retry-логику в цепочку
Четкая структура - легко понять, где может произойти сбой
Переиспользование - стабильные блоки можно использовать в разных тестах
1. Изоляция тестов
Проблема: Тесты влияют друг на друга, результат одного может ломать другой
// ПЛОХО: Тесты зависят от порядка выполнения [Test] public void Test1_CreateUser() { /* создает пользователя */ } [Test] public void Test2_DeleteUser() { /* удаляет пользователя из Test1 */ }
Решение: Каждый тест создаёт свои данные и завершает работу, возвращая систему в исходное состояние.
[Test] public void Test1_CreateUser() { var uniqueUser = TestDataFactory.CreateValidUser(); _mainWindowController .SetUserData(uniqueUser) .ClickRegistrationButton() .AssertRegistrationSuccess(); // Тест завершается, состояние очищается } [Test] public void Test2_DeleteUser() { var userToDelete = TestDataFactory.CreateValidUser(); // Сначала создаем пользователя для удаления _mainWindowController .SetUserData(userToDelete) .ClickRegistrationButton() .AssertRegistrationSuccess(); // Затем удаляем его _mainWindowController .SelectUser(userToDelete.UserId) .ClickDeleteButton() .AssertUserDeleted(); }
Почему важно: Изолированные тесты проще отлаживать, они предсказуемы и могут выполняться в любом порядке, даже параллельно.
2. Детерминированные данные
// ПЛОХО: Случайные данные могут вызывать нестабильность тестов. public static UserRegistrationData CreateRandomUser() { return new UserRegistrationData { UserId = $"user_{Random.Shared.Next(1000, 9999)}", // Случайно! LastName = "Тест", FirstName = "Тест" }; } // ХОРОШО: Используем детерминированные данные или генераторы, которые возвращают одинаковые значения для одного сценария. public static UserRegistrationData CreateDeterministicUser(int testNumber) { return new UserRegistrationData { UserId = $"user_{testNumber:D3}", // Всегда одинаково для одного номера LastName = "Тест", FirstName = "Тест" }; }
Почему важно: Тест становится воспроизводимым - падение можно повторить и изучить.
3. Ожидание готовности системы
[Test] public void Test1_StableUserRegistration() { // Ждем полной загрузки приложения _mainWindowController.WaitUntilApplicationReady(); // Очищаем форму перед тестом _mainWindowController.ClearForm(); // Ждем, пока форма станет доступной _mainWindowController.WaitUntilFormReady(); // Выполняем тест _mainWindowController .SetValidDataInUserForm() .ClickRegistrationButton() .AssertRegistrationSuccess(); }
4. Обработка асинхронных операций
[Test] public void Test1_AsyncDataLoading() { _mainWindowController .ClickLoadDataButton() .WaitUntilDataLoaded() // Явное ожидание загрузки .AssertDataDisplayed() .AssertDataCount(ExpectedCount); } В контроллере проверяем стабильность данных несколько раз, чтобы убедиться, что асинхронная загрузка завершена. public MainWindowController WaitUntilDataLoaded(int timeoutMs = 10000) { var dataGrid = _locators.UsersCollectionDataGrid; // Ждем, пока таблица не перестанет обновляться var lastRowCount = 0; var stableCount = 0; for (int i = 0; i < timeoutMs / 100; i++) { var currentRowCount = dataGrid.GetRowCount(); if (currentRowCount == lastRowCount) { stableCount++; if (stableCount >= 3) // 3 проверки подряд = данные стабильны break; } else { stableCount = 0; lastRowCount = currentRowCount; } Thread.Sleep(100); } return this; }
5. Валидация состояния перед действиями
[Test] public void Test1_SafeButtonClick() { var button = _locators.RegistrationButton; // Проверяем, что кнопка существует if (button == null) throw new ElementNotFoundException("Кнопка регистрации не найдена"); // Проверяем, что кнопка доступна if (!button.IsEnabled) throw new InvalidOperationException("Кнопка регистрации недоступна"); // Проверяем, что кнопка видима if (button.IsOffscreen) throw new InvalidOperationException("Кнопка регистрации не видна"); // Только теперь кликаем button.Click(); }
Исключаем падения теста из-за «пустых» или недоступных элементов, а ошибки становятся понятными.
6. Использование транзакций для отката изменений
Зачем: Состояние приложения остаётся чистым для следующих тестов, что повышает предсказуемость.
[Test] public void Test1_RegistrationWithRollback() { var initialUserCount = _mainWindowController.GetUserCount(); try { _mainWindowController .SetValidDataInUserForm() .ClickRegistrationButton() .AssertRegistrationSuccess(); // Проверяем, что пользователь добавился var newUserCount = _mainWindowController.GetUserCount(); Assert.That(newUserCount, Is.EqualTo(initialUserCount + 1)); } finally { // Откатываем изменения (удаляем созданного пользователя) _mainWindowController .DeleteLastCreatedUser() .AssertUserDeleted(); // Проверяем, что состояние восстановлено var finalUserCount = _mainWindowController.GetUserCount(); Assert.That(finalUserCount, Is.EqualTo(initialUserCount)); } }
7. Параллельное выполнение тестов
Тесты могут выполняться одновременно, если они полностью изолированы.
Совет: Используйте уникальные данные для каждого параллельного теста.
[TestFixture] [Parallelizable(ParallelScope.Children)] // Тесты в классе могут выполняться параллельно public class ParallelUserTests : InitializeBaseTest { [Test] public void Test1_UserRegistration() { /* использует уникальные данные */ } [Test] public void Test2_UserLogin() { /* использует другие уникальные данные */ } [Test] public void Test3_UserProfile() { /* использует третьи уникальные данные */ } } // Важно: Тесты, зависящие друг от друга, должны выполняться последовательно. [TestFixture] [Parallelizable(ParallelScope.None)] // Отключаем параллельность public class SequentialTests : InitializeBaseTest { [Test] public void Test1_SetupDatabase() { } [Test] public void Test2_UseDatabase() { } // Зависит от Test1 [Test] public void Test3_CleanupDatabase() { } // Зависит от Test2 }
8. Мониторинг производительности
Зачем: Позволяет находить медленные сценарии и оптимизировать приложение, а также фиксировать потенциальные узкие места.
[Test] [MaxTime(5000)] // Тест должен завершиться за 5 секунд public void Test1_PerformanceCriticalOperation() { var stopwatch = Stopwatch.StartNew(); try { _mainWindowController .PerformComplexOperation() .AssertOperationSuccess(); } finally { stopwatch.Stop(); _logger.Info($"Операция выполнена за {stopwatch.ElapsedMilliseconds}мс"); // Логируем медленные операции if (stopwatch.ElapsedMilliseconds > 3000) { _logger.Warn($"Медленная операция: {stopwatch.ElapsedMilliseconds}мс"); } } }
9. Конфигурируемые таймауты
Это даёт гибкость на разных средах - локально, на CI/CD или на медленном сервере.
// UiAutoTests/Configuration/TestConfiguration.cs public static class TestConfiguration { public static int DefaultTimeout => int.Parse(Environment.GetEnvironmentVariable("TEST_TIMEOUT") ?? "5000"); public static int LongOperationTimeout => int.Parse(Environment.GetEnvironmentVariable("LONG_OPERATION_TIMEOUT") ?? "30000"); public static bool EnableScreenshots => bool.Parse(Environment.GetEnvironmentVariable("ENABLE_SCREENSHOTS") ?? "true"); } // Использование в тестах [Test] public void Test1_ConfigurableTimeout() { _mainWindowController .SetValidDataInUserForm() .ClickRegistrationButton(TestConfiguration.DefaultTimeout) .AssertRegistrationSuccess(); }
Заключение: От хаоса к системе
Поздравляю! Если вы дошли до этого места, значит, вы уже почти готовы перестать плеваться на падение тестов и начать использовать их как инструмент, а не источник стресса.
Автоматизация - это не просто красиво разложенные файлы и тестовые методы. Это система, которая работает на вас, экономит время и позволяет спать спокойно, даже когда CI/CD запускается ночью.
Что мы получили
Читаемые тесты - каждый сценарий читается как маленькая история: что сделал пользователь, что проверяем.
Fluent API - тесты легко пишутся, читаются и поддерживаются.
Управляемые данные - всё централизовано, всё детерминировано, никаких случайных «падений от магии».
Надежная диагностика - логи, скриншоты, контекст ошибок. Когда тест падает, вы сразу понимаете «где, кто и почему».
Стабильное выполнение - изоляция, ожидания асинхронности, проверки состояния элементов. Тесты перестают быть капризными.
Ключевые принципы
Fluent API везде - тесты должны читаться как живая речь, а не как технические инструкции.
Один тест = одна проверка - больше одного сценария за раз? Только если хотите головной боли.
Данные отдельно от логики - фабрики, JSON, детерминированные генераторы. Никаких случайных «сюрпризов».
Ошибки - это информация - не игнорируем их, логируем всё и учимся на них.
Стабильность превыше скорости - лучше медленный, но надежный тест, чем быстрый и непредсказуемый.
Следующие шаги
В следующих статьях мы подробно разберём:
Логирование и отчетность - как превратить логи в инструмент анализа.
CI/CD интеграция - как тесты становятся частью рабочего процесса и автоматически контролируют качество.
Продвинутые техники - работа с базами данных, API и файлами, управление нестабильными сценариями.
Практические советы
Начинайте с малого - один стабильный тест лучше десяти нестабильных. Не пытайтесь сразу проверить всё приложение - сначала поймайте уверенность в своих тестах.
Логируйте всё, без исключений - падение теста через месяц на CI? Логи скажут, где искать проблему. Каждая строчка логов - это ваш тайный агент.
Тестируйте тесты - проверяйте сами, что ваши проверки реально ловят баги. Не позволяйте «красным кружкам» обманывать вас.
Документируйте ожидания - комментарии не для себя, а для коллег (и будущего себя). Объясняйте, зачем тест делает то, что делает.
Не паникуйте при падении - стабильные тесты = спокойный сон, меньше стресса, больше контроля.
Помните: Хорошо организованные тесты - это инвестиция в спокойствие и предсказуемость разработки. Они не тормозят процесс - они работают на вас.
Потому что тесты должны служить вам, а не вы им!
