Events-MVC (example with htmx)
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.59.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Events.MVC\Events.MVC.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
1
Events-MVC/Tests/Events.Tests.UITests/GlobalUsings.cs
Normal file
1
Events-MVC/Tests/Events.Tests.UITests/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,77 @@
|
||||
using Events.Tests.UITests.Infrastructure;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace Events.Tests.UITests;
|
||||
|
||||
public class HomeAndSportsPageTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HomePageShouldDisplayEnglishDescription()
|
||||
{
|
||||
await using var harness = await UiTestHarness.CreateAsync();
|
||||
|
||||
await harness.Page.GotoAsync($"{harness.RootUrl}/");
|
||||
|
||||
await Assertions.Expect(harness.Page.GetByRole(AriaRole.Heading, new() { Name = "Events" })).ToBeVisibleAsync();
|
||||
await Assertions.Expect(harness.Page.GetByText("This sample demonstrates an ASP.NET Core MVC application")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(harness.Page.GetByRole(AriaRole.Link, new() { Name = "Sports" })).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SportsPageShouldDisplayExistingSportsAndAllowOpeningCreatePanel()
|
||||
{
|
||||
await using var harness = await UiTestHarness.CreateAsync();
|
||||
var expectedSports = new[] { "Running", "Chess", "Swimming" };
|
||||
|
||||
await harness.Page.GotoAsync($"{harness.RootUrl}/Sports");
|
||||
|
||||
await Assertions.Expect(harness.Page.GetByRole(AriaRole.Heading, new() { Name = "Sports list" })).ToBeVisibleAsync();
|
||||
await Assertions.Expect(harness.Page.Locator("#sports-table-body tr").First).ToBeVisibleAsync();
|
||||
await Assertions.Expect(harness.Page.GetByPlaceholder("Search by sport name")).ToBeVisibleAsync();
|
||||
foreach (var expectedSport in expectedSports)
|
||||
{
|
||||
await Assertions.Expect(harness.Page.Locator("#sports-list")).ToContainTextAsync(expectedSport);
|
||||
}
|
||||
|
||||
await harness.Page.GetByRole(AriaRole.Button, new() { Name = "New sport" }).ClickAsync();
|
||||
|
||||
await Assertions.Expect(harness.Page.GetByRole(AriaRole.Heading, new() { Name = "Add a new sport" })).ToBeVisibleAsync();
|
||||
await Assertions.Expect(
|
||||
harness.Page.Locator("#create-sport-form").GetByLabel("Name", new() { Exact = true }))
|
||||
.ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SportsPageShouldCreateSportAndShowSuccessToast()
|
||||
{
|
||||
await using var harness = await UiTestHarness.CreateAsync();
|
||||
var sportName = $"UI Test Sport {Guid.NewGuid():N}";
|
||||
|
||||
await harness.Page.GotoAsync($"{harness.RootUrl}/Sports");
|
||||
await harness.Page.GetByRole(AriaRole.Button, new() { Name = "New sport" }).ClickAsync();
|
||||
|
||||
var createForm = harness.Page.Locator("#create-sport-form");
|
||||
await createForm.GetByLabel("Name", new() { Exact = true }).FillAsync(sportName);
|
||||
await createForm.GetByRole(AriaRole.Button, new() { Name = "Add sport" }).ClickAsync();
|
||||
|
||||
await Assertions.Expect(harness.Page.Locator("#app-toast")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(harness.Page.Locator("#app-toast-title")).ToHaveTextAsync("Success");
|
||||
await Assertions.Expect(harness.Page.Locator("#app-toast-body")).ToContainTextAsync($"Sport '{sportName}' was added successfully.");
|
||||
|
||||
await harness.Page.GetByPlaceholder("Search by sport name").FillAsync(sportName);
|
||||
await harness.Page.GetByRole(AriaRole.Button, new() { Name = "Filter" }).ClickAsync();
|
||||
|
||||
await Assertions.Expect(harness.Page.Locator("#sports-list")).ToContainTextAsync(sportName);
|
||||
|
||||
var sportRow = harness.Page.Locator("#sports-table-body tr").Filter(new() { HasText = sportName });
|
||||
await Assertions.Expect(sportRow).ToHaveCountAsync(1);
|
||||
|
||||
harness.Page.Dialog += async (_, dialog) => await dialog.AcceptAsync();
|
||||
await sportRow.GetByRole(AriaRole.Button, new() { Name = "Delete" }).ClickAsync();
|
||||
|
||||
await Assertions.Expect(harness.Page.Locator("#app-toast")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(harness.Page.Locator("#app-toast-title")).ToHaveTextAsync("Success");
|
||||
await Assertions.Expect(harness.Page.Locator("#app-toast-body")).ToContainTextAsync($"Sport '{sportName}' was deleted successfully.");
|
||||
await Assertions.Expect(harness.Page.Locator("#sports-list")).Not.ToContainTextAsync(sportName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace Events.Tests.UITests.Infrastructure;
|
||||
|
||||
internal sealed class UiTestHarness : IAsyncDisposable
|
||||
{
|
||||
private readonly IPlaywright playwright;
|
||||
private readonly Process appProcess;
|
||||
private readonly StringBuilder processOutput;
|
||||
|
||||
private UiTestHarness(
|
||||
IPlaywright playwright,
|
||||
IBrowser browser,
|
||||
IBrowserContext browserContext,
|
||||
IPage page,
|
||||
Process appProcess,
|
||||
StringBuilder processOutput,
|
||||
string rootUrl)
|
||||
{
|
||||
this.playwright = playwright;
|
||||
this.appProcess = appProcess;
|
||||
this.processOutput = processOutput;
|
||||
Browser = browser;
|
||||
BrowserContext = browserContext;
|
||||
Page = page;
|
||||
RootUrl = rootUrl;
|
||||
}
|
||||
|
||||
public IBrowser Browser { get; }
|
||||
|
||||
public IBrowserContext BrowserContext { get; }
|
||||
|
||||
public IPage Page { get; }
|
||||
|
||||
public string RootUrl { get; }
|
||||
|
||||
public static async Task<UiTestHarness> CreateAsync()
|
||||
{
|
||||
var port = FindFreePort();
|
||||
var rootUrl = $"http://127.0.0.1:{port}";
|
||||
var mvcProjectPath = Path.GetFullPath(Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"..", "..", "..", "..", "..",
|
||||
"Events.MVC", "Events.MVC.csproj"));
|
||||
|
||||
var processOutput = new StringBuilder();
|
||||
var startInfo = new ProcessStartInfo("dotnet", $"run --project \"{mvcProjectPath}\" --urls {rootUrl}")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "UITest";
|
||||
#if POSTGRES
|
||||
startInfo.Environment["ConnectionStrings__EventsPostgres"] = ResolveUiTestConnectionString();
|
||||
#else
|
||||
startInfo.Environment["ConnectionStrings__EventsMssql"] = ResolveUiTestConnectionString();
|
||||
#endif
|
||||
|
||||
var appProcess = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("Failed to start the MVC app process for UI tests.");
|
||||
|
||||
appProcess.OutputDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is not null)
|
||||
{
|
||||
processOutput.AppendLine(args.Data);
|
||||
}
|
||||
};
|
||||
appProcess.ErrorDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is not null)
|
||||
{
|
||||
processOutput.AppendLine(args.Data);
|
||||
}
|
||||
};
|
||||
appProcess.BeginOutputReadLine();
|
||||
appProcess.BeginErrorReadLine();
|
||||
|
||||
await WaitForServerAsync(rootUrl, appProcess, processOutput);
|
||||
|
||||
IPlaywright playwright;
|
||||
try
|
||||
{
|
||||
playwright = await Playwright.CreateAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await StopProcessAsync(appProcess);
|
||||
throw;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
{
|
||||
Headless = true
|
||||
});
|
||||
var browserContext = await browser.NewContextAsync(new BrowserNewContextOptions
|
||||
{
|
||||
BaseURL = rootUrl
|
||||
});
|
||||
var page = await browserContext.NewPageAsync();
|
||||
|
||||
return new UiTestHarness(playwright, browser, browserContext, page, appProcess, processOutput, rootUrl);
|
||||
}
|
||||
catch (PlaywrightException)
|
||||
{
|
||||
await StopProcessAsync(appProcess);
|
||||
playwright.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
"Playwright browser is not installed. Run 'dotnet tool install --global Microsoft.Playwright.CLI' and then 'playwright install'.");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await BrowserContext.DisposeAsync();
|
||||
await Browser.DisposeAsync();
|
||||
playwright.Dispose();
|
||||
await StopProcessAsync(appProcess);
|
||||
}
|
||||
|
||||
private static int FindFreePort()
|
||||
{
|
||||
using var listener = new TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
|
||||
private static async Task WaitForServerAsync(string rootUrl, Process appProcess, StringBuilder processOutput)
|
||||
{
|
||||
using var httpClient = new HttpClient();
|
||||
var timeoutAt = DateTime.UtcNow.AddSeconds(30);
|
||||
|
||||
while (DateTime.UtcNow < timeoutAt)
|
||||
{
|
||||
if (appProcess.HasExited)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"MVC app process exited before the UI test server became ready.{Environment.NewLine}{processOutput}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await httpClient.GetAsync(rootUrl);
|
||||
if ((int)response.StatusCode < 500)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
await Task.Delay(250);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Timed out while waiting for the UI test server at {rootUrl}.{Environment.NewLine}{processOutput}");
|
||||
}
|
||||
|
||||
private static string ResolveUiTestConnectionString()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddUserSecrets<Program>(optional: true)
|
||||
.Build();
|
||||
|
||||
var connectionString = configuration.GetConnectionString("EventDB-Test");
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The EventDB-Test connection string must be available so UI tests can connect to the selected provider's test database.");
|
||||
}
|
||||
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
private static async Task StopProcessAsync(Process appProcess)
|
||||
{
|
||||
if (appProcess.HasExited)
|
||||
{
|
||||
appProcess.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
appProcess.Kill(entireProcessTree: true);
|
||||
await appProcess.WaitForExitAsync();
|
||||
appProcess.Dispose();
|
||||
}
|
||||
}
|
||||
43
Events-MVC/Tests/Events.Tests.UITests/README.md
Normal file
43
Events-MVC/Tests/Events.Tests.UITests/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Events.Tests.UITests
|
||||
|
||||
This project contains Playwright-based UI tests for `Events-MVC`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET SDK 10.0
|
||||
- Playwright CLI
|
||||
- Playwright browser binaries
|
||||
|
||||
## Playwright Installation
|
||||
|
||||
Install the Playwright CLI once:
|
||||
|
||||
```powershell
|
||||
dotnet tool install --global Microsoft.Playwright.CLI
|
||||
```
|
||||
|
||||
Install browser binaries:
|
||||
|
||||
```powershell
|
||||
playwright install
|
||||
```
|
||||
|
||||
## Running the UI Tests
|
||||
|
||||
Run the full UI test project:
|
||||
|
||||
```powershell
|
||||
dotnet test Events-MVC\Tests\Events.Tests.UITests\Events.Tests.UITests.csproj
|
||||
```
|
||||
|
||||
Run a single test:
|
||||
|
||||
```powershell
|
||||
dotnet test Events-MVC\Tests\Events.Tests.UITests\Events.Tests.UITests.csproj --filter HomeAndSportsPageTests.HomePageShouldDisplayEnglishDescription
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The UI test harness starts the MVC application automatically
|
||||
- UI tests connect the MVC application to the selected provider's test database from `ConnectionStrings:EventDB-Test`
|
||||
- The browser is currently configured in headless mode
|
||||
Reference in New Issue
Block a user