UI Testing with Reqnroll and Selenium WebDriver
Reqnroll and Selenium WebDriver pair well for UI testing: Reqnroll provides the Gherkin layer that product and QA teams can read and author, while Selenium handles the browser automation. This guide walks through project setup, the Page Object pattern in a BDD context, parallel execution, and screenshot capture on failure.
Project Setup
Create a test project and install the required packages:
dotnet new classlib -n MyApp.UISpecs
cd MyApp.UISpecs
dotnet add package Reqnroll.NUnit
dotnet add package NUnit
dotnet add package NUnit3TestAdapter
dotnet add package Microsoft.NET.Test.Sdk
dotnet add package Selenium.WebDriver
dotnet add package Selenium.WebDriver.ChromeDriverSelenium.WebDriver.ChromeDriver downloads a matching ChromeDriver binary at build time. If you need a specific browser or version, use WebDriverManager instead:
dotnet add package WebDriverManagerBrowser Context Class
Manage the IWebDriver instance via a shared context class. This lets multiple step definition classes access the same driver within a scenario without passing it explicitly.
public class BrowserContext : IDisposable
{
private IWebDriver? _driver;
public IWebDriver Driver => _driver ?? throw new InvalidOperationException("Driver not initialized");
public void Initialize(string browser = "chrome")
{
_driver = browser.ToLower() switch
{
"chrome" => new ChromeDriver(new ChromeOptions()),
"firefox" => new FirefoxDriver(),
_ => throw new ArgumentException($"Unknown browser: {browser}")
};
_driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(5);
_driver.Manage().Window.Maximize();
}
public void Dispose()
{
_driver?.Quit();
_driver?.Dispose();
}
}Register and clean it up via hooks:
[Binding]
public class BrowserHooks
{
private readonly BrowserContext _browser;
private readonly ScenarioContext _scenarioContext;
public BrowserHooks(BrowserContext browser, ScenarioContext scenarioContext)
{
_browser = browser;
_scenarioContext = scenarioContext;
}
[BeforeScenario]
public void StartBrowser()
{
_browser.Initialize();
}
[AfterScenario]
public void CloseBrowser()
{
_browser.Dispose();
}
}Page Object Pattern with BDD Steps
Page Objects encapsulate element selectors and page-level actions. Step definitions call Page Object methods rather than interacting with the driver directly.
public class LoginPage
{
private readonly IWebDriver _driver;
public LoginPage(IWebDriver driver)
{
_driver = driver;
}
private IWebElement EmailField => _driver.FindElement(By.Id("email"));
private IWebElement PasswordField => _driver.FindElement(By.Id("password"));
private IWebElement SubmitButton => _driver.FindElement(By.CssSelector("button[type='submit']"));
private IWebElement ErrorMessage => _driver.FindElement(By.CssSelector(".error-message"));
public void NavigateTo(string baseUrl) => _driver.Navigate().GoToUrl($"{baseUrl}/login");
public void EnterEmail(string email) => EmailField.SendKeys(email);
public void EnterPassword(string password) => PasswordField.SendKeys(password);
public void Submit() => SubmitButton.Click();
public string GetErrorMessage() => ErrorMessage.Text;
}Step definitions wire Gherkin to the Page Object:
[Binding]
public class LoginStepDefinitions
{
private readonly BrowserContext _browser;
private readonly LoginPage _loginPage;
public LoginStepDefinitions(BrowserContext browser)
{
_browser = browser;
_loginPage = new LoginPage(browser.Driver);
}
[Given("I am on the login page")]
public void GivenIAmOnTheLoginPage()
{
_loginPage.NavigateTo("https://app.example.com");
}
[When("I log in with email {string} and password {string}")]
public void WhenILogInWith(string email, string password)
{
_loginPage.EnterEmail(email);
_loginPage.EnterPassword(password);
_loginPage.Submit();
}
[Then("I should see an error {string}")]
public void ThenIShouldSeeAnError(string expectedError)
{
Assert.That(_loginPage.GetErrorMessage(), Is.EqualTo(expectedError));
}
}The corresponding feature file:
Feature: Login
Scenario: Invalid credentials show error
Given I am on the login page
When I log in with email "bad@example.com" and password "wrong"
Then I should see an error "Invalid email or password"Parallel Browser Test Execution
Reqnroll with NUnit supports parallel scenario execution. Add the assembly-level attribute:
// AssemblyInfo.cs
using NUnit.Framework;
[assembly: Parallelizable(ParallelScope.Fixtures)]Or in a separate file alongside your test project:
[assembly: LevelOfParallelism(4)]Each scenario gets its own BrowserContext instance because Reqnroll creates a new object graph per scenario when using constructor injection. No additional isolation code is needed -- as long as your Page Objects and context classes hold no static state, parallel runs work without race conditions.
For CI environments without a display, use headless Chrome:
public void Initialize()
{
var options = new ChromeOptions();
options.AddArgument("--headless=new");
options.AddArgument("--no-sandbox");
options.AddArgument("--disable-dev-shm-usage");
_driver = new ChromeDriver(options);
}Screenshots on Failure
Capture a screenshot in [AfterScenario] when the scenario status is TestError or StepDefinitionPending:
[AfterScenario]
public void TakeScreenshotOnFailure()
{
if (_scenarioContext.TestError == null)
return;
var screenshot = ((ITakesScreenshot)_browser.Driver).GetScreenshot();
var fileName = $"screenshot_{_scenarioContext.ScenarioInfo.Title.Replace(" ", "_")}_{DateTime.Now:yyyyMMdd_HHmmss}.png";
var outputDir = Path.Combine(TestContext.CurrentContext.WorkDirectory, "screenshots");
Directory.CreateDirectory(outputDir);
screenshot.SaveAsFile(Path.Combine(outputDir, fileName));
TestContext.AddTestAttachment(Path.Combine(outputDir, fileName), "Failure screenshot");
}TestContext.AddTestAttachment attaches the file to the NUnit test result, which Azure DevOps and GitHub Actions will pick up and display in the test report.
Explicit Waits
Avoid Thread.Sleep. Use WebDriverWait for elements that appear asynchronously:
public IWebElement WaitForElement(By locator, int timeoutSeconds = 10)
{
var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(timeoutSeconds));
return wait.Until(SeleniumExtras.WaitHelpers.ExpectedConditions.ElementIsVisible(locator));
}Add Selenium.Support to get WebDriverWait and ExpectedConditions:
dotnet add package Selenium.SupportPair explicit waits with Page Objects to keep step definitions free of timing concerns -- the page abstraction handles waiting internally, and steps read as pure business logic.