Skip to content

Commit 69735f3

Browse files
feat: Migrate tests from xunit to TUnit (#875)
- Replace xunit + coverlet with TUnit test framework - Update test project files to use TUnit package references - Add Microsoft.Testing.Platform runner config to global.json - Update CI test command for TUnit MTP compatibility - Convert all test classes and assertions to TUnit syntax - Add [NotInParallel] to tests using shared SQLite state - Use IsEquivalentTo for collection comparisons
1 parent 8ddf367 commit 69735f3

19 files changed

+340
-307
lines changed

.editorconfig

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,7 @@ csharp_style_expression_bodied_lambdas = true:silent
156156
csharp_style_expression_bodied_local_functions = false:silent
157157

158158
# CA1848: Use the LoggerMessage delegates
159-
dotnet_diagnostic.CA1848.severity = suggestion
159+
dotnet_diagnostic.CA1848.severity = suggestion
160+
# Test files - allow underscore-separated test method names (CA1707)
161+
[{EssentialCSharp.Web.Tests,EssentialCSharp.Chat.Tests}/**]
162+
dotnet_diagnostic.CA1707.severity = none

.github/workflows/PR-Build-And-Test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ jobs:
3535
run: dotnet build --configuration Release --no-restore /p:AccessToNugetFeed=false
3636

3737
- name: Run .NET Tests
38-
run: dotnet test --no-build --configuration Release --logger trx --results-directory ${{ runner.temp }}
38+
run: dotnet test --no-build --configuration Release --report-trx --coverage --results-directory ${{ runner.temp }}
3939

4040
- name: Convert TRX to VS Playlist
4141
if: failure()

Directory.Packages.props

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
<PackageVersion Include="Azure.Identity" Version="1.17.1" />
2222
<PackageVersion Include="Azure.Monitor.OpenTelemetry.AspNetCore" Version="1.4.0" />
2323
<PackageVersion Include="Microsoft.ApplicationInsights.Profiler.AspNetCore" Version="3.0.1" />
24-
<PackageVersion Include="coverlet.collector" Version="8.0.0" />
24+
<PackageVersion Include="TUnit" Version="1.17.25" />
2525
<PackageVersion Include="EssentialCSharp.Shared.Models" Version="$(ToolingPackagesVersion)" />
2626
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
2727
<PackageVersion Include="IntelliTect.Multitool" Version="1.5.3" />
@@ -36,7 +36,6 @@
3636
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
3737
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.3" />
3838
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.3" />
39-
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
4039
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.72.0" />
4140
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.PgVector" Version="1.70.0-preview" />
4241
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.103" />
@@ -50,7 +49,5 @@
5049
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
5150
<PackageVersion Include="Octokit" Version="14.0.0" />
5251
<PackageVersion Include="DotnetSitemapGenerator" Version="2.0.0" />
53-
<PackageVersion Include="xunit" Version="2.9.3" />
54-
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
5552
</ItemGroup>
5653
</Project>

EssentialCSharp.Chat.Tests/EssentialCSharp.Chat.Tests.csproj

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,17 @@
33
<PropertyGroup>
44
<TargetFramework>net10.0</TargetFramework>
55
<IsPackable>false</IsPackable>
6+
<IsPublishable>false</IsPublishable>
67
</PropertyGroup>
78

89
<ItemGroup>
9-
<PackageReference Include="coverlet.collector" />
10-
<PackageReference Include="Microsoft.NET.Test.Sdk" />
1110
<PackageReference Include="Moq" />
12-
<PackageReference Include="xunit" />
13-
<PackageReference Include="xunit.runner.visualstudio" />
11+
<PackageReference Include="TUnit" />
1412
</ItemGroup>
1513

1614
<ItemGroup>
1715
<ProjectReference Include="..\EssentialCSharp.Chat\EssentialCSharp.Chat.csproj" />
1816
</ItemGroup>
1917

20-
<ItemGroup>
21-
<Using Include="Xunit" />
22-
</ItemGroup>
2318

2419
</Project>

EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs

Lines changed: 35 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
using EssentialCSharp.Chat.Common.Services;
1+
using EssentialCSharp.Chat.Common.Services;
22
using Moq;
33

44
namespace EssentialCSharp.Chat.Tests;
5-
// TODO: Move to editorconfig later, just moving quick
6-
#pragma warning disable CA1707 // Identifiers should not contain underscores
5+
76
public class MarkdownChunkingServiceTests
87
{
98
#region MarkdownContentToHeadersAndSection
10-
[Fact]
11-
public void MarkdownContentToHeadersAndSection_ParsesSampleMarkdown_CorrectlyCombinesHeadersAndExtractsContent()
9+
[Test]
10+
public async Task MarkdownContentToHeadersAndSection_ParsesSampleMarkdown_CorrectlyCombinesHeadersAndExtractsContent()
1211
{
1312
string markdown = """
1413
### Beginner Topic
@@ -43,15 +42,16 @@ publicstaticvoid Main() // Method declaration
4342

4443
var sections = MarkdownChunkingService.MarkdownContentToHeadersAndSection(markdown);
4544

46-
Assert.Equal(3, sections.Count);
47-
Assert.Contains(sections, s => s.Header == "Beginner Topic: What Is a Method?" && string.Join("\n", s.Content).Contains("Syntactically, a **method** in C# is a named block of code"));
48-
Assert.Contains(sections, s => s.Header == "Main Method" && string.Join("\n", s.Content).Contains("The location where C# programs begin execution is the **Main method**, which begins with `static void Main()`")
49-
&& string.Join("\n", s.Content).Contains("publicclass Program"));
50-
Assert.Contains(sections, s => s.Header == "Main Method: Advanced Topic: Declaration of the Main Method" && string.Join("\n", s.Content).Contains("C# requires that the Main method return either `void` or `int`"));
45+
await Assert.That(sections).Count().IsEqualTo(3);
46+
await Assert.That(sections).Contains(s => s.Header == "Beginner Topic: What Is a Method?" && string.Join("\n", s.Content).Contains("Syntactically, a **method** in C# is a named block of code"));
47+
48+
await Assert.That(sections).Contains(s => s.Header == "Main Method" && string.Join("\n", s.Content).Contains("The location where C# programs begin execution is the **Main method**, which begins with `static void Main()`") && string.Join("\n", s.Content).Contains("publicclass Program"));
49+
50+
await Assert.That(sections).Contains(s => s.Header == "Main Method: Advanced Topic: Declaration of the Main Method" && string.Join("\n", s.Content).Contains("C# requires that the Main method return either `void` or `int`"));
5151
}
5252

53-
[Fact]
54-
public void MarkdownContentToHeadersAndSection_AppendsCodeListingToPriorSection()
53+
[Test]
54+
public async Task MarkdownContentToHeadersAndSection_AppendsCodeListingToPriorSection()
5555
{
5656
string markdown = """
5757
## Working with Variables
@@ -86,16 +86,14 @@ publicstaticvoid Main()
8686

8787
var sections = MarkdownChunkingService.MarkdownContentToHeadersAndSection(markdown);
8888

89-
Assert.Equal(2, sections.Count);
89+
await Assert.That(sections).Count().IsEqualTo(2);
9090
// The code listing should be appended to the Working with Variables section, not as its own section
91-
var workingWithVariablesSection = sections.FirstOrDefault(s => s.Header == "Working with Variables");
92-
Assert.True(!string.IsNullOrEmpty(workingWithVariablesSection.Header));
93-
Assert.Contains("publicclass MiracleMax", string.Join("\n", workingWithVariablesSection.Content));
94-
Assert.DoesNotContain(sections, s => s.Header == "Listing 1.12: Declaring and Assigning a Variable");
91+
await Assert.That(sections).Contains(s => s.Header == "Working with Variables" && string.Join("\n", s.Content).Contains("publicclass MiracleMax"));
92+
await Assert.That(sections).DoesNotContain(s => s.Header == "Listing 1.12: Declaring and Assigning a Variable");
9593
}
9694

97-
[Fact]
98-
public void MarkdownContentToHeadersAndSection_KeepsPriorHeadersAppended()
95+
[Test]
96+
public async Task MarkdownContentToHeadersAndSection_KeepsPriorHeadersAppended()
9997
{
10098
string markdown = """
10199
### Beginner Topic
@@ -143,19 +141,23 @@ publicstaticvoid Main()
143141
""";
144142

145143
var sections = MarkdownChunkingService.MarkdownContentToHeadersAndSection(markdown);
146-
Assert.Equal(5, sections.Count);
144+
await Assert.That(sections).Count().IsEqualTo(5);
145+
146+
await Assert.That(sections).Contains(s => s.Header == "Beginner Topic: What Is a Data Type?" && string.Join("\n", s.Content).Contains("The type of data that a variable declaration specifies is called a **data type**"));
147147

148-
Assert.Contains(sections, s => s.Header == "Beginner Topic: What Is a Data Type?" && string.Join("\n", s.Content).Contains("The type of data that a variable declaration specifies is called a **data type**"));
149-
Assert.Contains(sections, s => s.Header == "Declaring a Variable" && string.Join("\n", s.Content).Contains("In Listing 1.12, `string max` is a variable declaration"));
150-
Assert.Contains(sections, s => s.Header == "Declaring a Variable: Declaring another thing" && string.Join("\n", s.Content).Contains("Because a multivariable declaration statement allows developers to provide the data type only once"));
151-
Assert.Contains(sections, s => s.Header == "Assigning a Variable" && string.Join("\n", s.Content).Contains("After declaring a local variable, you must assign it a value before reading from it."));
152-
Assert.Contains(sections, s => s.Header == "Assigning a Variable: Continued Learning" && string.Join("\n", s.Content).Contains("From this listing, observe that it is possible to assign a variable as part of the variable declaration"));
148+
await Assert.That(sections).Contains(s => s.Header == "Declaring a Variable" && string.Join("\n", s.Content).Contains("In Listing 1.12, `string max` is a variable declaration"));
149+
150+
await Assert.That(sections).Contains(s => s.Header == "Declaring a Variable: Declaring another thing" && string.Join("\n", s.Content).Contains("Because a multivariable declaration statement allows developers to provide the data type only once"));
151+
152+
await Assert.That(sections).Contains(s => s.Header == "Assigning a Variable" && string.Join("\n", s.Content).Contains("After declaring a local variable, you must assign it a value before reading from it."));
153+
154+
await Assert.That(sections).Contains(s => s.Header == "Assigning a Variable: Continued Learning" && string.Join("\n", s.Content).Contains("From this listing, observe that it is possible to assign a variable as part of the variable declaration"));
153155
}
154156
#endregion MarkdownContentToHeadersAndSection
155157

156158
#region ProcessSingleMarkdownFile
157-
[Fact]
158-
public void ProcessSingleMarkdownFile_ProducesExpectedChunksAndHeaders()
159+
[Test]
160+
public async Task ProcessSingleMarkdownFile_ProducesExpectedChunksAndHeaders()
159161
{
160162
// Arrange
161163
var logger = new Mock<Microsoft.Extensions.Logging.ILogger<MarkdownChunkingService>>().Object;
@@ -178,15 +180,12 @@ public void ProcessSingleMarkdownFile_ProducesExpectedChunksAndHeaders()
178180
var result = service.ProcessSingleMarkdownFile(fileContent, fileName, filePath);
179181

180182
// Assert
181-
Assert.NotNull(result);
182-
Assert.Equal(fileName, result.FileName);
183-
Assert.Equal(filePath, result.FilePath);
184-
Assert.Contains("This is the first section.", string.Join("\n", result.Chunks));
185-
Assert.Contains("Console.WriteLine(\"Hello World\");", string.Join("\n", result.Chunks));
186-
Assert.Contains("This is the second section.", string.Join("\n", result.Chunks));
187-
Assert.Contains(result.Chunks, c => c.Contains("This is the second section."));
183+
await Assert.That(result).IsNotNull();
184+
await Assert.That(result.FileName).IsEqualTo(fileName);
185+
await Assert.That(result.FilePath).IsEqualTo(filePath);
186+
await Assert.That(string.Join("\n", result.Chunks)).Contains("This is the first section.");
187+
await Assert.That(string.Join("\n", result.Chunks)).Contains("Console.WriteLine(\"Hello World\");");
188+
await Assert.That(result.Chunks).Contains(c => c.Contains("This is the second section."));
188189
}
189190
#endregion ProcessSingleMarkdownFile
190191
}
191-
192-
#pragma warning restore CA1707 // Identifiers should not contain underscores

EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,14 @@
55

66
<IsPackable>false</IsPackable>
77
<IsPublishable>false</IsPublishable>
8-
<!--
9-
CA1707, Identifiers should not contain underscores - we allow these in test names
10-
-->
11-
<NoWarn>$(NoWarn);CA1707</NoWarn>
128
</PropertyGroup>
139

1410
<ItemGroup>
1511
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
1612
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
17-
<PackageReference Include="Microsoft.NET.Test.Sdk" />
1813
<PackageReference Include="Moq.AutoMock" />
14+
<PackageReference Include="TUnit" />
1915
<PackageReference Include="Newtonsoft.Json" />
20-
<PackageReference Include="xunit" />
21-
<PackageReference Include="xunit.runner.visualstudio">
22-
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
23-
<PrivateAssets>all</PrivateAssets>
24-
</PackageReference>
25-
<PackageReference Include="coverlet.collector">
26-
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
27-
<PrivateAssets>all</PrivateAssets>
28-
</PackageReference>
2916
</ItemGroup>
3017

3118
<ItemGroup>

EssentialCSharp.Web.Tests/FunctionalTests.cs

Lines changed: 26 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,57 +2,53 @@
22

33
namespace EssentialCSharp.Web.Tests;
44

5-
public class FunctionalTests
5+
[NotInParallel("FunctionalTests")]
6+
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerClass)]
7+
public class FunctionalTests(WebApplicationFactory factory)
68
{
7-
[Theory]
8-
[InlineData("/")]
9-
[InlineData("/hello-world")]
10-
[InlineData("/hello-world#hello-world")]
11-
[InlineData("/guidelines")]
12-
[InlineData("/healthz")]
9+
[Test]
10+
[Arguments("/")]
11+
[Arguments("/hello-world")]
12+
[Arguments("/hello-world#hello-world")]
13+
[Arguments("/guidelines")]
14+
[Arguments("/healthz")]
1315
public async Task WhenTheApplicationStarts_ItCanLoadLoadPages(string relativeUrl)
1416
{
15-
using WebApplicationFactory factory = new();
16-
1717
HttpClient client = factory.CreateClient();
1818
using HttpResponseMessage response = await client.GetAsync(relativeUrl);
1919

20-
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
20+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
2121
}
2222

23-
[Theory]
24-
[InlineData("/guidelines?rid=test-referral-id")]
25-
[InlineData("/about?rid=abc123")]
26-
[InlineData("/hello-world?rid=user-referral")]
27-
[InlineData("/guidelines?rid=")]
28-
[InlineData("/about?rid= ")]
29-
[InlineData("/guidelines?foo=bar")]
30-
[InlineData("/about?someOtherParam=value")]
23+
[Test]
24+
[Arguments("/guidelines?rid=test-referral-id")]
25+
[Arguments("/about?rid=abc123")]
26+
[Arguments("/hello-world?rid=user-referral")]
27+
[Arguments("/guidelines?rid=")]
28+
[Arguments("/about?rid= ")]
29+
[Arguments("/guidelines?foo=bar")]
30+
[Arguments("/about?someOtherParam=value")]
3131
public async Task WhenPagesAreAccessed_TheyReturnHtml(string relativeUrl)
3232
{
33-
using WebApplicationFactory factory = new();
34-
3533
HttpClient client = factory.CreateClient();
3634
using HttpResponseMessage response = await client.GetAsync(relativeUrl);
3735

38-
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
39-
36+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
37+
4038
// Ensure the response has content (not blank)
4139
string content = await response.Content.ReadAsStringAsync();
42-
Assert.NotEmpty(content);
43-
40+
await Assert.That(content).IsNotEmpty();
41+
4442
// Verify it's actually HTML content, not just whitespace
45-
Assert.Contains("<html", content, StringComparison.OrdinalIgnoreCase);
43+
await Assert.That(content).Contains("<html", StringComparison.OrdinalIgnoreCase);
4644
}
4745

48-
[Fact]
46+
[Test]
4947
public async Task WhenTheApplicationStarts_NonExistingPage_GivesCorrectStatusCode()
5048
{
51-
using WebApplicationFactory factory = new();
52-
5349
HttpClient client = factory.CreateClient();
5450
using HttpResponseMessage response = await client.GetAsync("/non-existing-page1234");
5551

56-
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
52+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
5753
}
58-
}
54+
}
Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,31 @@
1+
using EssentialCSharp.Web.Extensions;
12
using EssentialCSharp.Web.Models;
23
using EssentialCSharp.Web.Services;
34
using Microsoft.Extensions.Configuration;
45
using Microsoft.Extensions.DependencyInjection;
56

6-
namespace EssentialCSharp.Web.Extensions.Tests.Integration;
7+
namespace EssentialCSharp.Web.Tests.Integration;
78

8-
public class CaptchaTests(CaptchaServiceProvider serviceProvider) : IClassFixture<CaptchaServiceProvider>
9+
[ClassDataSource<CaptchaServiceProvider>(Shared = SharedType.PerClass)]
10+
public class CaptchaTests(CaptchaServiceProvider serviceProvider)
911
{
10-
[Fact]
11-
public async Task CaptchaService_Verify_Success()
12+
[Test]
13+
public async Task CaptchaService_Verify_Success(CancellationToken cancellationToken)
1214
{
1315
ICaptchaService captchaService = serviceProvider.ServiceProvider.GetRequiredService<ICaptchaService>();
1416

1517
// From https://docs.hcaptcha.com/#integration-testing-test-keys
1618
string hCaptchaSecret = "0x0000000000000000000000000000000000000000";
1719
string hCaptchaToken = "10000000-aaaa-bbbb-cccc-000000000001";
1820
string hCaptchaSiteKey = "10000000-ffff-ffff-ffff-000000000001";
19-
HCaptchaResult? response = await captchaService.VerifyAsync(hCaptchaSecret, hCaptchaToken, hCaptchaSiteKey);
21+
HCaptchaResult? response = await captchaService.VerifyAsync(hCaptchaSecret, hCaptchaToken, hCaptchaSiteKey, cancellationToken);
2022

21-
Assert.NotNull(response);
22-
Assert.True(response.Success);
23+
await Assert.That(response).IsNotNull();
24+
await Assert.That(response.Success).IsTrue();
2325
}
2426
}
2527

26-
public class CaptchaServiceProvider
28+
public class CaptchaServiceProvider : IDisposable, IAsyncDisposable
2729
{
2830
public ServiceProvider ServiceProvider { get; } = CreateServiceProvider();
2931
public static ServiceProvider CreateServiceProvider()
@@ -39,4 +41,24 @@ public static ServiceProvider CreateServiceProvider()
3941

4042
return services.BuildServiceProvider();
4143
}
42-
}
44+
public void Dispose()
45+
{
46+
Dispose(disposing: true);
47+
GC.SuppressFinalize(this);
48+
}
49+
50+
protected virtual void Dispose(bool disposing)
51+
{
52+
if (disposing)
53+
{
54+
ServiceProvider.Dispose();
55+
}
56+
}
57+
58+
public async ValueTask DisposeAsync()
59+
{
60+
await ServiceProvider.DisposeAsync().ConfigureAwait(false);
61+
Dispose(disposing: false);
62+
GC.SuppressFinalize(this);
63+
}
64+
}

0 commit comments

Comments
 (0)