Привет! Я Миша Симонов, работаю в Контуре ведущим специалистом по тестированию и являюсь техлидом по автоматизации тес��ирования кластера из 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.