Demonstrates a simple yet powerful approach to Behaviour-Driven Development and Functional Testing in .NET on a Blazor app.
The stack is: .NET | NUnit | Playwright for .NET | TickSpec | FsUnit
I have found this stack enables me to really quickly add functional tests to a new web app, using this process:
- Copy entire Tests.Functional directory into an app
- Change the local.runsettings to match default project port
- Add data-test-id's to code under test
- Change the .feature file to match the app
PS> git clone https://github.com/jcoliz/BlazorFunctionalTestStack.git
PS> cd BlazorFunctionalTestStack
If you don't already have the .NET 6.0 SDK installed, be sure to get a copy first from the Download .NET page.
PS> dotnet build
If this is your first time running the version of PlayWright used by the tests, you'll need to install the browsers.
PS> pwsh .\Tests.Functional\bin\Debug\net6.0\playwright.ps1 install
This script requires PowerShell 7. If you are running an old version, this is a great time to upgrade! Otherwise, you could open another window and run it there.
PS> .\startbg.ps1
Id Name PSJobTypeName State HasMoreData Location Command
-- ---- ------------- ----- ----------- -------- -------
27 uitestsbg BackgroundJob Running True localhost dotnet run
PS> dotnet test
Test run for .\Tests.Functional\bin\Debug\net6.0\Tests.Functional.dll (.NETCoreApp,Version=v6.0)
Microsoft (R) Test Execution Command Line Tool Version 17.1.0
Copyright (c) Microsoft Corporation. All rights reserved.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 7, Skipped: 0, Total: 7, Duration: 13 s - Tests.Functional.dll (net6.0)
Best to do it now before you forget!
PS> .\stopbg.ps1
PS> start .\Tests.Functional\bin\Debug\net6.0\Screenshot\
The tests are written in Gherkin. You can find the full set in the Porfolio.feature file. Gherkin is a great way to write clear, expressive tests that humans can make sense of.
Bonus is that you can write the Gherkin before writing any new code, to follow Behaviour Driven Development principles.
Feature: Site is alive and healthy
Scenario: Root loads OK
When user launches site
Then page loaded ok
And save a screenshot named 00_Root
Scenario Outline: Page navigates correctly from root
When user navigates to <Page> page via NavMenu
Then page title is <Title>
And element h1 is <Heading>
Then save a screenshot named <Id>_<Page>
Examples:
| Id | Page | Title | Heading |
| 10 | Home | Index | Hello, world! |
| 20 | Counter | Counter | Counter |
| 30 | Fetch | Weather forecast | Weather forecast |
Scenario: Counter increments when clicking button
Given user navigated to Counter page via NavMenu
When clicking Increment 5 times
Then currentCount is 5
Then, each step is backed by a few lines of Playwright code. You may notice that the tests are in F#. This is because TickSpec uses the language. Not to fear! F# is pretty easy, and generally more concise than the C# alternatives.
let [<Given>] ``user launched site`` (page: IPage) (uri: Uri) =
page.GotoAsync(uri.ToString()) |> Async.AwaitTask |> Async.RunSynchronously
let [<Then>] ``page loaded ok`` (response: IResponse) =
response.Ok
|> should be True
let [<Then>] ``(\S*) is (.*)`` (element:string) (expected:string) (page: IPage) =
page.TextContentAsync($"data-test-id={element}")
|> Async.AwaitTask
|> Async.RunSynchronously
|> should equal expected
By convention, I prefer to define explicit contracts for elements under test. This ensures that later if the text is changed, or the composition of the page is changed, it's highly likely that the tests will still pass.
Thus, the steps defined here use data-test-id by default.
<p role="status">Current count: <span data-test-id="currentCount">@currentCount</span></p>
<button data-test-id="Increment" class="btn btn-primary" @onclick="IncrementCount">Click me</button>
The first choice is to write the tests in the same framework used to write the code. Personally, I prefer .NET for everything, so it's my default starting point.
I actually prefer MSTest for its simplicity. However, MSTest doesn't work well in this case, so I needed to step up to NUnit. The problem is that it won't surface separate scenarios as separate tests. See MSTestWiring.fs and testfx-docs #52.
The alternative is Selenium using Webdriver. These have a reputation for producing somewhat unstable tests. Playwright is build on DevTools, which is newer. I've found my Playwright tests to be perfectly stable, once I got the timeouts correct for the environment I'm on. Overall, I'm super happy with the ease of use and stability of Playwright.
Use of TickSpec is probably the most unorthodox choice. SpecFlow is definitely the common choice. My view is that TickSpec is more lightweight and closer to the metal. SpecFlow tends to abstract away the details, with IDE extensions, and extra UI. I don't need extra UI. Or more abstractions.
TickSpec also brings the use of F#. For some, a whole new language may be a bit much just to adopt a test framework, and I understand that. Still, for me, I think F# is pretty cool, and enjoy learning it a bit more.
Adopting FsUnit allows for having a consistent coding style to the F#-defined steps. This is really an optional piece of the stack. Still, I find it helps for overall readability and consistency of the steps.
The main work of collecting tests is done by FeatureFixture.cs. This is taken from the TickSpec NUnit Examples, with a few changes for PlayWright.
- Inherit the underlying
PageTest
class, provided byMicrosoft.Playwright.NUnit
. - Add a
ServiceCollection
- Add the
Page
andUri
into theServiceCollection
, for tests to access
Here we inherit the base class, and define the ServiceCollection
:
/// Class containing all BDD tests in current assembly as NUnit unit tests
[<TestFixture>]
type FeatureFixture () =
+ inherit PageTest()
+
+ static let Services : ServiceCollection =
+ new ServiceCollection();
+
/// Test method for all BDD tests in current assembly as NUnit unit tests
[<Test>]
Here we add the the Page
and Uri
into the ServiceCollection
, for tests to access.
Steps can access these objects via dependency injection, by declaring a parameter of
the given type.
@@ -16,10 +24,12 @@ type FeatureFixture () =
if scenario.Tags |> Seq.exists ((=) "ignore") then
raise (new IgnoreException("Ignored: " + scenario.ToString()))
try
+ Services.AddSingleton<Uri>(new Uri(TestContext.Parameters["uri"])) |> ignore
+ Services.AddSingleton<IPage>(base.Page) |> ignore
scenario.Action.Invoke()
with
| :? TargetInvocationException as ex -> ExceptionDispatchInfo.Capture(ex.InnerException).Throw()
Here we give the steps access to the ServiceCollection
.
@@ -38,6 +48,7 @@ type FeatureFixture () =
let assembly = Assembly.GetExecutingAssembly()
let definitions = new StepDefinitions(assembly.GetTypes())
+ definitions.ServiceProviderFactory <- fun () -> Services.BuildServiceProvider()
assembly.GetManifestResourceNames()