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

Содержание

  1. Как устроены наши тестовые сценарии - Структура и принципы

  2. Fluent API - Тесты как живая речь

  3. Работа с тестовыми данными - JSON, параметризация и Data-Driven Testing

  4. Обработка ошибок в тестах - Try-Catch, логирование и восстановление

  5. Как мы пишем стабильные тесты - 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 меняет всё:

  1. Читаемость как код документации

// Читается как инструкция пользователю
_mainWindowController
    .OpenUserForm()
    .FillRequiredFields()
    .ValidateForm()
    .SubmitRegistration()
    .VerifySuccessMessage();
  1. Легкость расширения

// Легко добавить новые шаги
_mainWindowController
    .SetUserData(userData)
    .UploadAvatar("avatar.jpg")        // Новый шаг
    .SetNotificationPreferences()      // Еще один шаг
    .ClickRegistrationButton()
    .AssertRegistrationSuccess();
  1. Гибкость комбинирования

// Можно создавать переиспользуемые блоки
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 критически важен:

  1. Тесты как документация - любой разработчик понимает, что происходит

  2. Легкость поддержки - добавить новый шаг = добавить один метод

  3. Переиспользование - можно создавать библиотеки готовых сценариев

  4. Отладка - легко понять, на каком шаге произошла ошибка

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 для стабильности:

  1. Явные ожидания - каждый шаг может включать проверки готовности

  2. Легкое восстановление - можно добавить retry-логику в цепочку

  3. Четкая структура - легко понять, где может произойти сбой

  4. Переиспользование - стабильные блоки можно использовать в разных тестах

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 запускается ночью.

Что мы получили

  1. Читаемые тесты - каждый сценарий читается как маленькая история: что сделал пользователь, что проверяем.

  2. Fluent API - тесты легко пишутся, читаются и поддерживаются.

  3. Управляемые данные - всё централизовано, всё детерминировано, никаких случайных «падений от магии».

  4. Надежная диагностика - логи, скриншоты, контекст ошибок. Когда тест падает, вы сразу понимаете «где, кто и почему».

  5. Стабильное выполнение - изоляция, ожидания асинхронности, проверки состояния элементов. Тесты перестают быть капризными.

Ключевые принципы

  • Fluent API везде - тесты должны читаться как живая речь, а не как технические инструкции.

  • Один тест = одна проверка - больше одного сценария за раз? Только если хотите головной боли.

  • Данные отдельно от логики - фабрики, JSON, детерминированные генераторы. Никаких случайных «сюрпризов».

  • Ошибки - это информация - не игнорируем их, логируем всё и учимся на них.

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

Следующие шаги

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

  • Логирование и отчетность - как превратить логи в инструмент анализа.

  • CI/CD интеграция - как тесты становятся частью рабочего процесса и автоматически контролируют качество.

  • Продвинутые техники - работа с базами данных, API и файлами, управление нестабильными сценариями.


Практические советы

  • Начинайте с малого - один стабильный тест лучше десяти нестабильных. Не пытайтесь сразу проверить всё приложение - сначала поймайте уверенность в своих тестах.

  • Логируйте всё, без исключений - падение теста через месяц на CI? Логи скажут, где искать проблему. Каждая строчка логов - это ваш тайный агент.

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

  • Документируйте ожидания - комментарии не для себя, а для коллег (и будущего себя). Объясняйте, зачем тест делает то, что делает.

  • Не паникуйте при падении - стабильные тесты = спокойный сон, меньше стресса, больше контроля.

Помните: Хорошо организованные тесты - это инвестиция в спокойствие и предсказуемость разработки. Они не тормозят процесс - они работают на вас.

Потому что тесты должны служить вам, а не вы им!


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

  1. UIAutomationTestKit на GitHub

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

  3. Предыдущая статья: Работа с элементами

  4. Первая статья: Основы автоматизации

Комментарии (2)


  1. AleksSharkov
    20.10.2025 14:20

    Я отказался от fluent потому что отладка с нем это целая история.

    // Act - выполнение действий _mainWindowController .SetUserData(userData) .ClickRegistrationButton();

    Поставьте точку останова на методе ClickRegistrationButton и попробуйте отладить тест, затем SetUserData и сделайте тоже самое. Удобно ?


    1. Aleksey_Zabrodin Автор
      20.10.2025 14:20

      Согласен, отладка через Fluent это точно не подарок. Я сам через это проходил: когда в цепочке из 7 вызовов что-то ломается, шагать дебаггером действительно неудобно.
      Я обычно делаю так: на этапе написания сценариев, пишу всё явно, шаг за шагом, чтобы было удобно дебажить.
      Когда сценарий стабилизировался, тогда выношу шаги в Fluent-методы, чтобы тест читался. По сути, Fluent это не про "облегчить отладку", а про структуру и читаемость. Поэтому он работает хорошо, когда логика уже проверена.