How to use Playwright with SpecFlow and Page Object Models in .NET (C#)

Adam Clarkson
Hippo Digital
9 min readDec 20, 2022

--

An animated gif showing the finished test being run from the Rider test controls before a browser window pops up. The browser window then changes as the test runs, going to the DuckDuckGo website and searching for Playwright before closing. The view then returns to Rider where tests results are shown showing that the test had passed.
The finished Playwright test running in JetBrains Rider.

I’m a recent convert to Playwright. Having worked mainly on Node.js projects before joining Hippo Digital, I’ve been spoilt by being able to use Cypress for end-to-end testing. But, as I ventured into the world of .NET, the only serious choice seemed to have been Selenium (which is showing its age, in my opinion).

Enter Playwright! With a ton of locators and features out-the-box such as trace viewers, multiple contexts, and auto-waiting, it is a very powerful framework. And after having a play with it in my own time, I saw an opportunity to implement it as a replacement for Selenium on a complex .NET project that I was working on at Hippo.

However, some of the requirements for this project meant it wasn’t going to be a simple setup:

  1. I wanted the whole project team to collaborate on the test cases, so the test scenarios needed to be written as Gherkin scripts using SpecFlow.
  2. Many of the scenarios that I wanted to test included the same pages and components. So I needed to implement Page Object Models (POMs) to keep things maintainable.
  3. There was also a need for multiple contexts, trace viewers and SpecFlow files to a LivingDoc. But I’ll save these topics for future blogs.

I set about reading through the framework docs and searching the web for articles and examples that used Playwright, SpecFlow, and POMs together. After a few weeks, I hadn’t found much at all. So, instead, I went about figuring it out myself along with the help of some brilliant colleagues within the Engineering and Data community at Hippo Digital.

The following step-by-step guide is a basic summary of how I ended up implementing it (to save you the head-scratching). I’m quite new to C#/.NET, and parts of this seem really obvious to me now. But, it was all a learning curve. Enjoy!

Pre-requisites

1. Install the SpecFlow IDE plugin (recommended)

This plugin comes highly recommended as it provides you with a project template for SpecFlow and tools for working with Gherkin feature files.

2. Create the project

If you installed the SpecFlow plugin for your IDE, create a new project using the SpecFlow project template.

A screenshot of the new project modal in Rider with the ‘SpecFlow project’ option selected and a project name entered.

Alternatively, create an empty project with the following directories:

NewProject/

├──Drivers/
├──Features/
├──Hooks/
└──Steps/

I’ll outline what these are used for later on.

3. Install Playwright

Install the NuGet package for Playwright into your newly created project.

4. Install SpecFlow.NUnit

Install the NuGet package for SpecFlow.NUnit.

Setting up your hooks

What are hooks?

The hooks file(s) are used by SpecFlow to perform additional automation logic at specific points. This includes before/after the test run or scenario.

Why do we need them with Playwright?

In a simple Playwright setup, without SpecFlow and POM, you could put all of the setup in a task that we call at the start of each test file. However, SpecFlow’s test cases are driven from the Gherkin-format feature files. So it’s the simplest option to put the setup in the hooks file.

Playwright setup in hooks

The following code contains the Playwright setup for 1 context. This goes in the hooks.cs file within /hooks. Read through the comments for details of each part.

If you only need one browser context for all of your testing, you could configure this without declaring the IPage User property. However, setting and calling this gives us more control over the browser context (to be continued…).

Write your first feature file

The Gherkin-format ‘feature’ files are where you put your test scenarios together. Any test code that you write will be bound to the lines within these files.

In this example, we’re going to write a test that goes to the Playwright website by searching for ‘Playwright’ on DuckDuckGo.

Create a new .feature file in the /Features directory. Then add the following Gherkin content:

If you’re using the SpecFlow plugin, you’ll notice that when you add this code, the lines under the scenario are underlined in a warning (as shown below). This is because there are no step definitions currently bound to these lines.

A screenshot of the SearchForPlaywright feature file before any of the step definitions have been created for any of the lines. As a result all of the lines are underlined in yellow and an error message is shown which reads ‘Cannot find matching step definition’

Writing Page Object Models (POMs)

Before we add the step definition code, you’re going to need to write our Page Object Models. This will provide you with the actions and assertions that you’ll then use in the step definition.

For this example, you’re going to need two Page Objects:

  1. The DuckDuckGo homepage
  2. The DuckDuckGo search results

Writing a POM for the DuckDuckGo homepage

Create a new directory for /Pages and add a .cs file to it called ‘DuckDuckGoHomePage’.

I prefer to follow a pattern with the page object models, splitting them into 3 parts:

  • Constructors
  • Selectors
  • Actions and assertions

Constructors:

The constructor sets up a reference to the IPage User parameter that’s declared in the Hooks file.

private readonly IPage _user;

public DuckDuckGoHomePage(Hooks.Hooks hooks)
{
_user = hooks.User;
}

Selectors:

This is where all of the page objects (DOM elements) are declared that you want to use in your actions and assertions. Don’t be tempted to write selectors for everything shown on the page. If you’re not going to use an object, don’t waste your time writing it in.

private ILocator SearchInput => _user.Locator("input[id='search_form_input_homepage']");
private ILocator SearchButton => _user.Locator("input[id='search_button_homepage']");

Actions and Assertions:

This part does exactly what it says on the tin! Anything that you actually want to do with your selectors should be written here as Tasks or Voids etc.

In this case, you want an assertion that checks that the test is on the correct page and that all the right objects are showing. You also need an action to type in a search term and press ‘enter’.

public async Task AssertPageContent()
{
//Assert that the correct URL has been reached
_user.Url.Should().Be("https://duckduckgo.com/?");

//Assert that the search input is visible
var searchInputVisibility = await SearchInput.IsVisibleAsync();
searchInputVisibility.Should().BeTrue();

// //Assert that the search button is visible
var searchBtnVisibility = await SearchButton.IsVisibleAsync();
searchBtnVisibility.Should().BeTrue();
}

public async Task SearchAndEnter(string searchTerm)
{
//Type the search term into the search input
await SearchInput.TypeAsync(searchTerm);

//Assert that the search input has the text entered
var searchInputInnerText = await SearchInput.InputValueAsync();
searchInputInnerText.Should().Be(searchTerm);

//Click the search button to submit the search
await SearchButton.ClickAsync();
}

Completed DuckDuckGo homepage POM file

Once you’ve added these, your DuckDuckGoHomePage.cs file should look like this:

Adding the search step definition

At this point, you’ve got a page object which covers the home screen, including doing a search and asserting the results. You’ve also written the feature file. So, you now need to write some step definitions.

The step definitions act as the bridge between the feature file and your actual test code.

Setting up a step definition file

Within the /Steps folder, create a new class called ‘SearchSteps’. You’re going to put any steps that relate to going to a new page in this file.

Steps files can be organised in any way you want, containing all or particular step definitions. SpecFlow will read any steps definition from any steps file as long as the class has the [Binding] attribute.

namespace PlaywrightSpecFlowPOM.Steps;

[Binding]
public class SearchSteps
{
}

In order to use the POM, there are a few more things you also need to add a constructor that uses the hooks and DuckDuckGoHomePage.cs POM class from earlier.

using Microsoft.Playwright;
using PlaywrightSpecFlowPOM.Pages;

namespace PlaywrightSpecFlowPOM.Steps;

[Binding]
public class SearchSteps
{
private readonly IPage _user;
private readonly DuckDuckGoHomePage _duckDuckGoHomePage;

public SearchSteps(Hooks.Hooks hooks, DuckDuckGoHomePage duckDuckGoHomePage)
{
_user = hooks.User;
_duckDuckGoHomePage = duckDuckGoHomePage;
}
}

Notice how I’ve also included the home screen POM in my constructor here, ready for the step definitions we’re going to add.

Create a new step definition from the feature file

Now we move over to the feature file. If you’re using the SpecFlow plugin for Rider or VS code, you’ll have an error action list option for each of the undefined steps within the file.

A screenshot of the SearchForPlaywright feature file showing the SpecFlow helper tool modal for creating new step definitions

Click on the error helper, and the ‘create step’ option should be shown. A list should then appear that contains the SearchSteps file you just created.

That new step definition will now appear in the SearchSteps.cs file, and the respective line in the feature file is no longer in error.

[Given(@"the user is on the DuckDuckGo homepage")]
public void GivenTheUserIsOnTheDuckDuckGoHomepage()
{
ScenarioContext.StepIsPending();
}

SpecFlow generates the step definition as a void with a ‘step pending’ function within it. However, for Playwright tests, you’ll usually end up changing these to async Tasks.

Now add some actual code to the step definition that you just created:

  [Given(@"the user is on the DuckDuckGo homepage")]
public async Task GivenTheUserIsOnTheDuckDuckGoHomepage()
{
//Go to the DuckDuckGo homepage
await _user.GotoAsync("https://duckduckgo.com/");

//Assert the page
await _duckDuckGoHomePage.AssertPageContent();
}

This step definition is now complete.

Adding the search step definition to the same file

Following the same process as above, add a step definition for the next feature file line to the SearchSteps.cs file.

[When(@"the user searches for '(.*)'")]
public async Task WhenTheUserSearchesFor(string searchTerm)
{
//Type the search term and press enter
await _duckDuckGoHomePage.SearchAndEnter(searchTerm);
}

As you can see, this step definition has been parameterised with the (.*) attribute. This will allow you to change this parameter (if you want/need to) within the feature file without having to change any code.

Completed SearchSteps.cs file

After adding these step definitions, your file should look like this:

Adding a POM for the search results page

At this point, you’re done with the DuckDuckGo home page and now need to focus on the search results page. This requires another POM.

You should set this up in the same way as the DuckDuckGoHomePage POM but with the name ‘SearchResultsPage’ and with the following code:

Constructors

Add a constructor with the hooks class like in the first Page Object Model from earlier on.

Selectors

The following selectors give us what we need to be able to assert both the search input and the page results shown.

private int _resultIndex; //-> this is being set in the action/assertions below

private ILocator SearchInput => _user.Locator("input[id='search_form_input']");
private ILocator SearchResults => _user.Locator("div[id='links']");

//Notice how the selector below uses the 'SearchResults' locator instead of the IPage to locate the element
//The 'nth' locator is used to select an element at a specific index when there are multiple elements found
private ILocator ResultArticle => SearchResults.Locator("article").Nth(_resultIndex);

//We're using the single search result that we've located as 'ResultArticle' to locate the next 2 selectors
private ILocator ResultHeading => ResultArticle.Locator("h2");
private ILocator ResultLink => ResultArticle.Locator("a[data-testid='result-title-a']");

Actions and assertions

You can then put in some assertions that will check the page content to ensure the test is on the correct page. And then also, check that the first result has the text and link that we expect it to have.

public async Task AssertPageContent(string searchTerm)
{
//Assert the page url
await _user.WaitForURLAsync($"https://duckduckgo.com/?q={searchTerm}*");

//Assert the search input has the search term
var searchInputInnerText = await SearchInput.InputValueAsync();
searchInputInnerText.Should().Be(searchTerm);
}

public async Task AssertSearchResultAtIndex(string searchTerm, int resultIndex, string expectedResultLink)
{

_resultIndex = resultIndex;

//Assert the first result text
var firstResultInnerText = await ResultHeading.InnerTextAsync();
firstResultInnerText.Should().Contain(searchTerm);

//Assert the first result link
var firstResultLink = await ResultLink.GetAttributeAsync("href");
firstResultLink.Should().Be(expectedResultLink);
}

Adding the search result steps file

Following the same process as earlier on, create a steps file for the search results. The constructor for this will need to include the SearchResultsPage class.

private readonly SearchResultsPage _searchResultsPage;

public SearchResultSteps(SearchResultsPage searchResultsPage)
{
_searchResultsPage = searchResultsPage;
}

Add the steps using the helper on the feature file line error. As with the others, this step needs to be parameterised and made an async Task instead of a void.

[Then(@"the search results show '(.*)' as the first result with link '(.*)'")]
public async Task ThenTheSearchResultsShowAsTheFirstResultWithLink(string expectedResult, string expectedLink)
{
ScenarioContext.StepIsPending();
}

We then just need to call the tasks in the SearchResultsPage POM with the expectedResult and expectedLink strings. So it becomes:

[Then(@"the search results show '(.*)' as the first result with link '(.*)'")]
public async Task ThenTheSearchResultsShowAsTheFirstResult(string expectedResult, string expectedLink)
{
//Assert the page content
await _searchResultsPage.AssertPageContent(expectedResult);

//Assert the first search result (hence the index of 0)
await _searchResultsPage.AssertSearchResultAtIndex(expectedResult, 0, expectedLink);
}

And with that, we have our completed test!

Running the test

Playwright is really simple to run, either by using the test controls in your IDE or by running dotnet test in terminal at your project root.

Taking a further look

I hope this basic tutorial has been useful to you.

If you’d like to have a further look at the example from this post, the full solution is available on GitHub:

--

--