Продолжаем серию статей про автоматизацию десктопных приложений. В первой части мы разбирали основы автоматизации, во второй - работу с элементами и ожиданиями.
В этой статье сосредоточимся на организации тестов: как структурировать тестовые сценарии, работать с данными, обрабатывать ошибки и писать стабильные тесты.
Если предыдущие статьи были про "как найти элемент" и "как с ним взаимодействовать", то сейчас поговорим о том, как организовать тесты так, чтобы они были читаемыми, поддерживаемыми и надежными.
Содержание
Работа с тестовыми данными - 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? Логи скажут, где искать проблему. Каждая строчка логов - это ваш тайный агент.
Тестируйте тесты - проверяйте сами, что ваши проверки реально ловят баги. Не позволяйте «красным кружкам» обманывать вас.
Документируйте ожидания - комментарии не для себя, а для коллег (и будущего себя). Объясняйте, зачем тест делает то, что делает.
Не паникуйте при падении - стабильные тесты = спокойный сон, меньше стресса, больше контроля.
Помните: Хорошо организованные тесты - это инвестиция в спокойствие и предсказуемость разработки. Они не тормозят процесс - они работают на вас.
Потому что тесты должны служить вам, а не вы им!
AleksSharkov
Я отказался от fluent потому что отладка с нем это целая история.
// Act - выполнение действий _mainWindowController .SetUserData(userData) .ClickRegistrationButton();Поставьте точку останова на методе ClickRegistrationButton и попробуйте отладить тест, затем SetUserData и сделайте тоже самое. Удобно ?
Aleksey_Zabrodin Автор
Согласен, отладка через Fluent это точно не подарок. Я сам через это проходил: когда в цепочке из 7 вызовов что-то ломается, шагать дебаггером действительно неудобно.
Я обычно делаю так: на этапе написания сценариев, пишу всё явно, шаг за шагом, чтобы было удобно дебажить.
Когда сценарий стабилизировался, тогда выношу шаги в Fluent-методы, чтобы тест читался. По сути, Fluent это не про "облегчить отладку", а про структуру и читаемость. Поэтому он работает хорошо, когда логика уже проверена.