Привет! Я Миша Симонов, работаю в Контуре ведущим специалистом по тестированию и являюсь техлидом по автоматизации тес��ирования кластера из 18 команд.
Когда говорят про архитектуру UI-тестов на Selenium или Playwright, то ограничиваются объяснением паттерна Page Object Model. Но достаточно ли только знания POM в современном мире программирования?
В этой статье я постараюсь мягко провести вас от классического Page Object Model к архитектуре, основанной на композиции и Dependency Injection. Я убеждён, что написание тестов — это такое же программирование, как и разработка фич. Здесь нужны те же современные практики: DI, чистая архитектура, композиция, SOLID — всё, что делает код гибким и живучим.
Потому предлагаю рассмотреть значимые части архитектуры тестов и разобраться в том, что за зверь такой — композиция — и как его приготовить. А готовить мы будем на NUnit, Playwright на C# и Microsoft.DependencyInjection. 👨🍳
Весь код из статьи можно посмотреть на Гитхабе — https://github.com/Jaxak/Playwright.Net. А более прокачанный — здесь https://github.com/skbkontur/Playwright.Net.Core. Так же есть возможность подключить к своим тестам готовые Nuget, но об этом я расскажу в конце статьи.

Это мы все всё знаем, но всё равно вспомним
Начнём с классического POM, примерами которого завален интернет, но я всё равно добавлю пояснения в блоки кода, чтобы и новички могли вытащить максимум пользы для себя.
Он состоит из двух частей, каждая из которых является реализацией паттерна Wrapper.
Часть 1: PageObject для описания типов страниц.
/// <summary> /// Абстрактный базовый класс для всех PageObjects. /// Содержит общую логику и структуру для работы со страницами приложения. /// </summary> /// <remarks> /// Реализует базовый уровень паттерна Wrapper для страниц, /// оборачивая объект IPage. /// </remarks> public abstract class PageBase(IPage page) { /// <summary> /// Объект страницы Playwright. Предоставляет доступ к навигации, /// взаимодействию с элементами и другим API браузера. /// </summary> public IPage Page { get; } = page; /// <summary> /// Абстрактное свойство, возвращающее URL страницы. /// Должно быть реализовано в конкретных классах страниц. /// </summary> public abstract string Url { get; } }
Часть 2: PageElement для описания веб-элементов, которые добавляются на страницы.
/// <summary> /// Базовый абстрактный класс для всех PageElements (UI-компонентов, контролов). /// Инкапсулирует локатор элемента и предоставляет методы для работы с ним. /// </summary> /// <remarks> /// Оборачивает низкоуровневый ILocator в семантически значимый объект предметной области. /// </remarks> public abstract class ControlBase(ILocator locator) { /// <summary> /// Локатор элемента страницы. Предоставляет доступ к низкоуровневым операциям /// взаимодействия с UI-элементом через Playwright API. /// </summary> public ILocator Locator { get; } = locator; /// <summary> /// Создает объект для утверждений (assertions) над текущим элементом. /// Позволяет выполнять проверки состояния элемента в тестах. /// </summary> /// <returns>Объект ILocatorAssertions для цепочки утверждений</returns> public ILocatorAssertions Expect() => Assertions.Expect(Locator); }
Далее эти 2 абстрактные части реализуются в конкретных бизнесовозначимых типах.
/// <summary> /// Элемент логотипа на странице. Наследует базовую функциональность из ControlBase. /// </summary> public class Logo(ILocator locator) : ControlBase(locator); /// <summary> /// Класс для главной страницы приложения (Kontur). /// Наследует общую функциональность из PageBase и добавляет специфичные для страницы элементы. /// </summary> /// <remarks> /// 1. Преврящает локаторы элементов страницы в типизированные свойства /// 2. Скрывает детали селекторов и структуры HTML /// </remarks> public class KonturPage(IPage page) : PageBase(page) { /// <summary> /// URL главной страницы. Используется для навигации. /// </summary> public override string Url { get; } = Urls.Main; /// <summary> /// Элемент логотипа на странице. /// Инициализируется локатором, найденным по селектору ".kontur-logo-image". /// </summary> public Logo Logo => new Logo(Page.Locator(".kontur-logo-image").First); } /// <summary> /// Класс для хранения констант URL-адресов, используемых в тестах /// </summary> public static class Urls { /// <summary>Основной URL сайта Контур</summary> public const string Main = "https://kontur.ru/"; }
Хорошо! Вспомнили, что такое POM, теперь вспомним как он используется в тестах. А используется он чаще всего так.
Шаг 1: Готовим базовый класс, который подготавливает браузер.
/// <summary> /// Базовый абстрактный класс для тестов с настройкой Playwright. /// Предоставляет общую логику инициализации браузера и создания страниц. /// </summary> public abstract class TestBase { /// <summary> /// Инициализация экземпляра Playwright. /// Выполняется один раз при первом обращении и используется всеми тестами. /// </summary> private static readonly Task<IPlaywright> PlaywrightTask = Playwright.CreateAsync(); /// <summary> /// Создает и возвращает но��ую страницу браузера, переходит по указанному URL. /// Запускает Chromium в видимом режиме (Headless = false). /// </summary> /// <param name="url">URL для перехода</param> /// <returns>Экземпляр страницы Playwright</returns> protected async Task<IPage> GetPageAsync(string url) { var pw = await PlaywrightTask; var browser = await pw.Chromium.LaunchAsync(new BrowserTypeLaunchOptions(){Headless = false}); var browserContext = await browser.NewContextAsync(); var page = await browserContext.NewPageAsync(); await page.GotoAsync(url); return page; } }
Шаг 2: Нарезаем тесты.
/// <summary> /// Тестовый класс для проверки элементов страницы Контур. /// Наследует базовый тестовый класс <see cref="TestBase"/> /// </summary> public class KonturPageShould : TestBase { [Test] public async Task ContainLogo() { var page = await GetPageAsync(Urls.Main); var konturPage = new KonturPage(page); await konturPage.Logo.Expect().ToHaveAttributeAsync("alt", "Kontur"); } }
В лучшем случае можно на уровне базового класса увидеть подобный метод в различных вариациях:
/// <summary> /// Создает экземпляр Page Object и переходит на его URL. /// Универсальный метод для инициализации страниц с автоматическим переходом. /// </summary> /// <typeparam name="TPage"> /// Тип Page Object, наследующий <see cref="PageBase"/>. /// Должен иметь конструктор, принимающий <see cref="IPage"/>, и свойство Url. /// </typeparam> /// <returns> /// Экземпляр указанного типа Page Object, ассоциированный с открытой страницей браузера. /// </returns> /// <remarks> /// Метод выполняет следующую последовательность действий: /// 1. Получает экземпляр Playwright из статической PlaywrightTask. /// 2. Запускает браузер Chromium в видимом режиме (Headless = false). /// 3. Создает новый контекст браузера и новую страницу. /// 4. Создает экземпляр Page Object через рефлексию (Activator.CreateInstance). /// 5. Переходит по URL, указанному в свойстве Url созданного Page Object. /// 6. Возвращает инициализированный Page Object. /// /// Нюансы: /// - Каждый вызов создает новый браузер, контекст и страницу. /// - URL для перехода определяется самим Page Object через свойство Url. /// </remarks> protected async Task<TPage> GoToAsync<TPage>() where TPage : PageBase { var pw = await PlaywrightTask; var browser = await pw.Chromium.LaunchAsync(new BrowserTypeLaunchOptions(){Headless = false}); var browserContext = await browser.NewContextAsync(); var page = await browserContext.NewPageAsync(); var pageObject = (TPage)Activator.CreateInstance(typeof(TPage), page)!; await page.GotoAsync(pageObject.Url); return pageObject; }
Тогда в тесте становится на одну ответственность меньше:
[Test] public async Task ContainLogo() { var konturPage = await GoToAsync<KonturPage>(); await konturPage.Logo.Expect().ToHaveAttributeAsync("alt", "Kontur"); }
Предоставленный код полностью рабочий и спокойно может закрывать какие-то потребности в тестировании. Потратив 10 минут мы уже имеем готовую инфру и рабочие тесты. Но мы потратим ещё 10 минут, дабы сэкономить часы на дистанции.
Итак — композиция
Композиция — это составление целого из частей.
Но что это значит? Мы же и так составляем страницу из частей, которыми являются контролы. Значит всё ок? Оно?
Нет, это не композиция. А всё потому, что не мы составляем страницу из частей. Это страница занимается созданием всех контролов, которые мы на неё добавили.
И здесь мы приходим к тому, что нужно учиться определять и разделять ответственности, при этом нужно не усугубить ситуацию и не перестараться.
Сделаем первый идеальный подход с KonturPage, который должен помочь раскрыть суть.
public class KonturPage(IPage page, Logo logo) : PageBase(page) { public override string Url { get; } = Urls.Main; public Logo Logo { get; } = logo; }
Контрол Logo теперь является параметром конструктора. Он стал деталькой, которую мы просто использовали. Ответственность за создание Logo была передана выше и в таком виде это будет композицией. Но мы перестарались ^_^
Остановимся и зададимся вопросами:
А куда мы передали ответственность за создание Logo?
А на сколько сложным станет конструктор, если на странице 100500 кнопок?
А где data-testid хранить и другие селекторы?
А ведь у каждой страницы будет свой уникальный конструктор? Значит инициализировать её придётся прямо в тесте? Значит и локаторы нужно прописывать прямо в тесте?
А это вообще удобно будет?
Здесь сколько не крути, а в чистом виде удобно не получится.
Отступим, чтобы подумать. Наша задача — передать ответственность за создание контролов и при этом не раздуть сложность.
Вспоминаем про паттерн Factory
Фабрика — это отдельный класс, чья единственная ответственность — создавать другие объекты (страницы, контролы), чтобы основной код не знал деталей их сборки.
Попробуем сделать свою фабрику и для начала обозначим её контракт.
/// <summary> /// Фабрика для создания контролов на веб-страницах. /// Предоставляет стандартизированный способ создания переиспользуемых UI-компонентов. /// </summary> /// <remarks> /// Используется для инкапсуляции логики создания контролов (кнопок, полей ввода и т.д.). /// Позволяет легко подменять реализацию контролов и упрощает поддержку тестов. /// </remarks> public interface IControlFactory { /// <summary>Создает экземпляр указанного типа элемента</summary> /// <typeparam name="TControl"> /// Тип создаваемого элемента, должен наследовать <see cref="ControlBase"/>. /// </typeparam> /// <param name="locator"> /// Локатор Playwright, определяющий положение элемента на странице. /// </param> /// <returns>Инициализированный экземпляр контрола</returns> TControl Create<TControl>(ILocator locator) where TControl : ControlBase; }
Контракт подготовили, можно добавлять на страницу и использовать:
public class KonturPage(IPage page, IControlFactory controlFactory) : PageBase(page) { public override string Url { get; } = Urls.Main; public Logo Logo => controlFactory.Create<Logo>(Page.Locator(".kontur-logo-image").First); }
Здорово! Мы делегировали ответственность за инициализацию фабрике и при этом не раздули конструктор параметрами. При этом мы можем использовать разные реализации IControlFactory. От простой, код которой я добавлю чуть ниже, до специфичных, с обработкой аттрибутов, автоматической валидацией и другими плюшками.
Да, это не чистая композиция, т.к. KonturPage знает некоторые детали того, как приготовить Logo, но лишь некоторые. Это как уточнить цвет при покупке телефона.
Итак, простейшая реализация:
/// <summary> /// Простая реализация фабрики контролов, использующая рефлексию для создания экземпляров. /// Создает контролы с помощью активатора, передавая в конструктор локатор. /// </summary> public class SimpleControlFactory : IControlFactory { public TControl Create<TControl>(ILocator locator) where TControl : ControlBase => (TControl)Activator.CreateInstance(typeof(TControl), locator)!; }
Ваш внутренний монолог
Всё сломалось! Тест красный! Не компилитс��!
Так, спокойно, надо разобраться. Мы модернизировали конструктор объекта KonturPage, а эта страница создавалась вот в этом тесте:
[Test] public async Task ContainLogo() { var page = await GetPageAsync(Urls.Main); var konturPage = new KonturPage(page); // <--- вот тут await konturPage.Logo.Expect().ToHaveAttributeAsync("alt", "Kontur"); }
Значит нам надо сделать как-то так?
var factory = new SimpleControlFactory(); var konturPage = new KonturPage(page, factory);
Блииииин.... А таких страниц-то по всему тестовому проекту раскидано ого-го сколько. Это мне каждый нужно обновить? А что если SimpleControlFactory начнёт зависеть от какого-нибудь логера или ещё от чего-то, это снова весь проект перелопатить?
Аааа... Оооо... Ответственность. Надо убрать её из теста. А там в самом начале статьи даже пример был с базовым классом. Но базовый класс плохо перегружать. Лучше сделаю фабрику для страницы.
/// <summary> /// Фабрика для создания экземпляров Page Object'ов. /// Обеспечивает единый механизм инициализации страниц в тестах. /// </summary> /// <remarks> /// Реализует паттерн "Фабрика" для создания объектов, представляющих веб-страницы. /// Позволяет централизовать логику создания Page Object'ов. /// </remarks> public interface IPageFactory { /// <summary>Создает экземпляр указанного типа страницы</summary> /// <typeparam name="TPage"> /// Тип создаваемого PageObject, должен наследовать <see cref="PageBase"/>. /// </typeparam> /// <param name="page">Экземпляр страницы Playwright</param> /// <returns>Инициализированный экземпляр страницы</returns> TPage Create<TPage>(IPage page) where TPage : PageBase; }
Таааак... Контракт готов! Теперь добавлю реализацию:
/// <summary> /// Реализация фабрики страниц, которая инкапсулирует создание Page Object'ов с внедрением зависимостей. /// Позволяет создавать страницы, автоматически получающие доступ к фабрике контролов. /// </summary> /// <remarks> /// Использует композицию для предоставления фабрики контролов создаваемым страницам, /// что позволяет страницам создавать свои собственные контролы через единый интерфейс. /// </remarks> /// <param name="factory">Фабрика контролов, которая будет передаваться в создаваемые страницы.</param> public class PageFactory(IControlFactory factory) : IPageFactory { public TPage Create<TPage>(IPage page) where TPage : PageBase => (TPage)Activator.CreateInstance(typeof(TPage), [page, factory])!; }
Вообще огненно! Теперь уберу ответственность из теста.
[Test] public async Task ContainLogo() { var konturPage = await GoToAsync<KonturPage>(); await konturPage.Logo.Expect().ToHaveAttributeAsync("alt", "Kontur"); }
И поправлю базовый класс.
protected static readonly IControlFactory ControlFactory = new SimpleControlFactory(); protected static readonly IPageFactory PageFactory = new PageFactory(ControlFactory); protected async Task<TPage> GoToAsync<TPage>() where TPage : PageBase { var pw = await PlaywrightTask; var browser = await pw.Chromium.LaunchAsync(new BrowserTypeLaunchOptions(){Headless = false}); var browserContext = await browser.NewContextAsync(); var page = await browserContext.NewPageAsync(); var pageObject = PageFactory.Create<TPage>(page); await page.GotoAsync(pageObject.Url); return pageObject; }
Эх... Всё равно в базовом классе пришлось инициализацию фабрики сделать. Только теперь двух, а не одной.
Что там дальше в статье написано?
Вы обо всём правильно подумали!
Во-первых, мы подметили, что инициализация объектов может быть размазана по всей системе. Страницы мы создавали в тесте, а локаторы в страницах. Одно маленькое изменение влечёт за собой большой рефакторинг. Во избежание этого и применяются стандартные подходы в ООП и разрабатывается удобная инфраструктура с применением базовых паттернов.
Во-вторых, мы подметили, что нехорошо перегружать базовый класс, и вновь задумались об ответственности класса. Должны ли все объекты создаваться в базовом классе? Может быть, можно прикрутить какую-то гигафабрику?
Да, можно. И этой гигафабрикой будет DI Container от Microsoft.
Добавляем DI
Я не буду подробно разбирать работу DI контейнеров. Обозначу лишь базовый минимум. Остальное легко можно найти в интернете. Например на ulearn.me или learn.microsoft.com.
Мы воспользуемся библиотекой Microsoft.Extensions.DependencyInjection.
Сейчас нас интересуют:
IServiceProvider. Это сервис, который будет выполнять роль нашей гигафабрики. Помимо самой инициализации нужных нам сервисов он так же управляет и их жизненным циклом в соответствии с теми правилами, которые мы для него подготовим.
IServiceCollection. Как раз таки и позволяет задать правила, по которым будут собираться сервисы.
Погнали добавим в TestBase.
public abstract class TestBase { private static readonly Task<IPlaywright> PlaywrightTask = Playwright.CreateAsync(); /// <summary> /// Защищенное (доступное в классе и наследниках) и доступное только для чтения поле, /// содержащее ServiceProvider с зарегистриро��анными зависимостями. /// </summary> protected readonly IServiceProvider ServiceProvider = new ServiceCollection() // Регистрирует фабрику страниц (Page Factory) в контейнере зависимостей. // Scoped гарантирует, что в рамках одного scope (например, одного теста) будет использоваться один и тотже экземпляр фабрики. .AddScoped<IPageFactory, PageFactory>() // Регистрирует фабрику элементов управления (Control Factory) в контейнере зависимостей. // Scoped гарантирует, что в рамках одной области видимости (например, одного теста) будет использоваться один экземпляр фабрики. .AddScoped<IControlFactory, SimpleControlFactory>() // Создает и возвращает IServiceProvider на основе сконфигурированной коллекции. // После вызова BuildServiceProvider() контейнер становится неизменяемым. .BuildServiceProvider(); protected async Task<TPage> GoToAsync<TPage>() where TPage : PageBase { var pw = await PlaywrightTask; var browser = await pw.Chromium.LaunchAsync(new BrowserTypeLaunchOptions(){Headless = false}); var browserContext = await browser.NewContextAsync(); var page = await browserContext.NewPageAsync(); // Теперь не TestBase инициализирует IPageFactory. // Теперь этим занимается наша "гигафабрика" var pageFactory = ServiceProvider.GetRequiredService<IPageFactory>(); // И мы просто ее используем для создания страницы. var pageObject = pageFactory.Create<TPage>(page); await page.GotoAsync(pageObject.Url); return pageObject; } }
Обратите внимание, что при регистрации IPageFactory мы никак не обозначили её зависимость от IControlFactory. DI контейнер сам анализирует нужные параметры конструктора, внутри себя ищет подходящие сервисы и использует их. Главное, чтобы они были зарегистрированы.
Хорошо! С DI познакомились. Теперь вернёмся к реализации SimpleControlFactory:
public class SimpleControlFactory : IControlFactory { public TControl Create<TControl>(ILocator locator) where TControl : ControlBase => (TControl)Activator.CreateInstance(typeof(TControl), locator)!; }
Сейчас нас интересует метод .
CreateInstance(typeof(TControl), locator);
Этот метод занимается инициализацией нужного нам контрола. Первым параметром мы передали тип, а вторым мы должны передать аргументы конструктора. У контролов зависимость только от ILocator, потому мы передали только locator. А вот в PageFactory
в CreateInstance мы передавали еще и factory, так как расширили конструктор страницы фабрикой.
public class PageFactory(IControlFactory factory) : IPageFactory { public TPage Create<TPage>(IPage page) where TPage : PageBase => (TPage)Activator.CreateInstance(typeof(TPage), [page, factory])!; }
Получилось не гибко, т.к. добавление новых параметров в конструктор потребует изменение метода фабрики. При этом нужно учитывать, что конструкторы у страниц в итоге могут отличаться. Для какой-то страницы мы захотели добавить генератор гифок, например, а для другой он не нужен. Как быть?
Первый вариант — это начать регистрировать страницы в DI. Этот подход имеет место быть, но он не подходит для контролов, которые находятся в такой же ситуации.
Поэтому пойдём другим путём. Добавим фабрику зависимостей, которая будет анализировать конструкторы страниц и контролов, и из IServiceProvider брать всё необходимое. Нам нужно будет отфильтровать из параметров IPage и ILocator, так как их мы передаём в конструктор самостоятельно.
/// <summary> /// Интерфейс фабрики зависимостей для создания объектов с dependency injection. /// Определяет контракт для разрешения зависимостей конструкторов POM. /// </summary> public interface IDependenciesFactory { /// <summary> /// Создать массив зависимостей для указанного типа контрола. /// </summary> /// <param name="controlType">Тип POM'а</param> /// <returns>Массив разрешённых зависимостей для конструктора</returns> object[] CreateDependency(Type pomType); } /// <summary> /// Фабрика зависимостей для интеграции с Microsoft.Extensions.DependencyInjection. /// Создаёт зависимости для конструкторов POM через DI контейнер. /// </summary> public class DependencyFactory(IServiceProvider serviceProvider) : IDependenciesFactory { /// <summary> /// Создать массив зависимостей для указанного типа контрола. /// Использует DI контейнер для разрешения зависимостей. /// </summary> /// <param name="pomType">Тип объекта, для которого создаются зависимости</param> /// <returns>Массив разрешённых зависимостей для конструктора</returns> /// <exception cref="NotSupportedException">Выбрасывается, если класс имеет более одного конструктора</exception> public object[] CreateDependency(Type pomType) { var constructors = pomType.GetConstructors(); if (constructors.Length != 1) { throw new NotSupportedException($"{pomType} должен иметь только один конструктор"); } var constructor = constructors.Single(); var parameters = constructor .GetParameters() .Where(x => x.ParameterType != typeof(IPage)) .Where(x => x.ParameterType != typeof(ILocator)) .Select<ParameterInfo, object>(x => serviceProvider.GetRequiredService(x.ParameterType)); return parameters.ToArray(); } }
Далее вспоминаем, чему мы научились в разделе про композицию, и добавляем этот новый кубик в фабрики:
// Смотрим на конструктор в котором появился кубик по имени dependenciesFactory public class SimpleControlFactory(IDependenciesFactory dependenciesFactory) : IControlFactory { public TControl Create<TControl>(ILocator locator) where TControl : ControlBase { var dependency = dependenciesFactory.CreateDependency(typeof(TControl)); return (TControl)Activator.CreateInstance( typeof(TControl), new []{locator}.Concat(dependency).ToArray() )!; } } public class PageFactory(IDependenciesFactory dependenciesFactory) : IPageFactory { public TPage Create<TPage>(IPage page) where TPage : PageBase { var dependency = dependenciesFactory.CreateDependency(typeof(TPage)); return (TPage)Activator.CreateInstance( typeof(TPage), new []{page}.Concat(dependency).ToArray() )!; } }
Обратите внимание, что мы убрали зависимость от IControlFactory из PageFactory. Теперь она будет решаться с помощью DI и IDependenciesFactory. Таким образом у нас появилась гибкость в создании страниц и контролов. Мы в любой момент можем добавить новый кубик в нужное нам место и при этом не придётся протаскивать его по всей системе.
Осталось только зарегистрировать в DI нашу фабрику зависимостей.
protected readonly IServiceProvider ServiceProvider = new ServiceCollection() .AddScoped<IPageFactory, PageFactory>() .AddScoped<IControlFactory, SimpleControlFactory>() .AddScoped<IDependenciesFactory, DependencyFactory>() .BuildServiceProvider();
На этом мы закончили с композицией в архитектуре POM, но не закончили с инфраструктурой в целом.
Работа с Playwright
Ещё раз посмотрим на код перехода на страницу сайта.
protected async Task<TPage> GoToAsync<TPage>() where TPage : PageBase { var pw = await PlaywrightTask; var browser = await pw.Chromium.LaunchAsync(new BrowserTypeLaunchOptions(){Headless = false}); var browserContext = await browser.NewContextAsync(); var page = await browserContext.NewPageAsync(); var pageObject = PageFactory.Create<TPage>(page); await page.GotoAsync(pageObject.Url); return pageObject; }
У этого метода много ответственности: дождаться запуска Playwright, запустить браузер, инициализировать новый контекст, инициализировать новую страницу браузера, попросить фабрику инициализировать нужную нам страницу и перейти на неё. Мы взаимодействуем с четырьмя слоями инфраструктуры Playwright. При этом, у каждого есть свои параметры, которые, возможно, захочется когда-то начать менять.
Декомпозируем этот метод на части с понятной единичной ответственностью каждого, и получим такие контракты:
/// <summary> /// Интерфейс для асинхронного получения экземпляра IPlaywright - корневого объекта библиотеки Playwright. /// Определяет стратегию инициализации или получения экземпляра Playwright, абстрагируя конкретную реализацию. /// </summary> public interface IPlaywrightGetter { /// <summary> /// Асинхронно возвращает инициализированный экземпляр IPlaywright, готовый к использованию. /// Реализация может создавать новый экземпляр, возвращать существующий или использовать пул объектов. /// </summary> /// <returns>Задача, результатом которой является готовый к использованию объект IPlaywright</returns> Task<IPlaywright> GetAsync(); } /// <summary> /// Интерфейс для асинхронного получения экземпляра IBrowser - представления браузера в Playwright. /// Абстрагирует процесс запуска или подключения к браузеру, позволяя централизованно управлять его конфигурацией. /// </summary> /// <remarks> /// Инкапсулирует логику инициализации браузера, включая выбор типа браузера, /// опции запуска, использование прокси и другие настройки, предоставляя унифицированный интерфейс для тестов. /// </remarks> public interface IBrowserGetter { /// <summary> /// Возвращает экземпляр браузера, готовый для создания страниц и выполнения действий. /// </summary> /// <returns>Задача, результатом которой является готовый к использованию объект IBrowser</returns> Task<IBrowser> GetAsync(); } /// <summary> /// Интерфейс для асинхронного получения экземпляра IBrowserContext - изолированного контекста браузера. /// Абстрагирует создание и настройку контекста, включая куки, разрешения, геолокацию и другие параметры. /// </summary> /// <remarks> /// Инкапсулирует настройку контекста браузера, обеспечивая единообразную /// конфигурацию для всех тестов и скрывая сложность установки параметров контекста. /// </remarks> public interface IBrowserContextGetter { /// <summary> /// Возвращает новый или существующий контекст браузера с предварительно настроенными параметрами. /// Контекст обеспечивает изоляцию сессий и может содержать уникальные куки, настройки и разрешения. /// </summary> /// <returns>Задача, результатом которой является настроенный объект IBrowserContext</returns> Task<IBrowserContext> GetAsync(); } /// <summary> /// Интерфейс для асинхронного получения экземпляра IPage - представления страницы или вкладки браузера. /// Определяет стратегию создания новых страниц или получения существующих, абстрагируя детали навигации. /// </summary> /// <remarks> /// Инкапсулирует процесс создания и инициализации страницы, обеспечивая /// стандартную настройку (размер окна, таймауты, обработчики событий) и единый интерфейс для работы. /// </remarks> public interface IPlaywrightPageGetter { /// <summary> /// Возвращает готовую к использованию страницу браузера. /// </summary> /// <returns>Задача, результатом которой является объект IPage, готовый для взаимодействия</returns> Task<IPage> GetAsync(); }
С контрактами определились, теперь добавим реализации этих интерфейсов.
public class PlaywrightSingleton : IPlaywrightGetter { /// <summary> /// Создаётся при первом обращении 1 раз. /// </summary> private static readonly Task<IPlaywright> PlaywrightTask = Playwright.CreateAsync(); public Task<IPlaywright> GetAsync() => PlaywrightTask; } public class ChromiumGetter(IPlaywrightGetter playwrightGetter) : IBrowserGetter { /// <summary> /// Лениво инициализируемый контекст браузера. /// Создаётся при первом обращении. /// </summary> private readonly Lazy<Task<IBrowser>> _browser = new(() => CreateBrowserAsync(playwrightGetter)); public Task<IBrowser> GetAsync() => _browser.Value; private static async Task<IBrowser> CreateBrowserAsync(IPlaywrightGetter getter) { var pw = await getter.GetAsync(); return await pw.Chromium.LaunchAsync(); } } public class BrowserContextGetter(IBrowserGetter browserGetter) : IBrowserContextGetter { /// <summary> /// Лениво инициализируемый контекст браузера. /// Создаётся при первом обращении. /// </summary> private readonly Lazy<Task<IBrowserContext>> _browserContext = new(() => CreateContextAsync(browserGetter)); public Task<IBrowserContext> GetAsync() => _browserContext.Value; private static async Task<IBrowserContext> CreateContextAsync(IBrowserGetter getter) { var browser = await getter.GetAsync(); return await browser.NewContextAsync(); } } public class PlaywrightPageGetter(IBrowserContextGetter browserContextGetter) : IPlaywrightPageGetter { /// <summary> /// Лениво инициализируемая страница браузера. /// При первом обращении получает существующую страницу или создаёт новую. /// </summary> private readonly Lazy<Task<IPage>> _page = new(() => CreatePageAsync(browserContextGetter)); public Task<IPage> GetAsync() => _page.Value; private static async Task<IPage> CreatePageAsync(IBrowserContextGetter getter) { var browser = await getter.GetAsync(); return await browser.NewPageAsync(); } }
Да, кода стало больше, но и гибкости тоже прибавилось. Теперь каждый из этих слоев можно легко заменить, добавить нужные настройки, добавить разные стратегии авторизации, добавить запись трассировок и всякое другое. Главное не забыть зарегистрировать нужную детальку композиции в DI. Поэтому возвращаемся в базовый класс и донастраиваем.
public abstract class TestBase { protected readonly IServiceProvider ServiceProvider = new ServiceCollection() .AddSingleton<IPlaywrightGetter, PlaywrightSingleton>() .AddScoped<IBrowserGetter, ChromiumGetter>() .AddScoped<IBrowserContextGetter, BrowserContextGetter>() .AddScoped<IPlaywrightPageGetter, PlaywrightPageGetter>() .AddScoped<IPageFactory, PageFactory>() .AddScoped<IControlFactory, SimpleControlFactory>() .AddScoped<IDependenciesFactory, DependencyFactory>() .BuildServiceProvider(); protected async Task<TPage> GoToAsync<TPage>() where TPage : PageBase { var pwPageGetter = ServiceProvider.GetRequiredService<IPlaywrightPageGetter>(); var pageFactory = ServiceProvider.GetRequiredService<IPageFactory>(); var pwPage = await pwPageGetter.GetAsync(); var pageObject = pageFactory.Create<TPage>(pwPage); await pwPage.GotoAsync(pageObject.Url); return pageObject; } }
Чтобы выглядело чище, лучше всю регистрацию вынести в метод расширения. С этим вы точно должны справиться.
Scope
Чтобы тесты друг с другом не сражались за один общий (синглтоновый) браузер, нам нужно как-то ограничить им область видимости. Полдела мы уже сделали, кода использовали метод AddScoped при регистрации зависимостей. Нам остаётся только научиться открывать и закрывать эти скоупы.
Для этого в TestBase сделаем следующие изменения.
// закрываем рутовый сервис провайдер модификатором private private readonly IServiceProvider _serviceProvider = new ServiceCollection().AddPlaywright().BuildServiceProvider(); // добавляем коллекцию для хранения связи между тестом и его скоупом private static readonly ConcurrentDictionary<string, IServiceScope> ScopeByTestLink = new(); // работаем с изолированным ServiceProvider для каждого теста protected IServiceProvider ServiceProvider => ScopeByTestLink.GetOrAdd(TestContext.CurrentContext.Test.ID, _ => _serviceProvider.CreateScope()) .ServiceProvider; [TearDown] private void CloseScope() { // удаляем скоуп и диспозим его по окончанию теста if (ScopeByTestLink.TryRemove(TestContext.CurrentContext.Test.ID, out var scope)) { scope.Dispose(); // чтобы закрывался браузер достаточно добавить в нужный слой метод Dispose // тогда при закрытии скоупа закроется и браузер (или в пул вернется, если сделаете пул) } }
Собственно и всё. Базовая инфраструктура готова. Теперь, когда все компоненты собраны в единую систему, предлагаю резюмировать ключевые моменты. Всё остальное вы сможете легко допилить под конкретный проект.
Подытожим
Тезисно вспомним всё, что прочитали, чтобы лучше усвоилось.
1. Классический Page Object Model
Реализован паттерн Wrapper через абстрактные классы
PageBaseиControlBase.Прямое создание объектов в тестах.
Простая и рабочая архитектура, но с ограниченной гибкостью.
2. Введение паттерна Factory
Создан IControlFactory и IPageFactory для централизованного создания объектов.
Делегирована ответственность за инициализацию объектов фабрикам.
Убрана инициализация объектов из тестов.
3. Интеграция Dependency Injection
Подключен Microsoft.Extensions.DependencyInjection.
Автоматическое разрешение зависимостей через DI-контейнер.
Централизованное управление жизненным циклом объектов.
4. Применение композиции
Создан IDependenciesFactory для анализа конструкторов и разрешения зависимостей.
Объекты создаются путём композиции (сборки из частей).
Гибкая система зависимостей — легко добавлять новые компоненты без изменения существующего кода.
5. Декомпозиция инфраструктуры Playwright
Разделены ответственности за создание Playwright, браузера, контекста и страницы.
Созданы интерфейсы: IPlaywrightGetter, IBrowserGetter, IBrowserContextGetter, IPlaywrightPageGetter.
Каждая часть инфраструктуры может быть легко заменена или настроена.
6. Управление скоупами
Реализована изоляция тестов через scoped зависимости.
Каждый тест получает свой собственный scope с изолированными ресурсами.
Автоматическая очистка ресурсов после завершения теста.
Итоговая структура проекта
TestBase (с DI-контейнером) ├── PlaywrightCore (инфраструктура браузера) │ ├── IPlaywrightGetter → PlaywrightSingleton │ ├── IBrowserGetter → ChromiumGetter │ ├── IBrowserContextGetter → BrowserContextGetter │ └── IPlaywrightPageGetter → PlaywrightPageGetter ├── POM (Page Object Model с фабриками) │ ├── IPageFactory → PageFactory (создает страницы) │ ├── IControlFactory → SimpleControlFactory (создает контролы) │ └── IDependenciesFactory → DependencyFactory (разрешает зависимости) └── Тесты
Ближайшие улучшения, которые вы можете сделать:
Вынести логику управления DI и scope'ами из базового класса.
Вынести метод GoToAsync в отдельный класс, например, Navigation.
Добавить методы Dispose в нужные слои, например, в BrowserGetter, для закрытия браузера после теста.
Добавить сервис аутентификации и добавить его в нужный слой, чтобы не проводить авторизацию в каждом тесте.
Добавить настройки для каждого из слоёв Playwright.
Послесловие
Ваши UI-тесты — это полноценная система, заслуживающая качественного дизайна. Хорошая архитектура складывается постепенно. Начинайте с малого, применяйте принципы осознанно и не перебарщивайте со специями, чтобы ваши тесты были вкусными и полезными. 👌
А для быстрого старта можно воспользоваться готовой библиотекой SkbKontur.Playwright.TestCore.
