SpecFlow — Cucumber in C#

Daniel Delimata
10 min readFeb 6, 2023

--

Welcome to my course of Cucumber framework for beginners. In this post we will go through setting up the project in C#. The project will be based on Selenium and the results will be reported by Allure reporting framework.

If you need some more general information about Cucumber and Gherkin please read the introduction.

Visual Studio Code with the project described in this article

GENERAL INFORMATION ABOUT C# LANGUAGE AND ITS ECOSYSTEM OF TOOLS

IDE

C# is a programming language developed by Microsoft Corp. since 2000. It is based on .NET which is a software framework.

There are two IDE which I recommend for editing C# code: Microsoft Visual Studio and Visual Studio Code (or shortly “vscode”) which is multipurpose code editor with variety of plugins for various languages. Here let us focus on the second one because it is more popular and more universal.

Most important shortcuts:

autocompletion:

  • On Mac/Linux/Windows: Ctrl + Space (⌃␣)

quick-fixing:

  • On Windows/Linux: Ctrl + .
  • On Mac: Command + . (⌘.)

auto format code:

  • On Windows: Shift + Alt + F
  • On Mac: Shift + Option + F (⇧⌥F)
  • On Linux: Ctrl + Shift + I

I suggest installing several extensions. The following set of commands will install them quickly.

code --install-extension CucumberOpen.cucumber-official
code --install-extension ms-dotnettools.csharp
code --install-extension aliasadidev/vscode-npm-gui

WHAT DO WE WANT TO TEST?

Our test page is pretty simple. It contains several typical elements of UI forms:

  • text field (input),
  • dropdown,
  • checkbox,
  • table with dynamic content,
  • text element with dynamic content.

The URL address of this page is: https://danieldelimata.github.io/sample-page/.

SETTING UP THE SKELETON OF THE PROJECT

In C# world there are three main tools used for running tests:

  • NUnit
  • xunit
  • MSTest

A survey done by JetBrains shows that popularity of these unit-testing frameworks is somehow similar and other tools (like MbUnit csUnit) are practically not present in the market.

NUnit was the most often chosen unit testing library by Selenium users in Garcia’s survey in 2020. 2/3 of respondents using C# in Selenium users survey pointed out NUnit as preferred option, so let us concentrate on NUnit.

The project can be setup from scratch in Microsoft Visual Studio, but the simpler way is using command line interface with the following commands. You can just paste whole bunch of them all together. You can of course change the name of solution sample-csharp-specflow and the name of project SampleCsharpSpecflow into your own names.

First type this command. It will install template of the project.

dotnet new install SpecFlow.Templates.DotNet

To create NUnit based project execute:

dotnet new sln -o sample-csharp-specflow
cd sample-csharp-specflow
dotnet new specflowproject --unittestprovider nunit -o SampleCsharpSpecflow
dotnet sln add ./SampleCsharpSpecflow/SampleCsharpSpecflow.csproj
dotnet add SampleCsharpSpecflow package NUnit
dotnet add SampleCsharpSpecflow package NUnit3TestAdapter
dotnet add SampleCsharpSpecflow package Selenium.WebDriver
dotnet add SampleCsharpSpecflow package Selenium.Support
dotnet add SampleCsharpSpecflow package Selenium.WebDriver.ChromeDriver
dotnet add SampleCsharpSpecflow package Microsoft.NET.Test.Sdk
dotnet add SampleCsharpSpecflow package NUnit3TestAdapter
dotnet add SampleCsharpSpecflow package NUnit.Allure
dotnet add SampleCsharpSpecflow package Noksa.Allure.StepInjector
dotnet add SampleCsharpSpecflow package DotNetSeleniumExtras.PageObjects.Core
dotnet add SampleCsharpSpecflow package SpecFlow.Allure

After a while all required packages are installed. Now you can open the solution in your IDE.

Unfortunately when I write this post the template is pretty old and not updated, so it starts our project with target platform “.NET Core 3.1” We want to use currently supported platform “.NET 7.0”.

To change it we can open project properties in Visual Studio and change the target framework, or just edit the file sample-csharp-specflow/SampleCsharpSpecflow/SampleCsharpSpecflow.csproj

The line

<targetframework>netcoreapp3.1</targetframework>

we have to change into

<targetframework>net7.0</targetframework>

At the bottom of the same file, just above </Project> add the following block

  <ItemGroup>
<Folder Include="Features\" />
<Folder Include="Pages\" />
</ItemGroup>
<ItemGroup>
<None Update="allureConfig.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="specflow.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

Next add allureConfig.json and specflow.json into SampleCSharpSpecflow.

specflow.json:

{
"stepAssemblies": [
{
"assembly": "Allure.SpecFlowPlugin"
}
]
}

allure.json:

{
"allure": {
"directory": "allure-results"
},
"specflow": {
"stepArguments": {
"convertToParameters": "true",
"paramNameRegex": "",
"paramValueRegex": ""
},
"grouping": {
"suites": {
"parentSuite": "^parentSuite:?(.+)",
"suite": "^suite:?(.+)",
"subSuite": "^subSuite:?(.+)"
},
"behaviors": {
"epic": "^epic:?(.+)",
"story": "^story:?(.+)"
},
"packages": {
"package": "^package:?(.+)",
"testClass": "^class:?(.+)",
"testMethod": "^method:?(.+)"
}
},
"labels": {
"owner": "^owner:?(.+)",
"severity": "^(normal|blocker|critical|minor|trivial)"
},
"links": {
"issue": "^issue:(\\d+)",
"tms": "^tms:(\\d+)"
}
}
}

If everything went correctly, we should have the following structure of directories and files.

sample-csharp-specflow
|-- SampleCsharpSpecflow
|-- bin
|-- Drivers
|-- Driver.cs
|-- Features
|-- Calculator.feature
|-- Hooks
|-- Hook.cs
|-- obj
|-- Steps
|-- CalculatorStepDefinitions.cs
.gitignore
SampleCsharpSpecflow.csproj
allure.json
specflow.json
sample-csharp-specflow.sln

Explanation:

  • The root folder of the solution (here sample-csharp-specflow) contains all projects (here only one project)
  • The root folder of the project (here SampleCsharpSpecflow) contains all the files related to the project.
  • The bin folder is intended for dotnet to place result of compilation
  • The Features folder contains all the feature files written in Gherkin syntax.
  • The Hooks folder contains all the code responsible for running our tests
  • The obj folder is intended for dotnet to store files needed during building
  • The Steps folder contains all the step definition files that implement the logic for the steps defined in the feature files.

The last preparation step is updating the packages.

  1. Open your project workspace in VSCode
  2. Open the Command Palette (Win: Ctrl + Shift + P, Mac: ⌘+⇧+P)
  3. Select “> Nuget Package Manager GUI” (we have installed it via CLI)
  4. Click “Load Package Versions”
  5. Click “Update All Packages”

MANUAL CHANGES IN THE CODE

The template contains some simple example for testing “Calculator”. We want to fill the project with our own contents, so let us change the following files:

  • Calculator.feature — let us remove this file. We will place here our own f01.feature later.
  • CalculatorStepDefinitions.cs — we can just rename it to StepDefinitions.cs

To use Page Object Pattern we want to store the code describing pages in the separate directory. Let us create the directory Pages in the project directory and new classes files e.g. CustomersPage.cs and ApplicationPage.cs

The structure should be as follows.

sample-csharp-specflow
|-- SampleCsharpSpecflow
|-- bin
|-- Drivers
|-- Driver.cs
|-- Features
|-- f01.feature
|-- Hooks
|-- Hook.cs
|-- obj
|-- Pages
|-- CustomersPage.cs
|-- ApplicationPage.cs
|-- Steps
|-- StepDefinitions.cs
.gitignore
SampleCsharpSpecflow.csproj
sample-csharp-specflow.sln

Now we want to start implementing our own code. Let us start from feature file f01.feature.

Feature: F01 Searching - Clearing of searching criteria

User story:
* As a user of Customers page
* I want to be able to clear searching criteria
* in order to quickly type new criteria

Acceptance criteria:
* After clearing, search criteria and summary should be as in the very beginning.

Scenario Outline: Clearing of searching criteria
Given the user is on the page
When the user enters the value "<Search>" in the text-input
And the user selects value "<Column>" in the drop-down
And the user sets case sensitivity switch to "<Case>"
And the user clears filters
Then the user should see that search criteria are cleared
And the user should see that the search result summary is as in the very beginning

Examples:
| Search | Column | Case |
| Alice | Name | True |
| alice | Name | False |

At this moment we do not have the glue code yet. We can however start execution just to get some snippets for it. They are produced in logs.

To execute our tests, we launch the command:

dotnet test

Such snippets of methods may look as follows.

        [Given(@"the user is on the page")]
public void GivenTheUserIsOnThePage()
{
_scenarioContext.Pending();
}

Our automation needs running of Selenium. Let us go to the file Hooks.cs and place there the code responsible for Selenium WebDriver. In the same file we place the code responsible for making screenshots when failure occurs.

using System;
using System.IO;
using System.Linq;
using System.Xml;
using Allure.Commons;
using BoDi;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using TechTalk.SpecFlow;

namespace SampleCsharpSpecflow.Hooks
{
[Binding]
public class Hooks
{
private readonly IObjectContainer objectContainer;
private readonly ScenarioContext scenarioContext;
private readonly AllureLifecycle allureLifecycle;

public Hooks(IObjectContainer objectContainer, ScenarioContext scenarioContext)
{
this.objectContainer = objectContainer;
this.scenarioContext = scenarioContext;
allureLifecycle = AllureLifecycle.Instance;
}

[BeforeTestRun]
public static void SetupForAllure()
{
AllureLifecycle.Instance.CleanupResultDirectory();
GenerateEnvironmentXml();
}

#region environment properties
private static void GenerateEnvironmentXml()
{
XmlWriter xmlWriter = XmlWriter.Create("allure-results/environment.xml");
xmlWriter.WriteStartDocument();
xmlWriter.WriteStartElement("environment");

AddUserDefinedParameterToEnvironmentXml(
xmlWriter,
"user defined key",
"user defined value");

foreach (System.Collections.DictionaryEntry entry
in Environment.GetEnvironmentVariables())
{
xmlWriter.WriteStartElement("parameter");
xmlWriter.WriteElementString("key", entry.Key.ToString());
xmlWriter.WriteElementString("value", entry.Value.ToString());
xmlWriter.WriteEndElement();
}

xmlWriter.WriteEndDocument();
xmlWriter.Flush();
xmlWriter.Close();
}

private static void AddUserDefinedParameterToEnvironmentXml(
XmlWriter xmlWriter,
string key,
string value)
{
xmlWriter.WriteStartElement("parameter");
xmlWriter.WriteElementString("key", key);
xmlWriter.WriteElementString("value", value);
xmlWriter.WriteEndElement();
}

#endregion
#region handling webdriver

[BeforeScenario(Order = 0)]
public void BeforeScenario()
{
ChromeOptions options = new();
options.AddArgument("--no-sandbox");
options.AddArgument("--start-maximized");
options.AddArgument("--headless");
var driver = new ChromeDriver(options);
objectContainer.RegisterInstanceAs<IWebDriver>(driver);
}

[AfterScenario]
public void AfterScenario()
{
IWebDriver driver = objectContainer.Resolve<IWebDriver>();
driver.Quit();
objectContainer.Dispose();
}

#endregion
#region screenshots

[AfterStep]
public void AfterStep()
{
if (scenarioContext.TestError != null)
{
SaveScreenshot(
scenarioContext.ScenarioInfo.Title + Guid.NewGuid().ToString(),
scenarioContext.StepContext.StepInfo.Text
);
}
}

public void SaveScreenshot(string title, string step)
{
try
{
string path = $"allure-results//{title}{DateTime.Now:HH}";
Directory.CreateDirectory(path);
string pathToFile = $"{path}//{CleanFileName(DateTime.UtcNow.ToLongTimeString())}.png";
IWebDriver driver = objectContainer.Resolve<IWebDriver>();

Screenshot screenshot = ((ITakesScreenshot)driver).GetScreenshot();
screenshot.SaveAsFile(pathToFile, ScreenshotImageFormat.Png);
allureLifecycle.AddAttachment(pathToFile, step);
}
catch (Exception ex)
{
Console.WriteLine("Taking a screenshot failed "
+ ex.Message
+ ex.StackTrace);
}
}
public static string CleanFileName(string fileName)
{
return Path.GetInvalidFileNameChars().Aggregate(fileName, (current, c)
=> current.Replace(c.ToString(), string.Empty));
}
#endregion
}
}

The next things we want to have, are classes representing our pages. We have only one such a page and one abstract class to have one common place for driver and initializing elements through PageFactory.InitElements().

using System;
using OpenQA.Selenium;
using SeleniumExtras.PageObjects;

namespace SampleCsharpSpecflow.Pages
{
internal class ApplicationPage
{
protected IWebDriver Driver { get; }

public ApplicationPage(IWebDriver driver)
{
Driver = driver ?? throw new ArgumentNullException(nameof(driver));
var retry = new RetryingElementLocator(Driver, TimeSpan.FromSeconds(10));
var decor = new DefaultPageObjectMemberDecorator();
PageFactory.InitElements(retry.SearchContext, this, decor);
}
}
}
using OpenQA.Selenium;
using OpenQA.Selenium.Support.UI;
using SeleniumExtras.PageObjects;

namespace SampleCsharpSpecflow.Pages
{
internal class CustomersPage : ApplicationPage
{
[FindsBy(How = How.Id, Using = "clear-button")]
private readonly IWebElement ClickToClearFiltersButton;

[FindsBy(How = How.Id, Using = "search-input")]
private readonly IWebElement SearchInput;

[FindsBy(How = How.Id, Using = "search-column")]
private readonly IWebElement SearchColumn;

[FindsBy(How = How.Id, Using = "match-case")]
private readonly IWebElement MatchCase;

[FindsBy(How = How.Id, Using = "table-resume")]
private readonly IWebElement Summary;

[FindsBy(How = How.Id, Using = "search-slogan")]
private readonly IWebElement SearchTerm;

[FindsBy(How = How.XPath, Using = "//table")]
private readonly IWebElement SearchResultsTable;

public CustomersPage(IWebDriver driver) : base(driver)
{
RetryingElementLocator retry
= new(driver, TimeSpan.FromSeconds(10));
IPageObjectMemberDecorator decor
= new DefaultPageObjectMemberDecorator();
PageFactory.InitElements(retry.SearchContext, this, decor);
}

/// <summary>
/// Click on Clear Filters Button.
/// </summary>
/// <returns>the CustomersPage class instance</returns>
internal CustomersPage ClickClearFiltersButton()
{
ClickToClearFiltersButton.Click();
return this;
}

/// <summary>
/// Set value to searchInput field
/// </summary>
/// <param name="searchInput">string value which should be typed into
/// the field</param>
/// <returns>the CustomersPage class instance</returns>
internal CustomersPage SetSearchInput(string searchInput)
{
SearchInput.SendKeys(searchInput);
return this;
}

/// <summary>
/// Set value to Search Column Drop Down List field.
/// </summary>
/// <param name="value">String which should match with one of values
/// visible on the dropdown</param>
/// <returns>the CustomersPage class instance</returns>
internal CustomersPage SetSearchColumnDropDownListField(string value)
{
new SelectElement(SearchColumn).SelectByText(value);
return this;
}

/// <summary>
/// Set Match Case Checkbox field to required value.
/// </summary>
/// <param name="value">boolean value of the checkbox status
/// true - checked, false unchecked</param>
/// <returns>the CustomersPage class instance</returns>
internal CustomersPage SetMatchCaseCheckboxField(bool value)
{
if (value != MatchCase.Selected)
{
MatchCase.Click();
}
return this;
}

public string SummaryText => Summary.Text;

public string SearchTermText => SearchTerm.GetAttribute("innerText");

public string SearchInputText => SearchInput.GetAttribute("innerText");

public string SearchResultsTableText => SearchResultsTable.Text;
}
}

Above class stores the code related to UI itself only. It should not contain methods related to tests. Where should they be placed? If you read the introduction (or tried Cucumber with another programming language) then you probably know that such a place should be class responsible for step definitions (so-called glue).

This is the main part of our work. We have to implement test methods for every step. Snippets from logs can be helpful thing here. Methods which we have created in the class CustomersPage.cs makes this activity rather easy especially together with features of IDE.

Pay attention to steps related to Then keyword. They should not contain any action, but they should contain assertions. Assertions are specific for every test library so if you decided to use xunit or MSTest then you have to implement these assertions differently.

Our class can look as follows, but you can experiment here and implement these methods in your own way.

using TechTalk.SpecFlow;
using SampleCsharpSpecflow.Pages;
using BoDi;
using OpenQA.Selenium.Support.UI;
using System;
using NUnit.Framework;
using System.Text.RegularExpressions;

namespace SampleCsharpSpecflow.Steps
{
[Binding]
public class Steps : BaseStep
{
private readonly ScenarioContext _scenarioContext;
private CustomersPage customersPage;
private string searchSummaryAtVeryBeginning;

public Steps(IObjectContainer objectContainer) : base(objectContainer)
{
_scenarioContext = objectContainer.Resolve<ScenarioContext>();
}

[Given(@"the user is on the page")]
public Steps GivenTheUserIsOnThePage()
{
OpenInitialPage();
return this;
}

private void OpenInitialPage()
{
string pageUrl = "https://danieldelimata.github.io/sample-page/";
WebDriver.Navigate().GoToUrl(pageUrl);
WebDriver.Manage().Window.Maximize();
customersPage = new CustomersPage(WebDriver);
searchSummaryAtVeryBeginning = customersPage.SummaryText;
}

[When(@"the user enters the value ""(.*)"" in the text-input")]
public void WhenTheUserEntersTheValueInTheText_Input(string searchInput)
{
customersPage.SearchInput.SendKeys(searchInput);
}

[When(@"the user selects value ""(.*)"" in the drop-down")]
public void WhenTheUserSelectsValueInTheDrop_Down(string value)
{
new SelectElement(customersPage.SearchColumn)
.SelectByText(value);
}

[When(@"the user sets case sensitivity switch to ""(.*)""")]
public void WhenTheUserSetsCaseSensitivitySwitchTo(string isCaseSensitive)
{
Boolean value = bool.Parse(isCaseSensitive);
customersPage.SetMatchCaseCheckboxField(value);
}

[Then(@"the user should see the following result summary ""(.*)""")]
public void ThenTheUserShouldSeeTheFollowingResultSummary(string expectedSummary)
{
Assert.AreEqual(expectedSummary, customersPage.SummaryText);
}

[Then(@"the user should see that the search term is ""(.*)""")]
public void ThenTheUserShouldSeeThatTheSearchTermIs(string expectedTerm)
{
StringAssert.StartsWith(expectedTerm, customersPage.SearchTermText);
}

[When(@"the user clears filters")]
public void WhenTheUserClearsFilters()
{
customersPage.ClickClearFiltersButton();
}

[Then(@"the user should see that search criteria are cleared")]
public void ThenTheUserShouldSeeThatSearchCriteriaAreCleared()
{
Assert.AreEqual("", customersPage.SearchInputText);
}

[Then(@"the user should see that the search result summary is as in the very beginning")]
public void ThenTheUserShouldSeeThatTheSearchResultSummaryIsAsInTheVeryBeginning()
{
Assert.AreEqual(searchSummaryAtVeryBeginning, customersPage.SummaryText);
}

[Then(@"the user should see that the search results are as follows: ""(.*)""")]
public void ThenTheUserShouldSeeThatTheSearchResultsAreAsFollows(string expectedResults)
{
Assert.AreEqual(
expectedResults,
Regex.Replace(customersPage.SearchResultsTableText, "\\s+", " ")
.Trim()
);
}
}
}

Now we can run our tests. Again we execute command:

dotnet test

Our results are stored in the directory SampleCsharpSpecflow/bin/Debug/net7.0/allure-results. To visualise them we can execute:

allure serve SampleCsharpSpecflow/bin/Debug/net7.0/allure-results

The results should open in the default web browser.

Test results

The whole project you can find on GitHub: DanielDelimata/sample-csharp-specflow-nunit

The story was originally created by me, but it may contain parts that were created with AI assistance. My original text has been corrected and partially rephrased by Chat Generative Pre-trained Transformer to improve the language.

--

--