АШШумилкин А. О.ОАиП · ПиТПМ
ПиТПМ · Лекция 04

Лекция №4. Автоматизация сквозного (E2E) тестирования интерфейсов и программных интерфейсов приложений (REST API)

8 008 слов12 разделов0 иллюстраций
↓ Скачать Markdown

Введение

В предыдущих лекциях мы освоили модульное тестирование — мощный инструмент, позволяющий проверять корректность отдельных методов и классов. Мы научились изолировать тестируемый код от внешних зависимостей с помощью заглушек (Stub) и имитаций (Mock), добиваясь быстрых, стабильных и детерминированных проверок. Это дало нам уверенность в том, что каждый «винтик» нашей программы работает правильно.

§4.1 От модулей к интеграции: зачем нужно тестировать всю систему целиком и полностью

Однако программное обеспечение — это не просто набор изолированных винтиков. Это сложный механизм, где детали взаимодействуют друг с другом, обмениваются данными и вызывают друг друга в строго определённой последовательности. И здесь нас поджидает фундаментальная проблема:

Аналогия с автомобилем

Представьте себе автомобиль. Вы проверили двигатель на стенде — он работает идеально. Вы проверили тормозную систему — она безупречна. Вы проверили электронику — все датчики показывают верные значения. Но когда вы собираете всё вместе и нажимаете на педаль газа, машина дёргается, глохнет или не реагирует на повороты руля. Почему? Потому что проблема не в отдельных деталях, а в том, как они соединены друг с другом — в проводке, в гидравлических шлангах, в порядке срабатывания блоков управления.

Рассмотрим конкретный пример на C#:

csharp

// Модуль А: сохраняет заказ в базу данных
public class OrderRepository
{
    public void Save(Order order)
    {
        // Здесь код сохранения в БД...
        // Работает корректно
    }
}

// Модуль Б: отправляет email-уведомление
public class EmailService
{
    public void SendConfirmation(string email, int orderId)
    {
        // Здесь код отправки письма...
        // Работает корректно
    }
}

// Модуль В: обрабатывает заказ (использует А и Б)
public class OrderProcessor
{
    private readonly OrderRepository _repository;
    private readonly EmailService _emailService;

    public OrderProcessor(OrderRepository repository, EmailService emailService)
    {
        _repository = repository;
        _emailService = emailService;
    }

    public void Process(Order order)
    {
        // ПОТЕНЦИАЛЬНАЯ ОШИБКА ИНТЕГРАЦИИ:
        // Сначала отправляем письмо, потом сохраняем в БД
        // А должно быть наоборот!
        _emailService.SendConfirmation(order.CustomerEmail, order.Id);
        _repository.Save(order);
    }
}

В этом примере:

  • Метод Save() работает правильно.
  • Метод SendConfirmation() работает правильно.
  • Но метод Process() содержит логическую ошибку интеграции: он сначала отправляет письмо, а только потом сохраняет заказ.

Что произойдёт?

Если сохранение в БД упадёт? Клиент получит письмо «Ваш заказ принят», но на самом деле заказ не сохранился. Это катастрофа с точки зрения бизнеса и доверия пользователей.

Модульные тесты не смогут найти эту ошибку, потому что они тестируют каждый модуль изолированно. Они проверяют, что Save() работает, и что SendConfirmation() работает, но они никогда не проверяют, в каком порядке эти методы вызываются в реальном сценарии.

Именно для решения этой проблемы существует сквозное (End-to-End, E2E) тестирование.

E2E-тестирование

— это подход, при котором мы проверяем не отдельные модули, а полный бизнес-сценарий — от начала до конца — точно так же, как это делает реальный пользователь или внешняя система.

Пример E2E-сценария для интернет-магазина:

  1. Пользователь открывает сайт.
  2. Авторизуется под своим логином и паролем.
  3. Ищет товар по ключевому слову.
  4. Добавляет товар в корзину.
  5. Переходит к оформлению заказа.
  6. Вводит адрес доставки и данные карты.
  7. Подтверждает заказ.
  8. Получает email-подтверждение.

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

Главное отличие

Модульные тесты отвечают на вопрос «Правильно ли мы построили каждый кирпичик?», а E2E-тесты — на вопрос «Может ли пользователь построить дом из этих кирпичиков?»

Сравнение подходов:

ХарактеристикаМодульные тесты (Unit)E2E-тесты
Что проверяютЛогику одного метода/классаПолный сценарий пользователя
СкоростьМиллисекундыСекунды или минуты
СтабильностьВысокая (изолированы)Могут быть нестабильными (зависят от сети, БД, браузера)
КоличествоМного (сотни/тысячи)Мало (десятки)
Сложность написанияНизкаяВысокая
Что находятОшибки в алгоритмахОшибки интеграции, логики последовательности, внешнего поведения
Кто пишетРазработчикиQA-инженеры / Инженеры по автоматизации

Важное замечание

Ошибочно думать, что нужно автоматизировать всё. Это привело бы к огромным затратам времени и нестабильным тестам, которые постоянно падают из-за изменения интерфейса.

Правильный подход

— использовать E2E-тесты только для критических бизнес-путей (Happy Path), то есть для тех сценариев, которые:

  • Приносят компании деньги (оформление заказа, оплата, регистрация нового клиента).
  • Могут привести к серьёзным репутационным или финансовым потерям в случае сбоя.
  • Используются большинством пользователей ежедневно.

Всё остальное (проверка валидации форм, сложные расчёты, обработка ошибок) должно покрываться модульными и интеграционными тестами.

Ограничения E2E-тестов

  1. Нестабильность (Flakiness). E2E-тесты зависят от множества внешних факторов: скорость интернета, загрузка браузера, анимации, доступность тестовой базы данных.
  2. Сложность отладки. Если E2E-тест упал, вы не всегда сразу понимаете, в каком месте проблема.
  3. Время выполнения. Сто E2E-тестов могут выполняться час и более.
  4. Стоимость поддержки. При изменении интерфейса приходится переписывать E2E-тесты.

§4.2 Пирамида тестирования и место E2E: почему не нужно автоматизировать всё

В предыдущем параграфе мы выяснили, что E2E-тесты необходимы для проверки полных пользовательских сценариев. Однако у начинающих тестировщиков возникает естественный вопрос: «Если E2E-тесты такие мощные, почему бы не написать их для всего приложения?»

Ответ кроется в фундаментальном экономическом законе тестирования: каждый тип тестов имеет свою стоимость, скорость и надёжность.

Если вы попытаетесь покрыть E2E-тестами всё подряд, ваш проект очень быстро превратится в медленный, нестабильный и дорогой в поддержке кошмар.

Чтобы понять, как правильно распределять усилия, существует классическая модель — Пирамида тестирования, предложенная Майком Коном (Mike Cohn) в книге «Succeeding with Agile».

Пирамида тестирования

Уровень 1: Модульные тесты (Unit Tests)

Основание пирамиды

— самое широкое. Это проверка отдельных методов и классов в изоляции от внешнего мира.

Характеристики:

  • Скорость: миллисекунды на тест. Тысячи тестов выполняются за секунды.
  • Стоимость: очень дёшево писать и поддерживать.
  • Надёжность: почти 100% стабильности, потому что нет внешних зависимостей.
  • Количество: их должно быть МНОГО (до 70% всех тестов проекта).

Модульные тесты проверяют всю бизнес-логику, все условия, все граничные значения, все исключения. Каждый метод должен иметь минимум 3-5 тестов.

csharp

// Пример: юнит-тест для метода расчёта скидки
[Theory]
[InlineData(100, 10, 90)]   // 10% скидка от 100 = 90
[InlineData(50, 0, 50)]     // 0% скидка = 50
[InlineData(0, 50, 0)]      // 50% скидка от 0 = 0
public void CalcDiscount_VariousInput_ReturnCorrect(
    decimal price, decimal percent, decimal expected)
{
    var service = new DiscountService();
    var result = service.ApplyDiscount(price, percent);
    Assert.Equal(expected, result);
}

Уровень 2: Интеграционные тесты / Тесты API

Средний уровень пирамиды. Они занимаются проверкой взаимодействия между несколькими модулями или сервисами. Тесты отправляют реальные запросы к API, работают с тестовой базой данных, но без пользовательского интерфейса.

Характеристики:

  • Скорость: секунды на тест (запрос к БД или API быстрее, чем рендеринг страницы).
  • Стоимость: средняя стоимость написания и поддержки.
  • Надёжность: более стабильны, чем UI-тесты, потому что нет проблем с анимациями и ожиданием загрузки элементов.
  • Количество: их должно быть УМЕРЕННО (около 20% всех тестов).

Данные тесты проверяют:

  • Корректность API-эндпоинтов
  • Структуру ответов
  • Статус-коды
  • Работу с базой данных
  • Интеграцию с внешними сервисами

csharp

// Пример: интеграционный тест для REST API
[Fact]
public async Task CreateOrder_ValidData_ReturnsOrderId()
{
    // Arrange
    var client = new HttpClient();
    var orderData = new { ProductId = 1, Quantity = 2 };
    var content = new StringContent(
        JsonSerializer.Serialize(orderData), 
        Encoding.UTF8, 
        "application/json"
    );

    // Act
    var response = await client.PostAsync(
        "https://localhost:5001/api/orders", 
        content
    );
    var responseString = await response.Content.ReadAsStringAsync();
    var json = JsonDocument.Parse(responseString);

    // Assert
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    Assert.True(json.RootElement.TryGetProperty("orderId", out var id));
    Assert.NotEqual(0, id.GetInt32());
}

Обратите внимание

Мы не открываем браузер, не кликаем по кнопкам, не ждём загрузки страницы. Мы просто отправляем HTTP-запрос и проверяем, что сервер ответил корректно. Это намного быстрее и стабильнее, чем UI-тест.

Уровень 3: E2E / UI тесты

Вершина пирамиды

— самое узкое место. Это полная проверка пользовательского сценария через реальный браузер или мобильное приложение. Имитирует поведение реального пользователя.

Характеристики:

  • Скорость: десятки секунд или минуты на тест (запуск браузера, загрузка страниц, ожидание анимаций).
  • Стоимость: дорого писать и очень дорого поддерживать.
  • Надёжность: низкая — тесты часто падают из-за проблем, не связанных с кодом (Flaky-тесты).
  • Количество: их должно быть МАЛО (не более 10% всех тестов).

Они занимаются только самыми критичными бизнес-путями, где ошибка стоит компании денег или репутации.

csharp

// Пример: E2E-тест для сценария "Покупка товара" (только Happy Path)
[Fact]
public void PurchaseFlow_ValidUser_CompletesSuccessfully()
{
    // Arrange
    var driver = new ChromeDriver();
    driver.Navigate().GoToUrl("https://myshop.com");

    // Act
    var homePage = new HomePage(driver);
    var productPage = homePage.SearchForProduct("iPhone 15");
    var cartPage = productPage.AddToCart();
    var paymentPage = cartPage.ProceedToCheckout();
    var confirmationPage = paymentPage.PayWithCard("1234", "12/25", "123");

    // Assert
    Assert.True(confirmationPage.IsOrderConfirmed());
    Assert.Contains("Спасибо за покупку!", confirmationPage.GetSuccessMessage());
}

Selenium WebDriver: сердце UI-автоматизации

В примере выше вы могли заметить странный объект driver типа ChromeDriver. Именно он является сердцем UI-автоматизации в .NET.

Selenium WebDriver

— это библиотека (набор классов и методов), которая позволяет управлять браузером программно. С её помощью мы можем:

  • Открывать и закрывать браузер.
  • Переходить по URL-адресам.
  • Находить элементы на странице (кнопки, поля ввода, ссылки).
  • Кликать по элементам, вводить текст, выбирать пункты из списков.
  • Читать текст, атрибуты и состояния элементов.

Главная идея Selenium заключается в том, что он использует нативные драйверы для каждого браузера. Это значит, что Selenium не эмулирует браузер, а реально управляет им через его собственный API.

Когда вы пишете:

csharp

driver.FindElement(By.Id("submit")).Click();

Происходит следующее:

  1. Ваш C#-код вызывает метод Selenium.
  2. Selenium преобразует вызов в HTTP-команду и отправляет её драйверу браузера (например, ChromeDriver).
  3. Драйвер, в свою очередь, передаёт эту команду реальному браузеру через его внутренний протокол (Chrome DevTools Protocol для Chrome).
  4. Браузер выполняет действие (находит элемент и кликает по нему).
  5. Результат возвращается обратно по той же цепочке.

Перевёрнутая пирамида: самая распространённая ошибка

Самая распространённая ошибка начинающих команд — это перевёрнутая пирамида, когда E2E-тестов становится больше, чем модульных.

Перевёрнутая пирамида (НЕПРАВИЛЬНО!)

Последствия такой стратегии:

  1. Сборка проекта длится часы вместо минут.
  2. Тесты падают постоянно из-за таймаутов, смены дизайна или проблем с окружением.
  3. Разработчики перестают доверять тестам и игнорируют красные отчёты.
  4. Поддержка тестов съедает всё время, вместо того чтобы приносить пользу.

Золотое правило

Для каждого E2E-теста должно быть как минимум 5-10 модульных тестов, покрывающих ту же функциональность на разных уровнях.

Пример неправильного подхода:

csharp

// ✕ ПЛОХО: 100 E2E-тестов для проверки валидации пароля
[Theory]
[InlineData("admin", "1234", "Пароль должен содержать 6 символов")]
[InlineData("admin", "password", "Добро пожаловать")]
// ... и так далее 100 раз
public void Login_AllValidationCases_ShowsCorrectMessage(
    string login, string pass, string mess)
{
    // Запуск браузера, ввод логина, ожидание сообщения...
    // Каждый тест занимает секунды!
}

Что произойдёт?

  • Каждый тест будет запускать браузер (медленно).
  • Каждый тест будет ждать загрузки страницы (нестабильно).
  • Если дизайнер поменяет цвет кнопки или расположение поля, все 100 тестов упадут.

Правильный подход:

csharp // ✓ ХОРОШО: только один E2E-тест для успешного сценария [Fact] public void Login_ValidCredentials_RedirectsToDashboard() { var driver = new ChromeDriver(); var loginPage = new LoginPage(driver); var dashboard = loginPage.LoginAs("admin", "password123"); Assert.True(dashboard.IsUserLoggedIn()); }

csharp // ✓ ХОРОШО: модульные тесты для валидации логики [Theory] [InlineData("", "password123", "Введите логин")] [InlineData("admin", "", "Введите пароль")] [InlineData("admin", "123", "Пароль должен содержать 6 символов")] // ... 100 тестов, которые работают за миллисекунды public void Login_VariousInputs_ReturnsCorrectError( string login, string password, string error) { var validator = new LoginValidator(); var result = validator.Validate(login, password); Assert.Equal(expectedError, result.ErrorMessage); } [/TABS]

Проблема Flaky-тестов и WebDriverWait

Flaky-тест

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

  • Страница загружалась на 0,3 секунды дольше обычного.
  • Анимация не успела завершиться до того, как тест попытался кликнуть по кнопке.
  • Сетевое соединение с тестовой БД было нестабильным.
  • Параллельно запустился другой тест и изменил данные в той же таблице.

Именно для решения этой проблемы в Selenium WebDriver существует специальный механизм — WebDriverWait.

WebDriverWait

— это класс в Selenium, который реализует умное ожидание. Вместо того чтобы заставлять тест спать фиксированное количество секунд (что всегда плохо), WebDriverWait периодически проверяет некоторое условие и продолжает выполнение, как только условие становится истинным.

`csharp // ✕ ПЛОХО: тест с "жестким" ожиданием [Fact] public void ClickButton_ShowsMessage() { var driver = new ChromeDriver(); driver.Navigate().GoToUrl("https://site.com");

// ПЛОХО: ждём ровно 3 секунды Thread.Sleep(3000);

var button = driver.FindElement(By.Id("submit")); button.Click();

// Тест может упасть, если страница загружалась 3.5 секунды var message = driver.FindElement(By.Id("success")); Assert.True(message.Displayed); } `

`csharp // ✓ ХОРОШО: тест с "умным" ожиданием [Fact] public void ClickButton_ShowsMessage() { var driver = new ChromeDriver(); driver.Navigate().GoToUrl("https://site.com");

// Умное ожидание: ждём, пока кнопка станет кликабельной var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10)); var button = wait.Until(d => d.FindElement(By.Id("submit"))); button.Click();

// Ждём появления сообщения var message = wait.Until(d => d.FindElement(By.Id("success"))); Assert.True(message.Displayed); } ` [/TABS]

Преимущество WebDriverWait

В этом примере мы не ждём фиксированное время (3 секунды), а ждём условие — пока кнопка станет доступной для клика. Если кнопка появится через 0.5 секунды — тест продолжит выполнение сразу. Если через 5 секунд — подождёт 5 секунд. Если через 11 секунд — тест упадёт с понятной ошибкой «Элемент не появился за 10 секунд».

§4.3 Введение в тестирование REST API: проверяем логику без интерфейса

В предыдущих параграфах мы говорили о пирамиде тестирования и выяснили, что E2E-тесты через браузер — это вершина, которой должно быть немного. Но как же тогда проверять, что бизнес-логика работает корректно, если мы не хотим открывать тяжёлый браузер для каждого теста?

Ответ прост: мы тестируем API напрямую, минуя пользовательский интерфейс.

Что такое API и REST API?

API (Application Programming Interface)

— это набор правил и инструментов, с помощью которых одна программа может общаться с другой. Проще говоря, это «дверь», в которую стучатся, чтобы получить данные или отправить команду.

REST API

— это один из самых популярных стилей построения API для веб-приложений. Он использует обычный протокол HTTP (тот самый, по которому работают сайты) и обменивается данными в формате JSON (реже — XML).

Ключевая идея

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

Аналогия с рестораном:

  • API — это меню и официант, который принимает заказы.
  • Клиент API — это посетитель (браузер, мобильное приложение, другой сервер).
  • Сервер — это кухня, где готовят блюда.

Независимо от того, как одет посетитель (какой у него интерфейс), официант принимает заказ по одному и тому же принципу. Точно так же независимо от того, открыли вы сайт в Chrome или в мобильном приложении, запросы к API будут одинаковыми.

Типичный REST API

Типичный REST API состоит из набора URL-адресов (эндпоинтов), каждый из которых отвечает за конкретную операцию. Например, для интернет-магазина:

Метод HTTPЭндпоинтЧто делает
GET/api/productsПолучить список всех товаров
GET/api/products/{id}Получить товар с указанным ID
POST/api/ordersСоздать новый заказ
PUT/api/orders/{id}Обновить существующий заказ
DELETE/api/orders/{id}Удалить заказ

Пример запроса и ответа:

http

# Запрос (то, что отправляет клиент):
POST /api/orders HTTP/1.1
Host: myshop.com
Content-Type: application/json

{
    "productId": 1,
    "quantity": 2,
    "customerEmail": "ivan@example.com"
}

# Ответ (то, что возвращает сервер):
HTTP/1.1 200 OK
Content-Type: application/json

{
    "orderId": 12345,
    "totalAmount": 2000.00,
    "status": "Created"
}

Обратите внимание

Здесь нет ни кнопок, ни полей ввода, ни CSS-стилей. Это чистые данные в структурированном формате.

Почему API-тесты лучше UI-тестов?

Многие начинающие тестировщики задают вопрос: «Зачем тестировать API, если мы всё равно в конце проверим всё через браузер?»

Ответ кроется в трёх ключевых преимуществах:

1. Скорость

UI-тест должен открыть браузер, загрузить страницу, дождаться всех анимаций, отрендерить элементы, а потом только начать взаимодействие. Это занимает секунды, а иногда и десятки секунд.

API-тест отправляет один HTTP-запрос (который занимает миллисекунды) и проверяет ответ.

`csharp // UI-тест: минуты для 100 тестов [Fact] public void CreateOrder_UI_Test() { var driver = new ChromeDriver(); driver.Navigate().GoToUrl("https://myshop.com");

// Поиск кнопки, ожидание загрузки, клики, переходы... // Это занимает 5-10 секунд } `

`csharp // API-тест: секунды для 100 тестов [Fact] public async Task CreateOrder_API_Test() { var client = new HttpClient();

// Отправили запрос → получили ответ за 100 мс var response = await client.PostAsync(...); } ` [/TABS]

2. Стабильность

API-тесты работают с данными, а не с пикселями. Пока эндпоинт и формат JSON не меняются, тест будет стабильным. Дизайнеры могут перерисовать сайт хоть трижды в неделю — API-тесты останутся зелёными.

3. Раннее тестирование (Shift-Left)

В современной разработке часто используют подход, когда бэкенд (API) разрабатывается параллельно или даже раньше фронтенда (UI). Пока дизайнеры рисуют макеты и верстальщики пишут HTML/CSS, бэкенд-разработчики уже могут предоставить рабочий API.

Мы можем начать тестировать бизнес-логику до того, как появился интерфейс. Это соответствует принципу раннего тестирования (Shift-Left), который мы обсуждали в первой лекции. Чем раньше мы найдём дефект, тем дешевле его исправить.

Пример API-теста

Рассмотрим простой пример: тест проверяет создание заказа через POST-запрос.

csharp

[Fact]
public async Task CreateOrder_ValidData_ReturnsOk()
{
    // Создаём HTTP-клиент для отправки запросов
    var client = new HttpClient();
    
    var orderData = new
    {
        ProductId = 1,              // ID товара
        Quantity = 2,               // Количество
        CustomerEmail = "test@example.com"  // Email покупателя
    };
    
    // Превращаем объект в JSON-строку
    var json = JsonSerializer.Serialize(orderData);
    
    // Упаковываем JSON в HTTP-сообщение с правильным заголовком
    var content = new StringContent(
        json, 
        Encoding.UTF8, 
        "application/json"
    );
    
    var response = await client.PostAsync(
        "https://localhost:5001/api/orders",  // URL эндпоинта
        content  // Тело запроса
    );
    
    // Главная проверка: статус-код должен быть 200 (OK)
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

Что здесь важно понять:

  1. Мы не открываем браузер. Нет ChromeDriver, нет Navigate().GoToUrl(). Мы просто отправляем HTTP-запрос.
  2. Мы не проверяем внешний вид. Нет CSS-селекторов, нет проверки видимости элементов.
  3. Мы проверяем данные. Статус-код 200 говорит: «Сервер принял и обработал запрос».
  4. Тест выполняется за миллисекунды. В отличие от UI-теста, который ждёт загрузки страницы.

Что проверяет профессиональный API-тест?

Проверка статус-кода — это только начало. Профессиональный API-тест проверяет гораздо больше:

1. Проверка структуры JSON-ответа

csharp

// Проверяем, что в ответе есть поле "orderId" с числовым значением
var responseString = await response.Content.ReadAsStringAsync();
var jsonDoc = JsonDocument.Parse(responseString);
var root = jsonDoc.RootElement;

Assert.True(root.TryGetProperty("orderId", out var idElement));
Assert.Equal(JsonValueKind.Number, idElement.ValueKind);
Assert.NotEqual(0, idElement.GetInt32());

2. Проверка полного содержимого

csharp

// Проверяем, что ответ содержит ожидаемые данные
var responseString = await response.Content.ReadAsStringAsync();
var order = JsonSerializer.Deserialize<OrderResponse>(responseString);

Assert.Equal(12345, order.OrderId);
Assert.Equal(2000.00m, order.TotalAmount);
Assert.Equal("Created", order.Status);

3. Проверка ошибок (негативные сценарии)

csharp

[Fact]
public async Task CreateOrder_InvalidProductId_ReturnsBadRequest()
{
    var client = new HttpClient();
    
    // несуществующий товар
    var invalidData = new { ProductId = 999, Quantity = 2 };
    var json = JsonSerializer.Serialize(invalidData);
    var content = new StringContent(json, Encoding.UTF8, "application/json");
    
    var response = await client.PostAsync(
        "https://localhost:5001/api/orders", 
        content
    );
    
    // Ожидаем ошибку 400 (Bad Request)
    Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    
    // Проверяем, что в ответе есть сообщение об ошибке
    var errorText = await response.Content.ReadAsStringAsync();
    Assert.Contains("Товар не найден", errorText);
}

4. Проверка заголовков ответа

csharp

// Проверяем, что сервер вернул правильный Content-Type
Assert.Equal(
    "application/json", 
    response.Content.Headers.ContentType.MediaType
);

// Проверяем, что сервер вернул правильный Content-Length
Assert.NotNull(response.Content.Headers.ContentLength);

Сравнение UI-тестов и API-тестов

ХарактеристикаUI-тесты (Selenium)API-тесты (HttpClient)
Что проверяютВнешний вид, поведение интерфейсаЛогику, данные, бизнес-процессы
СкоростьМедленные (секунды — минуты)Быстрые (миллисекунды — секунды)
СтабильностьНизкая (зависит от CSS, анимаций)Высокая (стабильный JSON)
СложностьВысокая (нужно ждать элементы)Низкая (просто отправка запросов)
Зависимость от дизайнаДа (CSS-классы, вёрстка)Нет (JSON-схема стабильна)
Можно тестировать раньше UIНетДа (API готово раньше)
Находит баги в логикеКосвенноНапрямую
Находит баги в вёрсткеДаНет (этому нужны UI-тесты)

Важный вывод

API-тесты не заменяют UI-тесты. Они решают разные задачи:

  • API-тесты отвечают на вопрос: «Правильно ли работает бизнес-логика и обмен данными?»
  • UI-тесты отвечают на вопрос: «Может ли пользователь взаимодействовать с системой через интерфейс?»

Грамотная стратегия выглядит так:

  1. 70% дефектов мы находим через модульные тесты (внутри кода).
  2. 20% дефектов мы находим через API-тесты (на уровне интеграции).
  3. 10% дефектов мы находим через UI-тесты (проверка внешнего вида и критических сценариев).

Аналогия с автомобилем

Представьте, что вы тестируете автомобиль:

  • Модульные тесты — проверка каждого винтика на стенде
  • API-тесты — проверка того, что двигатель передаёт крутящий момент на колёса
  • UI-тесты — проверка того, что водитель может нажать на педали и крутить руль

§4.4 Программные интерфейсы: структура теста для REST API (AAA)

В предыдущем параграфе мы познакомились с REST API и поняли, почему тестировать его выгоднее, чем UI. Теперь давайте разберём, как именно устроен API-тест, и убедимся, что он пишется по тем же правилам, что и модульные тесты.

Хорошая новость

Структура API-теста полностью повторяет структуру юнит-теста. Мы используем тот же паттерн AAA (Arrange — Act — Assert), который изучили во второй лекции. Меняется только тип данных: вместо вызова методов мы отправляем HTTP-запросы.

Давайте разберём каждый этап по порядку:

Структура API-теста (AAA)

Шаг 1. Arrange (Подготовка)

На этом этапе мы готовим всё необходимое для отправки запроса.

Что мы делаем:

  1. Создаём экземпляр HttpClient — это основной инструмент для отправки HTTP-запросов в .NET.
  2. Формируем тело запроса (Payload) — данные, которые мы отправляем на сервер. Обычно это JSON-объект.
  3. Указываем URL эндпоинта, куда будем отправлять запрос.
  4. (Опционально) Настраиваем заголовки — например, указываем, что отправляем JSON.

Как это выглядит в коде:

csharp

// 1. Создаём HTTP-клиент
var client = new HttpClient();

// 2. Формируем тело запроса (Payload)
// Создаём объект с данными для нового заказа
var orderData = new
{
    ProductId = 1,              // ID товара
    Quantity = 2,               // Количество
    CustomerEmail = "test@example.com"  // Email покупателя
};

// Превращаем объект в JSON-строку
var json = JsonSerializer.Serialize(orderData);

// 3. Упаковываем JSON в HTTP-сообщение
// Указываем: Content-Type = application/json
var content = new StringContent(
    json, 
    Encoding.UTF8, 
    "application/json"
);

// 4. Указываем URL эндпоинта
string url = "https://localhost:5001/api/orders";

Важное понятие: Payload

Payload

— это данные, которые клиент отправляет на сервер в теле HTTP-запроса. Для REST API это чаще всего JSON-объект.

В нашем примере Payload — это объект orderData, который мы превратили в JSON-строку.

Аналогия из жизни: вы приходите в ресторан. Payload — это ваш заказ: «Я хочу стейк, хорошо прожаренный, с картофелем фри». Вы передаёте этот заказ официанту (серверу), а он передаёт его на кухню (бэкенд).

Шаг 2. Act (Выполнение)

На этом этапе мы отправляем подготовленный запрос и получаем ответ.

Что мы делаем:

  1. Вызываем метод HttpClient, соответствующий HTTP-методу (GET, POST, PUT, DELETE).
  2. Передаём URL и тело запроса (для POST, PUT).
  3. Получаем объект HttpResponseMessage, который содержит ответ сервера.

Как это выглядит в коде:

csharp

var response = await client.PostAsync(url, content);

Всего одна строка! Но за ней скрывается целая цепочка действий:

  1. HttpClient устанавливает TCP-соединение с сервером.
  2. Отправляет HTTP-заголовки и тело запроса.
  3. Сервер обрабатывает запрос (проверяет данные, сохраняет в БД, выполняет бизнес-логику).
  4. Сервер формирует ответ и отправляет его обратно.
  5. HttpClient получает ответ и упаковывает его в объект response.

Шаг 3. Assert (Проверка)

На этом этапе мы проверяем, что сервер ответил корректно. Проверять можно три группы вещей:

  1. Статус-код — говорит о том, успешно ли выполнился запрос.
  2. Заголовки ответа — содержат служебную информацию (тип данных, размер, дата).
  3. Тело ответа (JSON) — содержит сами данные, которые вернул сервер.

csharp

// 1. Проверяем статус-код
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

// 2. Проверяем заголовки
Assert.Equal(
    "application/json", 
    response.Content.Headers.ContentType.MediaType
);

// 3. Проверяем тело ответа (JSON)
var responseString = await response.Content.ReadAsStringAsync();
var jsonDoc = JsonDocument.Parse(responseString);
var root = jsonDoc.RootElement;

// Проверяем наличие поля "orderId"
Assert.True(root.TryGetProperty("orderId", out var idElement));

// Проверяем тип значения (должно быть число)
Assert.Equal(JsonValueKind.Number, idElement.ValueKind);

// Проверяем конкретное значение
Assert.NotEqual(0, idElement.GetInt32());

Почему важно проверять структуру?

Представьте, что сервер возвращает код 200 OK, но в теле ответа — пустой JSON. Или JSON с неправильным полем order_id вместо orderId. Если вы проверите только статус-код, тест будет зелёным, а реальное приложение — сломается.

Аналогия: вы заказали пиццу. Курьер приехал (статус-код 200) и передал вам коробку. Но внутри коробки — не пицца, а кирпич. Если вы проверили только «коробка приехала» (статус-код), вы пропустили проблему. Если вы проверили «внутри коробки — пицца» (структура JSON), вы нашли дефект.

§4.5 Page Object Model (POM): как сделать UI-тесты поддерживаемыми

В предыдущих параграфах мы научились тестировать API — быстро, стабильно и без лишних хлопот. Однако, как мы уже говорили, API-тесты не проверяют внешний вид и поведение пользовательского интерфейса. Для этого нам всё равно нужны UI-тесты, которые открывают браузер и имитируют действия реального пользователя.

И здесь мы сталкиваемся с фундаментальной проблемой, которая преследует все UI-тесты без исключения.

Проблема: хрупкость UI-тестов

Представьте себе типичный UI-тест. В нём мы находим кнопку на странице, кликаем по ней, затем находим поле ввода, вводим текст, затем проверяем, что появилось сообщение об успехе.

Пока дизайн стабилен, тест работает. Но в реальном мире дизайн меняется постоянно.

  • Дизайнер решает, что кнопка должна быть не синей, а зелёной.
  • Верстальщик меняет структуру HTML, чтобы улучшить адаптивность.
  • Разработчик переименовывает CSS-класс, чтобы он соответствовал корпоративному стандарту.

И тут начинается кошмар.

csharp

// ✕ ПЛОХО: жёстко зашитый локатор в каждом тесте
driver.FindElement(By.XPath("//div[3]/form/button[1]")).Click();

Дизайнер добавил новый блок на страницу. Теперь наша кнопка стала не третьим <div>, а четвёртым. Все 100 тестов падают. Разработчик или тестировщик должны пройти по каждому тесту и исправить этот локатор. Это занимает часы, а то и дни. И самое обидное — бизнес-логика не менялась, менялся только внешний вид.

А теперь представьте, что таких изменений происходит несколько раз в месяц. Поддержка тестов превращается в ад, и команда начинает ненавидеть автоматизацию.

Решение: Page Object Model (POM)

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

Теперь представьте, что инженеры решили модернизировать станки. Они изменили расположение кнопок: красная кнопка «Стоп» теперь справа, а зелёная «Пуск» — слева. Если каждый рабочий привык искать кнопки на станке вручную, начнётся хаос. Все будут ошибаться, нажимать не на те кнопки, терять время.

Но есть другой подход. Вместо того чтобы каждый рабочий запоминал, где какая кнопка, мы создаём стандартную панель управления. На этой панели кнопки всегда подписаны и расположены в одном порядке: слева — «Пуск», справа — «Стоп».

Когда инженеры меняют станок, они переподключают провода на панели управления. А рабочие продолжают нажимать на те же кнопки, что и раньше. Им не нужно ничего переучивать.

Page Object Model (POM)

— это точно такая же панель управления для ваших UI-тестов.

Определение POM

Page Object Model (POM)

— это архитектурный паттерн для автоматизации тестирования пользовательского интерфейса. Его суть предельно проста:

Каждая страница (или крупный компонент страницы) описывается отдельным классом. Внутри этого класса хранятся:

  • Все локаторы элементов (как найти кнопку, поле, ссылку)
  • Методы для взаимодействия с этими элементами (кликнуть, ввести текст, прочитать значение)

Другими словами, POM создаёт слой абстракции между вашими тестами и реальным HTML-кодом страницы.

Архитектура POM

Архитектура Page Object Model

Пример: без POM vs с POM

Без POM (плохо):

csharp

// ✕ ПЛОХО: локатор размазан по 100 тестам
[Fact]
public void Test1_Login_ValidUser()
{
    // ... 20 строк кода
    driver.FindElement(By.XPath("//div[3]/form/button[1]")).Click();
    // ... ещё 20 строк кода
}

[Fact]
public void Test2_Login_InvalidPassword()
{
    // ... 20 строк кода
    driver.FindElement(By.XPath("//div[3]/form/button[1]")).Click();
    // ... ещё 20 строк кода
}

// И так 100 раз...

Проблема: Дизайнер поменял вёрстку — теперь кнопка не в третьем <div>, а в четвёртом. Все 100 тестов падают. Нужно идти в каждый тест и менять локатор.

С POM (хорошо):

csharp

// ✓ ХОРОШО: локатор хранится в одном месте
public class LoginPage
{
    // Все локаторы собраны в одном классе
    private By LoginButton => By.XPath("//div[3]/form/button[1]");
    
    public void ClickLoginButton()
    {
        // Здесь логика поиска и клика
    }
}

// А в тестах просто вызываем метод
[Fact]
public void Test1_Login_ValidUser()
{
    // ... 20 строк кода
    loginPage.ClickLoginButton();  // ← метод всегда одинаковый
    // ... ещё 20 строк кода
}

[Fact]
public void Test2_Login_InvalidPassword()
{
    // ... 20 строк кода
    loginPage.ClickLoginButton();  // ← метод всегда одинаковый
    // ... ещё 20 строк кода
}

Преимущество: Дизайнер поменял вёрстку. Мы идём в ОДИН класс LoginPage, меняем ОДИН локатор. Все 100 тестов снова работают. Мы потратили 5 минут вместо 5 часов.

Основные принципы Page Object Model

  1. Одна страница — один класс. Главная страница — класс HomePage. Страница логина — класс LoginPage. Страница корзины — класс CartPage. Это делает код структурированным и предсказуемым.
  2. В классе хранятся локаторы. Все By.Id, By.CssSelector, By.XPath — только внутри Page Object. Никаких локаторов в тестах!
  3. В классе хранятся методы взаимодействия. Методы вроде Login(), SearchProduct(), AddToCart() описывают действия, которые пользователь может выполнить на этой странице.
  4. Методы возвращают другие Page Object'ы. Например, метод LoginPage.Login() после успешного входа возвращает объект HomePage. Это создаёт цепочку действий, похожую на реальный пользовательский сценарий.
  5. Тесты становятся читаемыми. Вместо кучи driver.FindElement тест превращается в последовательность понятных шагов:

csharp

var homePage = new HomePage(driver);
var productPage = homePage.SearchForProduct("iPhone");
var cartPage = productPage.AddToCart();
var paymentPage = cartPage.ProceedToCheckout();
var confirmationPage = paymentPage.PayWithCard("1234...");

Такой код читается как обычный пользовательский сценарий. Это называется «тесты как документация».

Почему POM — это стандарт индустрии?

ПреимуществоОписание
ПоддерживаемостьИзменения в UI требуют правки только Page Object'ов, а не всех тестов.
ПереиспользованиеОдин Page Object может использоваться в сотнях тестов. Написали один раз — используете везде.
ЧитаемостьТесты становятся декларативными: они описывают ЧТО нужно сделать, а не КАК это сделать технически.
Разделение ответственностиТестировщик, пишущий тесты, работает с высокоуровневыми методами. Технические детали (локаторы, ожидания) скрыты внутри Page Object'ов.
МасштабируемостьКогда проект вырастает до 500 тестов, POM становится не просто удобством, а необходимостью.

Чего НЕ надо делать

  • Хранить локаторы в тестах — это главное нарушение POM. Если вы видите driver.FindElement в тесте — это нарушение.
  • Смешивать логику страниц — в классе LoginPage не должно быть методов работы с корзиной.
  • Возвращать void из методов, если после клика переходим на другую страницу — метод должен возвращать объект этой страницы.
  • Делать огромные POM — если страница слишком большая, разбейте её на компоненты.

§4.6 Реализация POM на C#: пример для страницы авторизации

В предыдущем параграфе мы разобрали концепцию Page Object Model и поняли, зачем этот паттерн нужен. Теперь пришло время увидеть, как POM реализуется на практике в C# с использованием Selenium WebDriver.

Мы разберём конкретный пример — страницу авторизации. Это идеальный случай для демонстрации POM, потому что:

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

Создаём класс LoginPage

Первый шаг — создать класс, который будет описывать нашу страницу логина. Назовём его LoginPage.

Что должно быть внутри класса:

  1. Приватное поле _driver — ссылка на экземпляр Selenium WebDriver, который управляет браузером. Мы передаём его через конструктор.
  2. Локаторы элементов — свойства, которые возвращают объекты типа By. Это и есть те самые «инструкции», как найти элемент на странице.
  3. Методы действий — публичные методы, которые описывают, что пользователь может сделать на этой странице.

Вот как выглядит класс LoginPage полностью:

csharp

public class LoginPage
{
    // 1. Поле для драйвера (передаётся через конструктор)
    private readonly IWebDriver _driver;
    
    // 2. Локаторы элементов (как найти элементы на странице)
    private By UserNameField => By.Id("username");
    private By PasswordField => By.Id("password");
    private By LoginButton => By.XPath("//button[text()='Войти']");
    private By ErrorMessage => By.CssSelector(".error-message");
    
    // 3. Конструктор (получает драйвер извне)
    public LoginPage(IWebDriver driver) => _driver = driver;
    
    // 4. Методы действий (что можно сделать на странице)
    public HomePage LoginAs(string username, string password)
    {
        // Находим поле для логина и вводим логин
        _driver.FindElement(UserNameField).SendKeys(username);
        
        // Находим поле для пароля и вводим пароль
        _driver.FindElement(PasswordField).SendKeys(password);
        
        // Находим кнопку "Войти" и нажимаем её
        _driver.FindElement(LoginButton).Click();
        
        // После успешного входа мы попадаем на главную страницу
        // Возвращаем объект HomePage, чтобы тест мог работать дальше
        return new HomePage(_driver);
    }
    
    public bool IsErrorMessageDisplayed()
    {
        try
        {
            return _driver.FindElement(ErrorMessage).Displayed;
        }
        catch (NoSuchElementException)
        {
            return false;
        }
    }
    
    public string GetErrorMessageText() => 
        _driver.FindElement(ErrorMessage).Text;
    
    public void NavigateTo() => 
        _driver.Navigate().GoToUrl("https://mysite.com/login");
}

Разбор кода по частям

1. Поле для драйвера и конструктор

csharp

private readonly IWebDriver _driver;

public LoginPage(IWebDriver driver)
{
    _driver = driver;
}

Это классическое внедрение зависимости (Dependency Injection) — мы уже изучали его в третьей лекции. Класс LoginPage не создаёт драйвер сам, а получает его извне. Это позволяет:

  • Использовать один и тот же драйвер для всех Page Object'ов в рамках одного теста.
  • Легко подменять драйвер в тестах (например, использовать ChromeDriver для одних тестов и FirefoxDriver для других).

2. Локаторы элементов

csharp

private By UserNameField => By.Id("username");
private By PasswordField => By.Id("password");
private By LoginButton => By.XPath("//button[text()='Войти']");

Локатор

— это инструкция для Selenium, как найти элемент на странице. В нашем примере:

  • Поле логина ищется по ID «username».
  • Поле пароля ищется по ID «password».
  • Кнопка входа ищется по XPath: кнопка с текстом «Войти».

Обратите внимание

Все локаторы приватные. Тесты не должны знать, как именно мы ищем элементы. Они работают только с публичными методами.

3. Методы действий

Метод LoginAs() — это главный метод нашего Page Object'а. Он выполняет три действия:

  1. Вводит логин в поле UserNameField.
  2. Вводит пароль в поле PasswordField.
  3. Нажимает кнопку LoginButton.

Важный момент: после нажатия кнопки мы переходим на главную страницу. Поэтому метод возвращает объект HomePage. Это называется цепочкой переходов — она делает тесты читаемыми и естественными.

Создаём класс HomePage

После успешного входа мы попадаем на главную страницу. Для неё тоже нужно создать Page Object. Он будет проще, потому что нам нужно только проверить, что вход выполнен успешно.

csharp

public class HomePage
{
    private readonly IWebDriver _driver;
    
    // Локатор для элемента, который виден только авторизованному пользователю
    private By UserProfileIcon => By.CssSelector(".user-profile");
    private By WelcomeMessage => By.XPath("//h1[contains(text(),'Добро пожаловать')]");
    
    public HomePage(IWebDriver driver)
    {
        _driver = driver;
    }
    
    public bool IsUserLoggedIn()
    {
        try
        {
            return _driver.FindElement(UserProfileIcon).Displayed;
        }
        catch (NoSuchElementException)
        {
            return false;
        }
    }
    
    public bool IsWelcomeMessageDisplayed()
    {
        try
        {
            return _driver.FindElement(WelcomeMessage).Displayed;
        }
        catch (NoSuchElementException)
        {
            return false;
        }
    }
}

Пишем тест с использованием POM

Теперь, когда у нас есть два Page Object'а (LoginPage и HomePage), мы можем написать тест, который проверяет сценарий авторизации.

csharp

[Fact]
public void Login_ValidCredentials_RedirectsToHomePage()
{
    // 1. Создаём драйвер (открываем браузер Chrome)
    var driver = new ChromeDriver();
    
    // 2. Создаём Page Object для страницы логина
    var loginPage = new LoginPage(driver);
    
    // 3. Открываем страницу логина
    loginPage.NavigateTo();
    
    // 4. Выполняем вход
    // Метод LoginAs() возвращает объект HomePage
    var homePage = loginPage.LoginAs("admin", "12345");
    
    // 5. Проверяем, что мы попали на главную страницу
    Assert.True(homePage.IsUserLoggedIn());
    
    // 6. Проверяем, что отображается приветственное сообщение
    Assert.True(homePage.IsWelcomeMessageDisplayed());
    
    driver.Quit();
}

Обратите внимание на читаемость

Тест читается как обычный пользовательский сценарий:

csharp

loginPage.NavigateTo();                    // Открыть страницу логина
var homePage = loginPage.LoginAs("admin", "12345");  // Войти
Assert.True(homePage.IsUserLoggedIn());    // Проверить, что вошли

Это не случайно — именно так и должен выглядеть хороший тест.

Что даёт нам использование POM?

1. Разделение ответственности

Что делаетГде находится
Знает, как найти поле логинаLoginPage (локатор UserNameField)
Знает, как ввести логинLoginPage (метод LoginAs)
Знает, как проверить, что мы вошлиHomePage (метод IsUserLoggedIn)
Описывает сценарий тестаТестовый метод Login_ValidCredentials_RedirectsToHomePage

Тест не знает, как искать элементы. Page Object не знает, какой сценарий тестируется. Каждый делает свою работу.

2. Устойчивость к изменениям

Представьте, что дизайнер поменял вёрстку страницы логина:

  • ID поля логина изменился с «username» на «login-email».
  • Класс кнопки изменился.

Без POM нужно идти в каждый тест, где используется логин, и менять локатор. Если таких тестов 50 — это 50 правок.

С POM: идём в класс LoginPage, меняем локатор в одном месте:

csharp

// Было
private By UserNameField => By.Id("username");

// Стало
private By UserNameField => By.Id("login-email");

Все 50 тестов продолжают работать. Мы потратили 1 минуту вместо часа.

3. Переиспользование

Класс LoginPage можно использовать во множестве тестов:

  • Тест успешного логина.
  • Тест с неверным паролем.
  • Тест с пустым логином.
  • Тест с блокировкой после трёх попыток.

Все эти тесты используют один и тот же Page Object. Написали один раз — используем везде.

§4.7 Инструментарий для автоматизации E2E в .NET

В предыдущих параграфах мы научились писать UI-тесты через Selenium и API-тесты через HttpClient. Но в реальных проектах этого набора недостаточно. Давайте посмотрим, какие ещё инструменты существуют в .NET-экосистеме.

UI-автоматизация

`csharp // Selenium WebDriver — классика var driver = new ChromeDriver(); driver.Navigate().GoToUrl("https://mysite.com/login"); driver.FindElement(By.Id("username")).SendKeys("admin");

// Открывает браузер, ищет элементы, кликает, вводит текст. // Это классика, которая работает везде. `

`csharp // Playwright — современный инструмент от Microsoft await page.FillAsync("#username", "admin"); await page.ClickAsync("button:has-text('Войти')");

// Быстрее, умнее, лучше работает с современными сайтами // (SPA на React/Angular). В нём не нужно писать WebDriverWait — // ожидания встроены прямо в методы. ` [/TABS]

API-тестирование

`csharp // HttpClient — встроенный в .NET var client = new HttpClient(); var json = JsonSerializer.Serialize(new { ProductId = 1, Quantity = 2 }); var content = new StringContent( json, Encoding.UTF8, "application/json" ); var response = await client.PostAsync( "https://api.myshop.com/orders", content );

// Отлично работает, но требует много шаблонного кода: // сериализация JSON, настройка заголовков, обработка ответов. `

`csharp // RestSharp — библиотека-обёртка var client = new RestClient("https://api.myshop.com"); var request = new RestRequest("/orders", Method.Post); request.AddJsonBody(new { ProductId = 1, Quantity = 2 }); var response = await client.ExecuteAsync(request);

// Делает код короче и чище. ` [/TABS]

Управление тестовыми данными

Самая большая головная боль в E2E-тестах — это данные. Тесты создают пользователей, заказы, товары. Если их не чистить, база данных засоряется, тесты начинают падать, потому что «пользователь уже существует».

`csharp // 1. Транзакции using var transaction = connection.BeginTransaction();

// ... тестируемый код...

transaction.Rollback();

// Просто и эффективно. Подходит для небольших проектов // и обучения. Оборачиваем тест в транзакцию и откатываем // её после завершения. `

`csharp // 2. Respawn await _respawner.ResetAsync(connectionString);

// Библиотека, которая чистит указанные таблицы после тестов. // Она удаляет все данные из таблиц, которые вы перечислили. // Работает быстро, не требует ручного написания скриптов. `

`csharp // 3. Testcontainers var container = new PostgreSqlBuilder().Build(); await container.StartAsync(); var connectionString = container.GetConnectionString();

// Запускает базу данных в Docker-контейнере специально // для тестов. Каждый запуск получает чистую базу. // Требует Docker, но даёт полную изоляцию. ` [/TABS]

§4.8 Антипаттерны в E2E-тестировании (Чего делать НЕ надо)

Мы разобрали инструменты, написали тесты с POM, научились работать с API. Теперь давайте поговорим о том, как превратить хорошие тесты в адскую поддержку.

Потому что, поверьте, в реальных проектах ошибки в тестах встречаются чаще, чем ошибки в коде. И эти ошибки имеют имена. В индустрии их называют антипаттернами.

Это такие «привычки», которые кажутся удобными в момент написания, но через месяц превращают вашу автоматизацию в кошмар.

Разберём три самых смертельных антипаттерна.

Антипаттерн №1: «Спящий тест» (Thread.Sleep)

Начинающие автоматизаторы обожают Thread.Sleep. Это антипаттерн №1.

Проблема: страница не загрузилась, тест упал. Они пишут: «Ага, нужно просто подождать подольше». И вставляют Thread.Sleep(5000) — жди 5 секунд, и всё будет хорошо.

csharp

// ✕ ПЛОХО: "Спящий тест"
[Fact]
public void ClickButton_ShowsMessage()
{
    var driver = new ChromeDriver();
    driver.Navigate().GoToUrl("https://site.com");
    
    // ПЛОХО: ждём ровно 3 секунды
    Thread.Sleep(3000);
    
    var button = driver.FindElement(By.Id("submit"));
    button.Click();
    
    // Тест может упасть, если страница загружалась 3.5 секунды
    var message = driver.FindElement(By.Id("success"));
    Assert.True(message.Displayed);
}

Почему это плохо?

  1. Вы гадаете. «Кажется, страница обычно грузится за 3 секунды» — это не инженерия, это магия.
  2. Нестабильность. Если сегодня сервер чуть медленнее, тест упадёт. Если завтра сервер быстрее, вы будете ждать лишние 2 секунды — тест станет медленнее, чем мог бы быть.
  3. Накопление времени. 5 секунд в одном тесте × 100 тестов = 500 секунд, или почти 10 минут ожидания. На каждом прогоне. Каждый день.

Правильное решение: использовать WebDriverWait — умное ожидание, которое ждёт не время, а условие.

csharp

// ✓ ХОРОШО: умное ожидание
[Fact]
public void ClickButton_ShowsMessage()
{
    var driver = new ChromeDriver();
    driver.Navigate().GoToUrl("https://site.com");
    
    // Создаём умное ожидание с таймаутом 10 секунд
    var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
    
    // Ждём, пока кнопка станет кликабельной
    var button = wait.Until(
        ExpectedConditions.ElementToBeClickable(By.Id("submit"))
    );
    button.Click();
    
    // Ждём появления сообщения
    var message = wait.Until(driver => driver.FindElement(By.Id("success")));
    Assert.True(message.Displayed);
}

Теперь тест ждёт ровно столько, сколько нужно. Если кнопка появилась через 0.5 секунды — тест идёт дальше. Если через 5 секунд — ждёт 5 секунд. Если через 11 секунд — падает с понятной ошибкой: «Элемент не появился за 10 секунд». Никаких гаданий.

Запомните

Thread.Sleep в E2E-тестах — это всегда признак плохого кода. Используйте явные ожидания (WebDriverWait). Всегда.

Антипаттерн №2: «Длинный тест» (всё в одном)

Второй по популярности антипаттерн — это попытка проверить всё в одном тесте.

Сценарий: «Регистрация → Логин → Поиск товара → Добавление в корзину → Оформление заказа → Выход». Всё в одном методе, всё в одном тесте.

csharp

// ✕ ПЛОХО: "Длинный тест" (всё в одном месте)
[Fact]
public void FullUserJourney_EverythingInOneTest()
{
    // 1. Регистрация
    var registerPage = new RegisterPage(driver);
    registerPage.Register("user@test.com", "password123");
    
    // 2. Логин
    var loginPage = new LoginPage(driver);
    loginPage.LoginAs("user@test.com", "password123");
    
    // 3. Поиск товара
    var homePage = new HomePage(driver);
    homePage.SearchForProduct("iPhone 15");
    
    // 4. Добавление в корзину
    var productPage = new ProductPage(driver);
    productPage.AddToCart();
    
    // 5. Оформление заказа
    var cartPage = new CartPage(driver);
    cartPage.ProceedToCheckout();
    
    // 6. Оплата
    var paymentPage = new PaymentPage(driver);
    paymentPage.PayWithCard("1234...");
    
    // 7. Проверка подтверждения
    var confirmationPage = new ConfirmationPage(driver);
    Assert.True(confirmationPage.IsOrderConfirmed());
}

В чём проблема? Тест упал на шаге 5 — «Оформление заказа». Мы видим красный отчёт. Что сломалось? Может быть, регистрация? Может быть, логин? Может быть, сам процесс оформления? Мы не знаем.

Теперь нужно запускать отладку, проходить все шаги заново, разбираться. Это занимает часы. А если в этом тесте 15 шагов? Кошмар.

Правильное решение: каждый тест проверяет одну бизнес-функцию. Для теста регистрации нужен новый пользователь, которого мы создадим не через UI (это долго), а через API (это быстро).

csharp

// ✓ ХОРОШО: тест проверяет только логин
[Fact]
public void Login_ValidCredentials_RedirectsToHomePage()
{
    // ARRANGE: создаём пользователя через API (быстро)
    var apiClient = new HttpClient();
    await apiClient.PostAsync("/api/users", new
    {
        Email = "user@test.com",
        Password = "password123"
    });
    
    // ACT: регистрируем через UI (проверяем именно логин)
    var loginPage = new LoginPage(driver);
    var homePage = loginPage.LoginAs("user@test.com", "password123");
    
    // ASSERT: проверяем, что регистрация успешна
    Assert.True(homePage.IsUserLoggedIn());
}

// ✓ ХОРОШО: другой тест проверяет только оплату
[Fact]
public void Payment_ValidCard_CompletesOrder()
{
    // ARRANGE: подготовка через API
    // ... создаём заказ через API, кладём товары в корзину
    
    // ACT: оплачиваем через UI
    var paymentPage = new PaymentPage(driver);
    var confirmation = paymentPage.PayWithCard("1234...");
    
    // ASSERT: проверяем оплату
    Assert.True(confirmation.IsOrderConfirmed());
}

Теперь если тест оплаты упал — мы точно знаем, что проблема в оплате, а не в регистрации или логине. Каждый тест проверяет свою зону ответственности. Это называется атомарность тестов.

Запомните

Один тест — одна бизнес-функция. Всё остальное готовим через API или другие быстрые механизмы.

Антипаттерн №3: «Магия строк» (локаторы в тестах)

Третий антипаттерн — это когда локаторы (особенно XPath) пишутся прямо в тестах, а не в Page Object'ах.

csharp

// ✕ ПЛОХО: локатор прямо в тесте
[Fact]
public void Login_ValidUser_ShowsDashboard()
{
    var driver = new ChromeDriver();
    driver.Navigate().GoToUrl("https://site.com/login");
    
    // МАГИЯ: XPath с цифрами, которые непонятно откуда взялись
    driver.FindElement(By.XPath("//div[3]/form/input[1]"))
        .SendKeys("admin");
    driver.FindElement(By.XPath("//div[3]/form/input[2]"))
        .SendKeys("12345");
    driver.FindElement(By.XPath("//div[3]/form/button[1]")).Click();
    
    // Ещё одна магия
    var header = driver.FindElement(By.CssSelector("div.header > h1"));
    Assert.Equal("Добро пожаловать", header.Text);
}

Почему это плохо? Представьте, что дизайнер поменял вёрстку. Теперь кнопка — это не //div[3]/form/button[1], а //div[4]/form/button[1]. И у вас 50 тестов, в каждом из которых есть этот локатор. Вы должны пойти в каждый тест и исправить его. Это 50 правок. А если локатор используется в 100 тестах? 100 правок. А если таких локаторов в каждом тесте по 5 штук? Это 500 правок. Дни работы. И всё потому, что дизайнер просто добавил один блок на страницу.

Правильное решение: все локаторы — только внутри Page Object классов. Никаких FindElement в тестах.

csharp

// ✓ ХОРОШО: локатор в Page Object'е
public class LoginPage
{
    private By UserNameField => By.Id("username");
    private By PasswordField => By.Id("password");
    private By LoginButton => By.XPath("//button[text()='Войти']");
    
    public void LoginAs(string user, string pass)
    {
        _driver.FindElement(UserNameField).SendKeys(user);
        _driver.FindElement(PasswordField).SendKeys(pass);
        _driver.FindElement(LoginButton).Click();
    }
}

// А в тесте просто вызываем метод
[Fact]
public void Login_ValidUser_ShowsDashboard()
{
    var loginPage = new LoginPage(driver);
    loginPage.LoginAs("admin", "12345");
    // ...
}

Теперь дизайнер поменял вёрстку. Мы идём в ОДИН класс LoginPage, меняем ОДИН локатор. Все 50 или 100 тестов продолжают работать. Мы потратили 1 минуту вместо дня.

Запомните

Если вы видите driver.FindElement в тесте — это нарушение. Все локаторы должны быть спрятаны внутри Page Object'ов. Тесты работают с методами, а не с элементами.

Сводная таблица антипаттернов

АнтипаттернСимптомПоследствияРешение
Спящий тестThread.Sleep() в кодеНестабильные тесты, медленные прогоныWebDriverWait с явными условиями
Длинный тестОдин тест проверяет 10+ шаговСложно понять, что сломалосьРазделять на атомарные тесты, готовить данные через API
Магия строкXPath/CSS в тестах100 правок при изменении вёрсткиВсе локаторы в Page Object'ах

§4.9 Создание тестовых данных для E2E: «Готовим поле до игры»

В предыдущем параграфе мы разобрали антипаттерны — что делать НЕ надо. Теперь поговорим о том, что делать НАДО, а именно — о тестовых данных.

Эта тема кажется очевидной, но именно на ней спотыкаются 90% начинающих команд автоматизации.

Проблема: конфликты данных

Представьте классическую ситуацию. Вы пишете тест, который проверяет создание заказа. Всё работает. Вы запускаете тест ещё раз — и он падает. Почему? Потому что в базе данных уже есть заказ №1, и система не позволяет создать его повторно.

csharp

// ✕ ПЛОХО: тест с жёстко зашитыми данными
[Fact]
public void CreateOrder_ValidData_CreatesOrder()
{
    // Проблема: если заказ с ID=1 уже существует, тест упадёт
    var order = new Order
    {
        Id = 1,
        ProductId = 100,
        Quantity = 2
    };
    
    var service = new OrderService();
    service.CreateOrder(order);
    
    // Проверяем, что заказ создался
    var created = service.GetOrder(1);
    Assert.NotNull(created);
}

Первый запуск

— тест прошёл, заказ создан. Второй запуск — тест упал, потому что заказ с ID=1 уже существует. Третий запуск — опять упал. Что делать? Ручками чистить базу? А если у вас 200 тестов?

Решение 1: Уникальные данные

Самый простой способ избежать конфликтов — генерировать уникальные данные для каждого запуска. Вместо того чтобы использовать один и тот же email, логин или ID, мы добавляем к ним временную метку или случайное число.

csharp

// ✓ ХОРОШО: уникальные данные для каждого запуска
[Fact]
public void CreateOrder_ValidData_CreatesOrder()
{
    // Генерируем уникальный ID на основе текущего времени
    var uniqueId = DateTime.Now.Ticks;
    
    var order = new Order
    {
        Id = uniqueId,
        ProductId = 100,
        Quantity = 2
    };
    
    var service = new OrderService();
    service.CreateOrder(order);
    
    var created = service.GetOrder(uniqueId);
    Assert.NotNull(created);
}

Теперь каждый запуск теста создаёт уникальные данные. Конфликтов нет. Но есть другая проблема — база данных разрастается. Каждый запуск оставляет в ней «хвосты».

Решение 2: Чистка после себя (Teardown)

Если вы создали данные в тесте — вы должны их удалить после теста. Это называется «чистка после себя» (teardown). В xUnit для этого используется метод Dispose() или атрибут [Fact] с реализацией IDisposable.

csharp

// ✓ ХОРОШО: тест с чисткой после себя
public class OrderTests : IDisposable
{
    private readonly OrderService _service;
    private long _createdOrderId;
    
    public OrderTests()
    {
        _service = new OrderService();
    }
    
    [Fact]
    public void CreateOrder_ValidData_CreatesOrder()
    {
        var uniqueId = DateTime.Now.Ticks;
        _createdOrderId = uniqueId;  // Запоминаем ID для удаления
        
        var order = new Order
        {
            Id = uniqueId,
            ProductId = 100,
            Quantity = 2
        };
        
        _service.CreateOrder(order);
        
        var created = _service.GetOrder(uniqueId);
        Assert.NotNull(created);
    }
    
    // Этот метод выполняется после каждого теста
    public void Dispose()
    {
        // Удаляем созданный заказ
        if (_createdOrderId > 0)
        {
            _service.DeleteOrder(_createdOrderId);
        }
    }
}

Теперь база данных остаётся чистой после каждого теста. Но есть нюанс — если тест упал до того, как мы сохранили _createdOrderId, удаления не произойдёт. Поэтому лучше использовать другой подход.

Решение 3: Транзакции (самый надёжный способ)

Самый надёжный способ — обернуть тест в транзакцию и откатить её после завершения. Никакие данные не попадут в базу, даже если тест упал.

csharp

// ✓ ХОРОШО: тест в транзакции
[Fact]
public void CreateOrder_ValidData_CreatesOrder()
{
    using var transaction = _connection.BeginTransaction();
    
    var uniqueId = DateTime.Now.Ticks;
    var order = new Order
    {
        Id = uniqueId,
        ProductId = 100,
        Quantity = 2
    };
    
    _service.CreateOrder(order, transaction);
    
    var created = _service.GetOrder(uniqueId, transaction);
    Assert.NotNull(created);
    
    // Транзакция откатывается автоматически при выходе из using
    // Данные НЕ попадают в базу
}

Подготовка данных для UI-тестов

В UI-тестах самая большая проблема — это скорость. Если перед каждым тестом вы регистрируете пользователя через UI (открываете браузер, заполняете форму, нажимаете кнопку «Зарегистрироваться»), то это занимает 5-10 секунд. Умножьте на 50 тестов — и вы получите 5 минут только на подготовку данных.

Правильный подход: данные для UI-тестов нужно готовить через API (быстро), а проверять — через UI (то, что мы тестируем).

csharp

// ✓ ХОРОШО: готовим через API, проверяем через UI
[Fact]
public void Login_ValidUser_RedirectsToDashboard()
{
    // ARRANGE: создаём пользователя через API (быстро — 100 мс)
    var apiClient = new HttpClient();
    await apiClient.PostAsync("/api/users", new
    {
        Email = "test@example.com",
        Password = "password123"
    });
    
    // ACT: проверяем логин через UI (то, что тестируем)
    var loginPage = new LoginPage(driver);
    var dashboard = loginPage.LoginAs("test@example.com", "password123");
    
    // ASSERT: проверяем, что вошли
    Assert.True(dashboard.IsUserLoggedIn());
    
    // TEARDOWN: удаляем пользователя через API
    await apiClient.DeleteAsync($"/api/users/test@example.com");
}

Что здесь важно:

  1. Пользователь создаётся через API — это занимает 100 миллисекунд, а не 5 секунд через UI.
  2. Сам тест проверяет логин через UI — мы проверяем именно то, что нужно.
  3. Пользователь удаляется через API — чистота после теста.

Главное правило

Готовьте данные через API, проверяйте через UI.

Это правило экономит часы времени на каждом прогоне тестов. Если в вашем проекте нет API для создания пользователей — попросите разработчиков сделать его. Тестовые эндпоинты — это стандарт индустрии.

§4.10 Место E2E в стратегии качества

Сквозное тестирование занимает верхушку пирамиды тестирования и должно составлять не более 10% от всех автоматизированных проверок.

Его единственная цель — проверить критические бизнес-сценарии, где ошибка стоит компании денег или репутации:

  • Логин
  • Оплата
  • Регистрация
  • Оформление заказа

Всё остальное

— валидация полей, обработка ошибок, граничные условия — должно тестироваться на уровне модульных и API-тестов, которые быстрее, стабильнее и дешевле в поддержке.

E2E-тесты дополняют, а не заменяют

E2E-тесты не заменяют, а дополняют нижние уровни пирамиды:

  • Модульные тесты дают быструю обратную связь разработчику.
  • API-тесты проверяют корректность работы бэкенда.
  • E2E дают финальную гарантию, что система работает у пользователя.

Временные ограничения

Если E2E-тесты выполняются дольше 30-40 минут, часть сценариев нужно переносить на уровень API — это сохранит покрытие критических путей и ускорит прогон в CI/CD.

Главный вывод

Качество важнее количества

E2E-тесты — это не про количество, а про качество.

Несколько хорошо спроектированных сквозных сценариев, покрывающих основные бизнес-процессы, принесут больше пользы, чем сотня хрупких тестов, которые постоянно падают из-за изменений в вёрстке или таймаутов.

Профессиональный подход заключается в:

  1. Разумном распределении усилий между уровнями пирамиды.
  2. Чётком понимании того, какой тип тестов проверяет какой аспект системы.