This article is part of the “Introduction to E2E Testing with Reqnroll × Playwright” series.
Reqnroll + Playwright Project Structure in .NET (Best Practices)
Conclusion
The best way to use Playwright in a Reqnroll (.NET) project is to separate responsibilities into Hooks, Steps, Pages, and Features.
This structure keeps your E2E tests clean, scalable, and easy to maintain, especially as your test suite grows.
What You’ll Learn
In this guide, you’ll learn:
- The recommended project structure for Reqnroll + Playwright
- Where to initialize Playwright
- How Step Definitions interact with the browser
- How to manage the
Pageobject - Why the Page Object pattern improves maintainability
This article focuses on code structure, not environment setup.
Recommended Project Structure
A common structure for Reqnroll + Playwright projects looks like this:
TestProject
├ Hooks
│ └ PlaywrightHooks.cs
│
├ Pages
│ └ LoginPage.cs
│
├ Steps
│ └ LoginSteps.cs
│
└ Features
└ Login.feature
Folder Responsibilities
| Folder | Purpose |
|---|---|
| Features | BDD scenarios (Gherkin) |
| Steps | Scenario implementation (C#) |
| Hooks | Playwright setup and teardown |
| Pages | Page Object classes |
This separation makes your test code easier to manage as it scales.
Initializing Playwright with Hooks
The recommended place to initialize Playwright is inside Hooks.
Reqnroll provides lifecycle hooks like:
BeforeScenarioAfterScenario
These allow you to manage setup and cleanup in one place.
Example: PlaywrightHooks
using Microsoft.Playwright;
using Reqnroll;
[Binding]
public class PlaywrightHooks
{
public static IPage Page;
private static IBrowser browser;
private static IPlaywright playwright;
[BeforeScenario]
public async Task BeforeScenario()
{
playwright = await Playwright.CreateAsync();
browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = false
});
var context = await browser.NewContextAsync();
Page = await context.NewPageAsync();
}
[AfterScenario]
public async Task AfterScenario()
{
await browser.CloseAsync();
playwright.Dispose();
}
}
PlaywrightHooks Class Explained
Role of the PlaywrightHooks Class
This class uses Reqnroll’s Hooks feature to start and stop the Playwright browser before and after test execution.
In Reqnroll, Hooks allow you to centralize common setup and teardown logic, such as:
- Pre-test processing
- Post-test processing
In this implementation, a browser is launched for each Scenario and closed when the Scenario finishes.
Using Directives
using Microsoft.Playwright;
using Reqnroll;
These statements import the required libraries.
| Namespace | Purpose |
|---|---|
| Microsoft.Playwright | Playwright API |
| Reqnroll | Reqnroll BDD functionality |
Classes used to control Playwright (such as IPage and IBrowser) are included in the Microsoft.Playwright namespace.
Binding Attribute
[Binding]
public class PlaywrightHooks
The [Binding] attribute tells Reqnroll to recognize this class during test execution.
Any class marked with [Binding] is registered as:
- Step Definitions
- Hooks
This attribute is required for the class to function as Hooks.
Field (Variable) Definitions
public static IPage Page;
private static IBrowser browser;
private static IPlaywright playwright;
These fields define the main Playwright objects.
| Variable | Role |
|---|---|
| IPlaywright | Playwright core |
| IBrowser | Browser instance |
| IPage | Browser tab (page) |
Playwright operates with the following hierarchy:
Playwright
↓
Browser
↓
BrowserContext
↓
Page
In this setup, the Page object is exposed so it can be used in Step Definitions.
It is declared as public static so it can be accessed from other classes.
Example:
await PlaywrightHooks.Page.GotoAsync("https://example.com");
BeforeScenario
[BeforeScenario]
public async Task BeforeScenario()
[BeforeScenario] marks a method that runs before each Scenario.
Execution flow:
Scenario Start
↓
BeforeScenario
↓
Step Definitions
Starting Playwright
playwright = await Playwright.CreateAsync();
This creates a Playwright instance.
You must create this object before using Playwright.
Launching the Browser
browser = await playwright.Chromium.LaunchAsync(
new BrowserTypeLaunchOptions
{
Headless = false
});
This launches a Chromium browser.
The Headless setting controls whether the browser UI is displayed:
| Value | Meaning |
|---|---|
| true | Runs without UI (headless) |
| false | Shows the browser window |
For debugging, setting Headless = false is useful to observe browser behavior.
Creating a BrowserContext
var context = await browser.NewContextAsync();
A BrowserContext represents an isolated browser session.
Conceptually:
Browser
├ Session 1 (incognito)
├ Session 2 (incognito)
By creating a new context per Scenario, you can isolate:
- Cookies
- LocalStorage
- Session data
Creating a Page
Page = await context.NewPageAsync();
This creates a browser tab (Page).
All Playwright interactions are performed through this object.
For example:
Page.GotoAsync()
Page.ClickAsync()
Page.FillAsync()
AfterScenario
[AfterScenario]
public async Task AfterScenario()
[AfterScenario] runs after each Scenario completes.
Execution flow:
Scenario End
↓
AfterScenario
Closing the Browser
await browser.CloseAsync();
This closes the browser.
Closing the browser after each test helps:
- Prevent memory leaks
- Ensure test isolation
Disposing Playwright
playwright.Dispose();
This releases Playwright resources.
In .NET, it is recommended to call Dispose when working with libraries that use unmanaged or external resources.
Overall Execution Flow
The Hooks execute in the following order:
Scenario Start
↓
BeforeScenario
↓
Playwright initialized
↓
Browser launched
↓
Context created
↓
Page created
↓
Step Definitions executed
↓
AfterScenario
↓
Browser closed
↓
Playwright disposed
Using Playwright in Step Definitions
Step Definitions use the Page created in Hooks.
Example
using Reqnroll;
[Binding]
public class LoginSteps
{
[Given(@"I open the login page")]
public async Task OpenLoginPage()
{
await PlaywrightHooks.Page.GotoAsync("https://example.com/login");
}
[When(@"I enter username and password")]
public async Task InputLogin()
{
await PlaywrightHooks.Page.FillAsync("#user", "test");
await PlaywrightHooks.Page.FillAsync("#password", "password");
}
[When(@"I click the login button")]
public async Task ClickLogin()
{
await PlaywrightHooks.Page.ClickAsync("#login");
}
}
Execution Flow
Feature → Step Definition → Playwright → Browser
Using the Page Object Pattern
As your test code grows, writing Playwright logic directly in Step Definitions becomes hard to maintain.
The solution is the Page Object pattern.
Example: LoginPage
using Microsoft.Playwright;
public class LoginPage
{
private readonly IPage page;
public LoginPage(IPage page)
{
this.page = page;
}
public async Task Open()
{
await page.GotoAsync("https://example.com/login");
}
public async Task Login(string user, string password)
{
await page.FillAsync("#user", user);
await page.FillAsync("#password", password);
await page.ClickAsync("#login");
}
}
Using Page Object in Step Definitions
[Binding]
public class LoginSteps
{
private readonly LoginPage loginPage;
public LoginSteps()
{
loginPage = new LoginPage(PlaywrightHooks.Page);
}
[Given(@"I open the login page")]
public async Task OpenPage()
{
await loginPage.Open();
}
}
Benefits
- Cleaner Step Definitions
- Reusable UI logic
- Easier maintenance when UI changes
Overall Test Architecture
A well-structured test follows this flow:
Feature (Gherkin)
↓
Step Definition (C#)
↓
Page Object
↓
Playwright
↓
Browser Automation
This layered approach improves readability and scalability.
Best Practices
Keep Responsibilities Separate
- Hooks → setup/teardown
- Steps → test logic
- Pages → UI interactions
Use One Page Object per Screen
Avoid mixing multiple pages in one class.
Isolate Scenarios
Create a new BrowserContext per scenario to avoid shared state.
Use Headed Mode for Debugging
Set:
Headless = false
to visually debug browser behavior.
Summary
To build maintainable E2E tests with Reqnroll and Playwright in .NET:
- Use Hooks to manage browser lifecycle
- Use Step Definitions for scenario logic
- Use Page Objects for UI operations
- Follow a clear folder structure
This approach ensures your test code remains clean, scalable, and production-ready as your project grows.
