From 611c96918f9cc00f54a43e78f1981e4aeca788ca Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Tue, 24 Feb 2026 21:45:30 -0800 Subject: [PATCH 1/4] feat: Migrate tests from xunit to TUnit - 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 --- .github/workflows/PR-Build-And-Test.yml | 2 +- Directory.Packages.props | 5 +- .../EssentialCSharp.Chat.Tests.csproj | 8 +- .../MarkdownChunkingServiceTests.cs | 58 ++++++------- .../EssentialCSharp.Web.Tests.csproj | 11 +-- EssentialCSharp.Web.Tests/FunctionalTests.cs | 47 +++++----- .../Integration/CaptchaTests.cs | 84 +++++++++--------- .../ListingSourceCodeControllerTests.cs | 68 ++++++++------- .../ListingSourceCodeServiceTests.cs | 64 +++++++------- .../RouteConfigurationServiceTests.cs | 22 ++--- EssentialCSharp.Web.Tests/SiteMappingTests.cs | 76 ++++++++-------- .../SitemapXmlHelpersTests.cs | 87 ++++++++++--------- .../StringExtensionsTests.cs | 52 +++++------ EssentialCSharp.Web.Tests/Usings.cs | 1 - global.json | 3 + 15 files changed, 293 insertions(+), 295 deletions(-) diff --git a/.github/workflows/PR-Build-And-Test.yml b/.github/workflows/PR-Build-And-Test.yml index 1fd7a1d2..c20a3e50 100644 --- a/.github/workflows/PR-Build-And-Test.yml +++ b/.github/workflows/PR-Build-And-Test.yml @@ -35,7 +35,7 @@ jobs: run: dotnet build --configuration Release --no-restore /p:AccessToNugetFeed=false - name: Run .NET Tests - run: dotnet test --no-build --configuration Release --logger trx --results-directory ${{ runner.temp }} + run: dotnet test --no-build --configuration Release -- --report-trx --results-directory ${{ runner.temp }} - name: Convert TRX to VS Playlist if: failure() diff --git a/Directory.Packages.props b/Directory.Packages.props index cacbdee9..d43565d5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,7 +21,7 @@ - + @@ -36,7 +36,6 @@ - @@ -50,7 +49,5 @@ - - diff --git a/EssentialCSharp.Chat.Tests/EssentialCSharp.Chat.Tests.csproj b/EssentialCSharp.Chat.Tests/EssentialCSharp.Chat.Tests.csproj index 62dc7206..c8638c29 100644 --- a/EssentialCSharp.Chat.Tests/EssentialCSharp.Chat.Tests.csproj +++ b/EssentialCSharp.Chat.Tests/EssentialCSharp.Chat.Tests.csproj @@ -6,19 +6,13 @@ - - - - + - - - diff --git a/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs b/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs index 8aab8cb6..98f74a74 100644 --- a/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs +++ b/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs @@ -7,8 +7,8 @@ namespace EssentialCSharp.Chat.Tests; public class MarkdownChunkingServiceTests { #region MarkdownContentToHeadersAndSection - [Fact] - public void MarkdownContentToHeadersAndSection_ParsesSampleMarkdown_CorrectlyCombinesHeadersAndExtractsContent() + [Test] + public async Task MarkdownContentToHeadersAndSection_ParsesSampleMarkdown_CorrectlyCombinesHeadersAndExtractsContent() { string markdown = """ ### Beginner Topic @@ -43,15 +43,15 @@ publicstaticvoid Main() // Method declaration var sections = MarkdownChunkingService.MarkdownContentToHeadersAndSection(markdown); - Assert.Equal(3, sections.Count); - 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")); - 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()`") + await Assert.That(sections.Count).IsEqualTo(3); + 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")); + 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")); - 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`")); + 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`")); } - [Fact] - public void MarkdownContentToHeadersAndSection_AppendsCodeListingToPriorSection() + [Test] + public async Task MarkdownContentToHeadersAndSection_AppendsCodeListingToPriorSection() { string markdown = """ ## Working with Variables @@ -86,16 +86,16 @@ publicstaticvoid Main() var sections = MarkdownChunkingService.MarkdownContentToHeadersAndSection(markdown); - Assert.Equal(2, sections.Count); + await Assert.That(sections.Count).IsEqualTo(2); // The code listing should be appended to the Working with Variables section, not as its own section var workingWithVariablesSection = sections.FirstOrDefault(s => s.Header == "Working with Variables"); - Assert.True(!string.IsNullOrEmpty(workingWithVariablesSection.Header)); - Assert.Contains("publicclass MiracleMax", string.Join("\n", workingWithVariablesSection.Content)); - Assert.DoesNotContain(sections, s => s.Header == "Listing 1.12: Declaring and Assigning a Variable"); + await Assert.That(!string.IsNullOrEmpty(workingWithVariablesSection.Header)).IsTrue(); + await Assert.That(string.Join("\n", workingWithVariablesSection.Content)).Contains("publicclass MiracleMax"); + await Assert.That(sections).DoesNotContain(s => s.Header == "Listing 1.12: Declaring and Assigning a Variable"); } - [Fact] - public void MarkdownContentToHeadersAndSection_KeepsPriorHeadersAppended() + [Test] + public async Task MarkdownContentToHeadersAndSection_KeepsPriorHeadersAppended() { string markdown = """ ### Beginner Topic @@ -143,19 +143,19 @@ publicstaticvoid Main() """; var sections = MarkdownChunkingService.MarkdownContentToHeadersAndSection(markdown); - Assert.Equal(5, sections.Count); + await Assert.That(sections.Count).IsEqualTo(5); - 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**")); - 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")); - 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")); - 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.")); - 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")); + 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**")); + 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")); + 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")); + 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.")); + 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")); } #endregion MarkdownContentToHeadersAndSection #region ProcessSingleMarkdownFile - [Fact] - public void ProcessSingleMarkdownFile_ProducesExpectedChunksAndHeaders() + [Test] + public async Task ProcessSingleMarkdownFile_ProducesExpectedChunksAndHeaders() { // Arrange var logger = new Mock>().Object; @@ -178,13 +178,13 @@ public void ProcessSingleMarkdownFile_ProducesExpectedChunksAndHeaders() var result = service.ProcessSingleMarkdownFile(fileContent, fileName, filePath); // Assert - Assert.NotNull(result); - Assert.Equal(fileName, result.FileName); - Assert.Equal(filePath, result.FilePath); - Assert.Contains("This is the first section.", string.Join("\n", result.Chunks)); - Assert.Contains("Console.WriteLine(\"Hello World\");", string.Join("\n", result.Chunks)); - Assert.Contains("This is the second section.", string.Join("\n", result.Chunks)); - Assert.Contains(result.Chunks, c => c.Contains("This is the second section.")); + await Assert.That(result).IsNotNull(); + await Assert.That(result.FileName).IsEqualTo(fileName); + await Assert.That(result.FilePath).IsEqualTo(filePath); + await Assert.That(string.Join("\n", result.Chunks)).Contains("This is the first section."); + await Assert.That(string.Join("\n", result.Chunks)).Contains("Console.WriteLine(\"Hello World\");"); + await Assert.That(string.Join("\n", result.Chunks)).Contains("This is the second section."); + await Assert.That(result.Chunks).Contains(c => c.Contains("This is the second section.")); } #endregion ProcessSingleMarkdownFile } diff --git a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj index 88e40aa3..a192c778 100644 --- a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj +++ b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj @@ -14,18 +14,9 @@ - + - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - diff --git a/EssentialCSharp.Web.Tests/FunctionalTests.cs b/EssentialCSharp.Web.Tests/FunctionalTests.cs index abdf499d..7eb65f4c 100644 --- a/EssentialCSharp.Web.Tests/FunctionalTests.cs +++ b/EssentialCSharp.Web.Tests/FunctionalTests.cs @@ -1,15 +1,16 @@ using System.Net; +using System.Threading.Tasks; namespace EssentialCSharp.Web.Tests; public class FunctionalTests { - [Theory] - [InlineData("/")] - [InlineData("/hello-world")] - [InlineData("/hello-world#hello-world")] - [InlineData("/guidelines")] - [InlineData("/healthz")] + [Test] + [Arguments("/")] + [Arguments("/hello-world")] + [Arguments("/hello-world#hello-world")] + [Arguments("/guidelines")] + [Arguments("/healthz")] public async Task WhenTheApplicationStarts_ItCanLoadLoadPages(string relativeUrl) { using WebApplicationFactory factory = new(); @@ -17,17 +18,17 @@ public async Task WhenTheApplicationStarts_ItCanLoadLoadPages(string relativeUrl HttpClient client = factory.CreateClient(); using HttpResponseMessage response = await client.GetAsync(relativeUrl); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); } - [Theory] - [InlineData("/guidelines?rid=test-referral-id")] - [InlineData("/about?rid=abc123")] - [InlineData("/hello-world?rid=user-referral")] - [InlineData("/guidelines?rid=")] - [InlineData("/about?rid= ")] - [InlineData("/guidelines?foo=bar")] - [InlineData("/about?someOtherParam=value")] + [Test] + [Arguments("/guidelines?rid=test-referral-id")] + [Arguments("/about?rid=abc123")] + [Arguments("/hello-world?rid=user-referral")] + [Arguments("/guidelines?rid=")] + [Arguments("/about?rid= ")] + [Arguments("/guidelines?foo=bar")] + [Arguments("/about?someOtherParam=value")] public async Task WhenPagesAreAccessed_TheyReturnHtml(string relativeUrl) { using WebApplicationFactory factory = new(); @@ -35,17 +36,17 @@ public async Task WhenPagesAreAccessed_TheyReturnHtml(string relativeUrl) HttpClient client = factory.CreateClient(); using HttpResponseMessage response = await client.GetAsync(relativeUrl); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + // Ensure the response has content (not blank) string content = await response.Content.ReadAsStringAsync(); - Assert.NotEmpty(content); - + await Assert.That(content).IsNotEmpty(); + // Verify it's actually HTML content, not just whitespace - Assert.Contains(" -{ - [Fact] - public async Task CaptchaService_Verify_Success() - { - ICaptchaService captchaService = serviceProvider.ServiceProvider.GetRequiredService(); - - // From https://docs.hcaptcha.com/#integration-testing-test-keys - string hCaptchaSecret = "0x0000000000000000000000000000000000000000"; - string hCaptchaToken = "10000000-aaaa-bbbb-cccc-000000000001"; - string hCaptchaSiteKey = "10000000-ffff-ffff-ffff-000000000001"; - HCaptchaResult? response = await captchaService.VerifyAsync(hCaptchaSecret, hCaptchaToken, hCaptchaSiteKey); - - Assert.NotNull(response); - Assert.True(response.Success); - } -} - -public class CaptchaServiceProvider -{ - public ServiceProvider ServiceProvider { get; } = CreateServiceProvider(); - public static ServiceProvider CreateServiceProvider() - { - IServiceCollection services = new ServiceCollection(); - - IConfigurationRoot configuration = new ConfigurationBuilder() - .SetBasePath(IntelliTect.Multitool.RepositoryPaths.GetDefaultRepoRoot()) - .AddJsonFile($"{nameof(EssentialCSharp)}.{nameof(Web)}/appsettings.json") - .Build(); - services.AddCaptchaService(configuration.GetSection(CaptchaOptions.CaptchaSender)); - // Add other necessary services here - - return services.BuildServiceProvider(); - } -} +namespace EssentialCSharp.Web.Extensions.Tests.Integration; + +[ClassDataSource(Shared = SharedType.PerClass)] +public class CaptchaTests(CaptchaServiceProvider serviceProvider) +{ + [Test] + public async Task CaptchaService_Verify_Success() + { + ICaptchaService captchaService = serviceProvider.ServiceProvider.GetRequiredService(); + + // From https://docs.hcaptcha.com/#integration-testing-test-keys + string hCaptchaSecret = "0x0000000000000000000000000000000000000000"; + string hCaptchaToken = "10000000-aaaa-bbbb-cccc-000000000001"; + string hCaptchaSiteKey = "10000000-ffff-ffff-ffff-000000000001"; + HCaptchaResult? response = await captchaService.VerifyAsync(hCaptchaSecret, hCaptchaToken, hCaptchaSiteKey); + + await Assert.That(response).IsNotNull(); + await Assert.That(response.Success).IsTrue(); + } +} + +public class CaptchaServiceProvider +{ + public ServiceProvider ServiceProvider { get; } = CreateServiceProvider(); + public static ServiceProvider CreateServiceProvider() + { + IServiceCollection services = new ServiceCollection(); + + IConfigurationRoot configuration = new ConfigurationBuilder() + .SetBasePath(IntelliTect.Multitool.RepositoryPaths.GetDefaultRepoRoot()) + .AddJsonFile($"{nameof(EssentialCSharp)}.{nameof(Web)}/appsettings.json") + .Build(); + services.AddCaptchaService(configuration.GetSection(CaptchaOptions.CaptchaSender)); + // Add other necessary services here + + return services.BuildServiceProvider(); + } +} \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs index 1944d309..bf0ce5b9 100644 --- a/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs @@ -1,12 +1,13 @@ using System.Net; using System.Net.Http.Json; using EssentialCSharp.Web.Models; +using System.Threading.Tasks; namespace EssentialCSharp.Web.Tests; public class ListingSourceCodeControllerTests { - [Fact] + [Test] public async Task GetListing_WithValidChapterAndListing_Returns200WithContent() { // Arrange @@ -17,18 +18,18 @@ public async Task GetListing_WithValidChapterAndListing_Returns200WithContent() using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1/listing/1"); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + ListingSourceCodeResponse? result = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(result); - Assert.Equal(1, result.ChapterNumber); - Assert.Equal(1, result.ListingNumber); - Assert.NotEmpty(result.FileExtension); - Assert.NotEmpty(result.Content); + await Assert.That(result).IsNotNull(); + await Assert.That(result.ChapterNumber).IsEqualTo(1); + await Assert.That(result.ListingNumber).IsEqualTo(1); + await Assert.That(result.FileExtension).IsNotEmpty(); + await Assert.That(result.Content).IsNotEmpty(); } - [Fact] + [Test] public async Task GetListing_WithInvalidChapter_Returns404() { // Arrange @@ -39,10 +40,10 @@ public async Task GetListing_WithInvalidChapter_Returns404() using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/999/listing/1"); // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); } - [Fact] + [Test] public async Task GetListing_WithInvalidListing_Returns404() { // Arrange @@ -53,10 +54,10 @@ public async Task GetListing_WithInvalidListing_Returns404() using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1/listing/999"); // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); } - [Fact] + [Test] public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings() { // Arrange @@ -67,27 +68,30 @@ public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings( using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1"); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + List? results = await response.Content.ReadFromJsonAsync>(); - Assert.NotNull(results); - Assert.NotEmpty(results); - + await Assert.That(results).IsNotNull(); + await Assert.That(results).IsNotEmpty(); + // Verify all results are from chapter 1 - Assert.All(results, r => Assert.Equal(1, r.ChapterNumber)); - + foreach (var r in results) + { + await Assert.That(r.ChapterNumber).IsEqualTo(1); + } + // Verify results are ordered by listing number - Assert.Equal(results.OrderBy(r => r.ListingNumber).ToList(), results); - + await Assert.That(results).IsEquivalentTo(results.OrderBy(r => r.ListingNumber).ToList()); + // Verify each listing has required properties - Assert.All(results, r => + foreach (var r in results) { - Assert.NotEmpty(r.FileExtension); - Assert.NotEmpty(r.Content); - }); + await Assert.That(r.FileExtension).IsNotEmpty(); + await Assert.That(r.Content).IsNotEmpty(); + } } - [Fact] + [Test] public async Task GetListingsByChapter_WithInvalidChapter_ReturnsEmptyList() { // Arrange @@ -98,10 +102,10 @@ public async Task GetListingsByChapter_WithInvalidChapter_ReturnsEmptyList() using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/999"); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + List? results = await response.Content.ReadFromJsonAsync>(); - Assert.NotNull(results); - Assert.Empty(results); + await Assert.That(results).IsNotNull(); + await Assert.That(results).IsEmpty(); } -} +} \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs index fbc1d595..544c450e 100644 --- a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs @@ -4,12 +4,13 @@ using Microsoft.Extensions.FileProviders; using Moq; using Moq.AutoMock; +using System.Threading.Tasks; namespace EssentialCSharp.Web.Tests; public class ListingSourceCodeServiceTests { - [Fact] + [Test] public async Task GetListingAsync_WithValidChapterAndListing_ReturnsCorrectListing() { // Arrange @@ -19,14 +20,14 @@ public async Task GetListingAsync_WithValidChapterAndListing_ReturnsCorrectListi ListingSourceCodeResponse? result = await service.GetListingAsync(1, 1); // Assert - Assert.NotNull(result); - Assert.Equal(1, result.ChapterNumber); - Assert.Equal(1, result.ListingNumber); - Assert.Equal("cs", result.FileExtension); - Assert.NotEmpty(result.Content); + await Assert.That(result).IsNotNull(); + await Assert.That(result.ChapterNumber).IsEqualTo(1); + await Assert.That(result.ListingNumber).IsEqualTo(1); + await Assert.That(result.FileExtension).IsEqualTo("cs"); + await Assert.That(result.Content).IsNotEmpty(); } - [Fact] + [Test] public async Task GetListingAsync_WithInvalidChapter_ReturnsNull() { // Arrange @@ -36,10 +37,10 @@ public async Task GetListingAsync_WithInvalidChapter_ReturnsNull() ListingSourceCodeResponse? result = await service.GetListingAsync(999, 1); // Assert - Assert.Null(result); + await Assert.That(result).IsNull(); } - [Fact] + [Test] public async Task GetListingAsync_WithInvalidListing_ReturnsNull() { // Arrange @@ -49,10 +50,10 @@ public async Task GetListingAsync_WithInvalidListing_ReturnsNull() ListingSourceCodeResponse? result = await service.GetListingAsync(1, 999); // Assert - Assert.Null(result); + await Assert.That(result).IsNull(); } - [Fact] + [Test] public async Task GetListingAsync_DifferentFileExtension_AutoDiscoversFileExtension() { // Arrange @@ -62,11 +63,11 @@ public async Task GetListingAsync_DifferentFileExtension_AutoDiscoversFileExtens ListingSourceCodeResponse? result = await service.GetListingAsync(1, 2); // Assert - Assert.NotNull(result); - Assert.Equal("xml", result.FileExtension); + await Assert.That(result).IsNotNull(); + await Assert.That(result.FileExtension).IsEqualTo("xml"); } - [Fact] + [Test] public async Task GetListingsByChapterAsync_WithValidChapter_ReturnsAllListings() { // Arrange @@ -76,16 +77,19 @@ public async Task GetListingsByChapterAsync_WithValidChapter_ReturnsAllListings( IReadOnlyList results = await service.GetListingsByChapterAsync(1); // Assert - Assert.NotEmpty(results); - Assert.All(results, r => Assert.Equal(1, r.ChapterNumber)); - Assert.All(results, r => Assert.NotEmpty(r.Content)); - Assert.All(results, r => Assert.NotEmpty(r.FileExtension)); - + await Assert.That(results).IsNotEmpty(); + foreach (var r in results) + { + await Assert.That(r.ChapterNumber).IsEqualTo(1); + await Assert.That(r.Content).IsNotEmpty(); + await Assert.That(r.FileExtension).IsNotEmpty(); + } + // Verify results are ordered - Assert.Equal(results.OrderBy(r => r.ListingNumber).ToList(), results); + await Assert.That(results).IsEquivalentTo(results.OrderBy(r => r.ListingNumber).ToList()); } - [Fact] + [Test] public async Task GetListingsByChapterAsync_DirectoryContainsNonListingFiles_ExcludesNonListingFiles() { // Arrange - Chapter 10 has Employee.cs which doesn't match the pattern @@ -95,17 +99,17 @@ public async Task GetListingsByChapterAsync_DirectoryContainsNonListingFiles_Exc IReadOnlyList results = await service.GetListingsByChapterAsync(10); // Assert - Assert.NotEmpty(results); - + await Assert.That(results).IsNotEmpty(); + // Ensure all results match the {CC}.{LL}.{ext} pattern - Assert.All(results, r => + foreach (var r in results) { - Assert.Equal(10, r.ChapterNumber); - Assert.InRange(r.ListingNumber, 1, 99); - }); + await Assert.That(r.ChapterNumber).IsEqualTo(10); + await Assert.That(r.ListingNumber).IsBetween(1, 99); + } } - [Fact] + [Test] public async Task GetListingsByChapterAsync_WithInvalidChapter_ReturnsEmptyList() { // Arrange @@ -115,7 +119,7 @@ public async Task GetListingsByChapterAsync_WithInvalidChapter_ReturnsEmptyList( IReadOnlyList results = await service.GetListingsByChapterAsync(999); // Assert - Assert.Empty(results); + await Assert.That(results).IsEmpty(); } private static ListingSourceCodeService CreateService() @@ -144,4 +148,4 @@ private static DirectoryInfo GetTestDataPath() return testDataDirectory; } -} +} \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs index 9280e3fd..2b1a1797 100644 --- a/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs +++ b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs @@ -1,9 +1,11 @@ using EssentialCSharp.Web.Services; using Microsoft.Extensions.DependencyInjection; +using System.Threading.Tasks; namespace EssentialCSharp.Web.Tests; -public class RouteConfigurationServiceTests : IClassFixture +[ClassDataSource(Shared = SharedType.PerClass)] +public class RouteConfigurationServiceTests { private readonly WebApplicationFactory _Factory; @@ -12,8 +14,8 @@ public RouteConfigurationServiceTests(WebApplicationFactory factory) _Factory = factory; } - [Fact] - public void GetStaticRoutes_ShouldReturnExpectedRoutes() + [Test] + public async Task GetStaticRoutes_ShouldReturnExpectedRoutes() { // Act var routes = _Factory.InServiceScope(serviceProvider => @@ -23,13 +25,13 @@ public void GetStaticRoutes_ShouldReturnExpectedRoutes() }); // Assert - Assert.NotEmpty(routes); + await Assert.That(routes).IsNotEmpty(); // Check for expected routes from the HomeController - Assert.Contains("home", routes); - Assert.Contains("about", routes); - Assert.Contains("guidelines", routes); - Assert.Contains("announcements", routes); - Assert.Contains("termsofservice", routes); + await Assert.That(routes).Contains("home"); + await Assert.That(routes).Contains("about"); + await Assert.That(routes).Contains("guidelines"); + await Assert.That(routes).Contains("announcements"); + await Assert.That(routes).Contains("termsofservice"); } -} +} \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/SiteMappingTests.cs b/EssentialCSharp.Web.Tests/SiteMappingTests.cs index cf4c2c34..4d56e5bf 100644 --- a/EssentialCSharp.Web.Tests/SiteMappingTests.cs +++ b/EssentialCSharp.Web.Tests/SiteMappingTests.cs @@ -1,4 +1,5 @@ using EssentialCSharp.Web.Extensions; +using System.Threading.Tasks; namespace EssentialCSharp.Web.Tests; @@ -51,40 +52,40 @@ public static List GetSiteMap() ]; } - [Fact] - public void FindHelloWorldWithAnchorSlugReturnsCorrectSiteMap() + [Test] + public async Task FindHelloWorldWithAnchorSlugReturnsCorrectSiteMap() { SiteMapping? foundSiteMap = GetSiteMap().Find("hello-world#hello-world"); - Assert.NotNull(foundSiteMap); - Assert.Equivalent(HelloWorldSiteMapping, foundSiteMap); + await Assert.That(foundSiteMap).IsNotNull(); + await Assert.That(foundSiteMap).IsEquivalentTo(HelloWorldSiteMapping); } - [Fact] - public void FindCSyntaxFundamentalsWithSpacesReturnsCorrectSiteMap() + [Test] + public async Task FindCSyntaxFundamentalsWithSpacesReturnsCorrectSiteMap() { SiteMapping? foundSiteMap = GetSiteMap().Find("C# Syntax Fundamentals"); - Assert.NotNull(foundSiteMap); - Assert.Equivalent(CSyntaxFundamentalsSiteMapping, foundSiteMap); + await Assert.That(foundSiteMap).IsNotNull(); + await Assert.That(foundSiteMap).IsEquivalentTo(CSyntaxFundamentalsSiteMapping); } - [Fact] - public void FindCSyntaxFundamentalsWithSpacesAndAnchorReturnsCorrectSiteMap() + [Test] + public async Task FindCSyntaxFundamentalsWithSpacesAndAnchorReturnsCorrectSiteMap() { SiteMapping? foundSiteMap = GetSiteMap().Find("C# Syntax Fundamentals#hello-world"); - Assert.NotNull(foundSiteMap); - Assert.Equivalent(CSyntaxFundamentalsSiteMapping, foundSiteMap); + await Assert.That(foundSiteMap).IsNotNull(); + await Assert.That(foundSiteMap).IsEquivalentTo(CSyntaxFundamentalsSiteMapping); } - [Fact] - public void FindCSyntaxFundamentalsSanitizedWithAnchorReturnsCorrectSiteMap() + [Test] + public async Task FindCSyntaxFundamentalsSanitizedWithAnchorReturnsCorrectSiteMap() { SiteMapping? foundSiteMap = GetSiteMap().Find("c-syntax-fundamentals#hello-world"); - Assert.NotNull(foundSiteMap); - Assert.Equivalent(CSyntaxFundamentalsSiteMapping, foundSiteMap); + await Assert.That(foundSiteMap).IsNotNull(); + await Assert.That(foundSiteMap).IsEquivalentTo(CSyntaxFundamentalsSiteMapping); } - [Fact] - public void FindPercentComplete_KeyIsNull_ReturnsNull() + [Test] + public async Task FindPercentComplete_KeyIsNull_ReturnsNull() { // Arrange @@ -92,29 +93,26 @@ public void FindPercentComplete_KeyIsNull_ReturnsNull() string? percent = GetSiteMap().FindPercentComplete(null!); // Assert - Assert.Null(percent); + await Assert.That(percent).IsNull(); } - [Theory] - [InlineData(" ")] - [InlineData("")] - public void FindPercentComplete_KeyIsWhiteSpace_ThrowsArgumentException(string? key) + [Test] + [Arguments(" ")] + [Arguments("")] + public async Task FindPercentComplete_KeyIsWhiteSpace_ThrowsArgumentException(string? key) { // Arrange // Act // Assert - Assert.Throws(() => - { - GetSiteMap().FindPercentComplete(key); - }); + await Assert.That(() => GetSiteMap().FindPercentComplete(key)).Throws(); } - [Theory] - [InlineData("hello-world", "50.00")] - [InlineData("c-syntax-fundamentals", "100.00")] - public void FindPercentComplete_ValidKey_Success(string? key, string result) + [Test] + [Arguments("hello-world", "50.00")] + [Arguments("c-syntax-fundamentals", "100.00")] + public async Task FindPercentComplete_ValidKey_Success(string? key, string result) { // Arrange @@ -122,11 +120,11 @@ public void FindPercentComplete_ValidKey_Success(string? key, string result) string? percent = GetSiteMap().FindPercentComplete(key); // Assert - Assert.Equal(result, percent); + await Assert.That(percent).IsEqualTo(result); } - [Fact] - public void FindPercentComplete_EmptySiteMappings_ReturnsZeroPercent() + [Test] + public async Task FindPercentComplete_EmptySiteMappings_ReturnsZeroPercent() { // Arrange IList siteMappings = new List(); @@ -135,11 +133,11 @@ public void FindPercentComplete_EmptySiteMappings_ReturnsZeroPercent() string? percent = siteMappings.FindPercentComplete("test"); // Assert - Assert.Equal("0.00", percent); + await Assert.That(percent).IsEqualTo("0.00"); } - [Fact] - public void FindPercentComplete_KeyNotFound_ReturnsZeroPercent() + [Test] + public async Task FindPercentComplete_KeyNotFound_ReturnsZeroPercent() { // Arrange @@ -147,6 +145,6 @@ public void FindPercentComplete_KeyNotFound_ReturnsZeroPercent() string? percent = GetSiteMap().FindPercentComplete("non-existent-key"); // Assert - Assert.Equal("0.00", percent); + await Assert.That(percent).IsEqualTo("0.00"); } -} +} \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs index be8edc90..0f167973 100644 --- a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs +++ b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs @@ -1,13 +1,17 @@ +using System.IO; using System.Globalization; using DotnetSitemapGenerator; using EssentialCSharp.Web.Helpers; using EssentialCSharp.Web.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using System.Threading.Tasks; namespace EssentialCSharp.Web.Tests; -public class SitemapXmlHelpersTests : IClassFixture +[NotInParallel] +[ClassDataSource(Shared = SharedType.PerClass)] +public class SitemapXmlHelpersTests { private readonly WebApplicationFactory _Factory; @@ -16,8 +20,8 @@ public SitemapXmlHelpersTests(WebApplicationFactory factory) _Factory = factory; } - [Fact] - public void EnsureSitemapHealthy_WithValidSiteMappings_DoesNotThrow() + [Test] + public async Task EnsureSitemapHealthy_WithValidSiteMappings_DoesNotThrow() { // Arrange var siteMappings = new List @@ -27,13 +31,12 @@ public void EnsureSitemapHealthy_WithValidSiteMappings_DoesNotThrow() CreateSiteMapping(2, 1, true) }; - // Act & Assert - var exception = Record.Exception(() => SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)); - Assert.Null(exception); + // Act & Assert - if it throws, the test will fail + SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings); } - [Fact] - public void EnsureSitemapHealthy_WithMultipleCanonicalLinksForSamePage_ThrowsException() + [Test] + public async Task EnsureSitemapHealthy_WithMultipleCanonicalLinksForSamePage_ThrowsException() { // Arrange - Two mappings for the same chapter/page both marked as canonical var siteMappings = new List @@ -43,15 +46,14 @@ public void EnsureSitemapHealthy_WithMultipleCanonicalLinksForSamePage_ThrowsExc }; // Act & Assert - var exception = Assert.Throws(() => - SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)); + var exception = await Assert.That(() => SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)).Throws(); - Assert.Contains("Chapter 1, Page 1", exception.Message); - Assert.Contains("more than one canonical link", exception.Message); + await Assert.That(exception!.Message).Contains("Chapter 1, Page 1"); + await Assert.That(exception.Message).Contains("more than one canonical link"); } - [Fact] - public void EnsureSitemapHealthy_WithNoCanonicalLinksForPage_ThrowsException() + [Test] + public async Task EnsureSitemapHealthy_WithNoCanonicalLinksForPage_ThrowsException() { // Arrange - No mappings marked as canonical for this page var siteMappings = new List @@ -61,14 +63,13 @@ public void EnsureSitemapHealthy_WithNoCanonicalLinksForPage_ThrowsException() }; // Act & Assert - var exception = Assert.Throws(() => - SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)); + var exception = await Assert.That(() => SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)).Throws(); - Assert.Contains("Chapter 1, Page 1", exception.Message); + await Assert.That(exception!.Message).Contains("Chapter 1, Page 1"); } - [Fact] - public void GenerateSitemapXml_DoesNotIncludeIdentityRoutes() + [Test] + public async Task GenerateSitemapXml_DoesNotIncludeIdentityRoutes() { // Arrange var tempDir = new DirectoryInfo(Path.GetTempPath()); @@ -87,16 +88,16 @@ public void GenerateSitemapXml_DoesNotIncludeIdentityRoutes() var allUrls = nodes.Select(n => n.Url).ToList(); // Verify no Identity routes are included - Assert.DoesNotContain(allUrls, url => url.Contains("Identity", StringComparison.OrdinalIgnoreCase)); - Assert.DoesNotContain(allUrls, url => url.Contains("Account", StringComparison.OrdinalIgnoreCase)); + await Assert.That(allUrls).DoesNotContain(url => url.Contains("Identity", StringComparison.OrdinalIgnoreCase)); + await Assert.That(allUrls).DoesNotContain(url => url.Contains("Account", StringComparison.OrdinalIgnoreCase)); // But verify that expected routes are included - Assert.Contains(allUrls, url => url.Contains("/home", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(allUrls, url => url.Contains("/about", StringComparison.OrdinalIgnoreCase)); + await Assert.That(allUrls).Contains(url => url.Contains("/home", StringComparison.OrdinalIgnoreCase)); + await Assert.That(allUrls).Contains(url => url.Contains("/about", StringComparison.OrdinalIgnoreCase)); } - [Fact] - public void GenerateSitemapXml_IncludesBaseUrl() + [Test] + public async Task GenerateSitemapXml_IncludesBaseUrl() { // Arrange var tempDir = new DirectoryInfo(Path.GetTempPath()); @@ -112,16 +113,16 @@ public void GenerateSitemapXml_IncludesBaseUrl() baseUrl, out var nodes); - Assert.Contains(nodes, node => node.Url == baseUrl); + await Assert.That(nodes).Contains(node => node.Url == baseUrl); // Verify the root URL has highest priority var rootNode = nodes.First(node => node.Url == baseUrl); - Assert.Equal(1.0M, rootNode.Priority); - Assert.Equal(ChangeFrequency.Daily, rootNode.ChangeFrequency); + await Assert.That(rootNode.Priority).IsEqualTo(1.0M); + await Assert.That(rootNode.ChangeFrequency).IsEqualTo(ChangeFrequency.Daily); } - [Fact] - public void GenerateSitemapXml_IncludesSiteMappingsMarkedForXml() + [Test] + public async Task GenerateSitemapXml_IncludesSiteMappingsMarkedForXml() { // Arrange var tempDir = new DirectoryInfo(Path.GetTempPath()); @@ -145,13 +146,13 @@ public void GenerateSitemapXml_IncludesSiteMappingsMarkedForXml() var allUrls = nodes.Select(n => n.Url).ToList(); - Assert.Contains(allUrls, url => url.Contains("test-page-1")); - Assert.DoesNotContain(allUrls, url => url.Contains("test-page-2")); // Not marked for XML - Assert.Contains(allUrls, url => url.Contains("test-page-3")); + await Assert.That(allUrls).Contains(url => url.Contains("test-page-1")); + await Assert.That(allUrls).DoesNotContain(url => url.Contains("test-page-2")); // Not marked for XML + await Assert.That(allUrls).Contains(url => url.Contains("test-page-3")); } - [Fact] - public void GenerateSitemapXml_DoesNotIncludeIndexRoutes() + [Test] + public async Task GenerateSitemapXml_DoesNotIncludeIndexRoutes() { // Arrange var tempDir = new DirectoryInfo(Path.GetTempPath()); @@ -170,11 +171,11 @@ public void GenerateSitemapXml_DoesNotIncludeIndexRoutes() var allUrls = nodes.Select(n => n.Url).ToList(); // Should not include Index action routes (they're the default) - Assert.DoesNotContain(allUrls, url => url.Contains("/Index", StringComparison.OrdinalIgnoreCase)); + await Assert.That(allUrls).DoesNotContain(url => url.Contains("/Index", StringComparison.OrdinalIgnoreCase)); } - [Fact] - public void GenerateSitemapXml_DoesNotIncludeErrorRoutes() + [Test] + public async Task GenerateSitemapXml_DoesNotIncludeErrorRoutes() { // Arrange var tempDir = new DirectoryInfo(Path.GetTempPath()); @@ -193,11 +194,11 @@ public void GenerateSitemapXml_DoesNotIncludeErrorRoutes() var allUrls = nodes.Select(n => n.Url).ToList(); // Should not include Error action routes - Assert.DoesNotContain(allUrls, url => url.Contains("/Error", StringComparison.OrdinalIgnoreCase)); + await Assert.That(allUrls).DoesNotContain(url => url.Contains("/Error", StringComparison.OrdinalIgnoreCase)); } - [Fact] - public void GenerateSitemapXml_UsesLastModifiedDateFromSiteMapping() + [Test] + public async Task GenerateSitemapXml_UsesLastModifiedDateFromSiteMapping() { // Arrange var tempDir = new DirectoryInfo(Path.GetTempPath()); @@ -220,7 +221,7 @@ public void GenerateSitemapXml_UsesLastModifiedDateFromSiteMapping() // Assert var siteMappingNode = nodes.First(node => node.Url.Contains("test-page-1")); - Assert.Equal(specificLastModified, siteMappingNode.LastModificationDate); + await Assert.That(siteMappingNode.LastModificationDate).IsEqualTo(specificLastModified); } private static SiteMapping CreateSiteMapping( @@ -246,4 +247,4 @@ private static SiteMapping CreateSiteMapping( lastModified: lastModified ); } -} +} \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/StringExtensionsTests.cs b/EssentialCSharp.Web.Tests/StringExtensionsTests.cs index 50116ab3..cc348d0d 100644 --- a/EssentialCSharp.Web.Tests/StringExtensionsTests.cs +++ b/EssentialCSharp.Web.Tests/StringExtensionsTests.cs @@ -1,34 +1,36 @@ -namespace EssentialCSharp.Web.Extensions.Tests; +using System.Threading.Tasks; + +namespace EssentialCSharp.Web.Extensions.Tests; public class StringExtensionsTests { - [Theory] - [InlineData(" ExtraSpacing ", "extraspacing")] - [InlineData("Hello World", "hello-world")] - [InlineData("Coding the Publish–Subscribe Pattern with Multicast Delegates", "coding-the-publish-subscribe-pattern-with-multicast-delegates")] - [InlineData("C#", "c")] - [InlineData("C# Syntax Fundamentals", "c-syntax-fundamentals")] - [InlineData("C#_Syntax_Fundamentals", "c-syntax-fundamentals")] - [InlineData("C# Syntax_Fundamentals-for-me", "c-syntax-fundamentals-for-me")] - [InlineData("Bitwise Operators (<<, >>, |, &, ^, ~)", "bitwise-operators")] - [InlineData(".NET Standard", "net-standard")] - [InlineData("Working with System.Threading", "working-with-system-threading")] - public void SanitizeStringToOnlyHaveDashesAndLowerCase(string actual, string sanitized) + [Test] + [Arguments(" ExtraSpacing ", "extraspacing")] + [Arguments("Hello World", "hello-world")] + [Arguments("Coding the Publish–Subscribe Pattern with Multicast Delegates", "coding-the-publish-subscribe-pattern-with-multicast-delegates")] + [Arguments("C#", "c")] + [Arguments("C# Syntax Fundamentals", "c-syntax-fundamentals")] + [Arguments("C#_Syntax_Fundamentals", "c-syntax-fundamentals")] + [Arguments("C# Syntax_Fundamentals-for-me", "c-syntax-fundamentals-for-me")] + [Arguments("Bitwise Operators (<<, >>, |, &, ^, ~)", "bitwise-operators")] + [Arguments(".NET Standard", "net-standard")] + [Arguments("Working with System.Threading", "working-with-system-threading")] + public async Task SanitizeStringToOnlyHaveDashesAndLowerCase(string actual, string sanitized) { - Assert.Equal(sanitized, actual.Sanitize()); - Assert.Equal(sanitized, actual.Sanitize().Sanitize()); + await Assert.That(actual.Sanitize()).IsEqualTo(sanitized); + await Assert.That(actual.Sanitize().Sanitize()).IsEqualTo(sanitized); } - [Theory] - [InlineData("hello-world#hello-world", "hello-world")] - [InlineData("C#Syntax#hello-world", "csyntax")] - [InlineData("C#Syntax", "csyntax")] - [InlineData("cSyntax", "csyntax")] - [InlineData(".NET", "net")] - [InlineData("System.Threading", "system-threading")] - public void GetPotentialMatches(string actual, string match) + [Test] + [Arguments("hello-world#hello-world", "hello-world")] + [Arguments("C#Syntax#hello-world", "csyntax")] + [Arguments("C#Syntax", "csyntax")] + [Arguments("cSyntax", "csyntax")] + [Arguments(".NET", "net")] + [Arguments("System.Threading", "system-threading")] + public async Task GetPotentialMatches(string actual, string match) { var matches = actual.GetPotentialMatches().ToList(); - Assert.Contains(match, matches); + await Assert.That(matches).Contains(match); } -} +} \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/Usings.cs b/EssentialCSharp.Web.Tests/Usings.cs index 8c927eb7..e69de29b 100644 --- a/EssentialCSharp.Web.Tests/Usings.cs +++ b/EssentialCSharp.Web.Tests/Usings.cs @@ -1 +0,0 @@ -global using Xunit; \ No newline at end of file diff --git a/global.json b/global.json index d50dabe4..7e5a4f3c 100644 --- a/global.json +++ b/global.json @@ -2,5 +2,8 @@ "sdk": { "version": "10.0.103", "rollForward": "latestMinor" + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } \ No newline at end of file From e6e87f0870ad7df8091819dd106c11fc8e4b742d Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Tue, 24 Feb 2026 22:33:10 -0800 Subject: [PATCH 2/4] fix: Use proper TUnit exception assertion idioms - Use ThrowsNothing() instead of bare method call in DoesNotThrow test - Use WithMessageContaining() chained on Throws<>() instead of capturing exception variable and checking .Message with Contains() - For multiple message checks, use .And.HasMessageContaining() --- .../SitemapXmlHelpersTests.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs index 0f167973..9603fed5 100644 --- a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs +++ b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs @@ -31,8 +31,8 @@ public async Task EnsureSitemapHealthy_WithValidSiteMappings_DoesNotThrow() CreateSiteMapping(2, 1, true) }; - // Act & Assert - if it throws, the test will fail - SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings); + // Act & Assert + await Assert.That(() => SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)).ThrowsNothing(); } [Test] @@ -46,10 +46,10 @@ public async Task EnsureSitemapHealthy_WithMultipleCanonicalLinksForSamePage_Thr }; // Act & Assert - var exception = await Assert.That(() => SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)).Throws(); - - await Assert.That(exception!.Message).Contains("Chapter 1, Page 1"); - await Assert.That(exception.Message).Contains("more than one canonical link"); + await Assert.That(() => SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)) + .Throws() + .WithMessageContaining("Chapter 1, Page 1") + .And.HasMessageContaining("more than one canonical link"); } [Test] @@ -63,9 +63,9 @@ public async Task EnsureSitemapHealthy_WithNoCanonicalLinksForPage_ThrowsExcepti }; // Act & Assert - var exception = await Assert.That(() => SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)).Throws(); - - await Assert.That(exception!.Message).Contains("Chapter 1, Page 1"); + await Assert.That(() => SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)) + .Throws() + .WithMessageContaining("Chapter 1, Page 1"); } [Test] From 2fcc896e6616316b2ac303af09372a6d07b4df77 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 25 Feb 2026 08:52:00 -0800 Subject: [PATCH 3/4] Update --- .github/workflows/PR-Build-And-Test.yml | 2 +- .../MarkdownChunkingServiceTests.cs | 42 ++++++++++++++----- EssentialCSharp.Web.Tests/FunctionalTests.cs | 10 +---- .../Integration/CaptchaTests.cs | 10 +++-- .../ListingSourceCodeControllerTests.cs | 22 +++++----- .../ListingSourceCodeServiceTests.cs | 14 ++++--- .../RouteConfigurationServiceTests.cs | 1 - EssentialCSharp.Web.Tests/SiteMappingTests.cs | 1 - .../SitemapXmlHelpersTests.cs | 11 ++--- .../StringExtensionsTests.cs | 4 +- 10 files changed, 67 insertions(+), 50 deletions(-) diff --git a/.github/workflows/PR-Build-And-Test.yml b/.github/workflows/PR-Build-And-Test.yml index c20a3e50..93507127 100644 --- a/.github/workflows/PR-Build-And-Test.yml +++ b/.github/workflows/PR-Build-And-Test.yml @@ -35,7 +35,7 @@ jobs: run: dotnet build --configuration Release --no-restore /p:AccessToNugetFeed=false - name: Run .NET Tests - run: dotnet test --no-build --configuration Release -- --report-trx --results-directory ${{ runner.temp }} + run: dotnet test --no-build --configuration Release --report-trx --results-directory ${{ runner.temp }} - name: Convert TRX to VS Playlist if: failure() diff --git a/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs b/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs index 98f74a74..35aa978f 100644 --- a/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs +++ b/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs @@ -44,10 +44,18 @@ publicstaticvoid Main() // Method declaration var sections = MarkdownChunkingService.MarkdownContentToHeadersAndSection(markdown); await Assert.That(sections.Count).IsEqualTo(3); - 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")); - 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")); - 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`")); + var beginnerSection = sections.FirstOrDefault(s => s.Header == "Beginner Topic: What Is a Method?"); + await Assert.That(beginnerSection.Header).IsNotNull(); + await Assert.That(string.Join("\n", beginnerSection.Content)).Contains("Syntactically, a **method** in C# is a named block of code"); + + var mainMethodSection = sections.FirstOrDefault(s => s.Header == "Main Method"); + await Assert.That(mainMethodSection.Header).IsNotNull(); + await Assert.That(string.Join("\n", mainMethodSection.Content)).Contains("The location where C# programs begin execution is the **Main method**, which begins with `static void Main()`"); + await Assert.That(string.Join("\n", mainMethodSection.Content)).Contains("publicclass Program"); + + var advancedTopicSection = sections.FirstOrDefault(s => s.Header == "Main Method: Advanced Topic: Declaration of the Main Method"); + await Assert.That(advancedTopicSection.Header).IsNotNull(); + await Assert.That(string.Join("\n", advancedTopicSection.Content)).Contains("C# requires that the Main method return either `void` or `int`"); } [Test] @@ -89,7 +97,7 @@ publicstaticvoid Main() await Assert.That(sections.Count).IsEqualTo(2); // The code listing should be appended to the Working with Variables section, not as its own section var workingWithVariablesSection = sections.FirstOrDefault(s => s.Header == "Working with Variables"); - await Assert.That(!string.IsNullOrEmpty(workingWithVariablesSection.Header)).IsTrue(); + await Assert.That(workingWithVariablesSection.Header).IsNotNull().And.IsNotEmpty(); await Assert.That(string.Join("\n", workingWithVariablesSection.Content)).Contains("publicclass MiracleMax"); await Assert.That(sections).DoesNotContain(s => s.Header == "Listing 1.12: Declaring and Assigning a Variable"); } @@ -145,11 +153,25 @@ publicstaticvoid Main() var sections = MarkdownChunkingService.MarkdownContentToHeadersAndSection(markdown); await Assert.That(sections.Count).IsEqualTo(5); - 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**")); - 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")); - 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")); - 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.")); - 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")); + var beginnerDataTypeSection = sections.FirstOrDefault(s => s.Header == "Beginner Topic: What Is a Data Type?"); + await Assert.That(beginnerDataTypeSection.Header).IsNotNull(); + await Assert.That(string.Join("\n", beginnerDataTypeSection.Content)).Contains("The type of data that a variable declaration specifies is called a **data type**"); + + var declaringSection = sections.FirstOrDefault(s => s.Header == "Declaring a Variable"); + await Assert.That(declaringSection.Header).IsNotNull(); + await Assert.That(string.Join("\n", declaringSection.Content)).Contains("In Listing 1.12, `string max` is a variable declaration"); + + var declaringAnotherSection = sections.FirstOrDefault(s => s.Header == "Declaring a Variable: Declaring another thing"); + await Assert.That(declaringAnotherSection.Header).IsNotNull(); + await Assert.That(string.Join("\n", declaringAnotherSection.Content)).Contains("Because a multivariable declaration statement allows developers to provide the data type only once"); + + var assigningSection = sections.FirstOrDefault(s => s.Header == "Assigning a Variable"); + await Assert.That(assigningSection.Header).IsNotNull(); + await Assert.That(string.Join("\n", assigningSection.Content)).Contains("After declaring a local variable, you must assign it a value before reading from it."); + + var continuedLearningSection = sections.FirstOrDefault(s => s.Header == "Assigning a Variable: Continued Learning"); + await Assert.That(continuedLearningSection.Header).IsNotNull(); + await Assert.That(string.Join("\n", continuedLearningSection.Content)).Contains("From this listing, observe that it is possible to assign a variable as part of the variable declaration"); } #endregion MarkdownContentToHeadersAndSection diff --git a/EssentialCSharp.Web.Tests/FunctionalTests.cs b/EssentialCSharp.Web.Tests/FunctionalTests.cs index 7eb65f4c..87175874 100644 --- a/EssentialCSharp.Web.Tests/FunctionalTests.cs +++ b/EssentialCSharp.Web.Tests/FunctionalTests.cs @@ -1,9 +1,9 @@ using System.Net; -using System.Threading.Tasks; namespace EssentialCSharp.Web.Tests; -public class FunctionalTests +[ClassDataSource(Shared = SharedType.PerClass)] +public class FunctionalTests(WebApplicationFactory factory) { [Test] [Arguments("/")] @@ -13,8 +13,6 @@ public class FunctionalTests [Arguments("/healthz")] public async Task WhenTheApplicationStarts_ItCanLoadLoadPages(string relativeUrl) { - using WebApplicationFactory factory = new(); - HttpClient client = factory.CreateClient(); using HttpResponseMessage response = await client.GetAsync(relativeUrl); @@ -31,8 +29,6 @@ public async Task WhenTheApplicationStarts_ItCanLoadLoadPages(string relativeUrl [Arguments("/about?someOtherParam=value")] public async Task WhenPagesAreAccessed_TheyReturnHtml(string relativeUrl) { - using WebApplicationFactory factory = new(); - HttpClient client = factory.CreateClient(); using HttpResponseMessage response = await client.GetAsync(relativeUrl); @@ -49,8 +45,6 @@ public async Task WhenPagesAreAccessed_TheyReturnHtml(string relativeUrl) [Test] public async Task WhenTheApplicationStarts_NonExistingPage_GivesCorrectStatusCode() { - using WebApplicationFactory factory = new(); - HttpClient client = factory.CreateClient(); using HttpResponseMessage response = await client.GetAsync("/non-existing-page1234"); diff --git a/EssentialCSharp.Web.Tests/Integration/CaptchaTests.cs b/EssentialCSharp.Web.Tests/Integration/CaptchaTests.cs index f83a0e5b..e8483a49 100644 --- a/EssentialCSharp.Web.Tests/Integration/CaptchaTests.cs +++ b/EssentialCSharp.Web.Tests/Integration/CaptchaTests.cs @@ -1,8 +1,7 @@ using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; namespace EssentialCSharp.Web.Extensions.Tests.Integration; @@ -25,7 +24,7 @@ public async Task CaptchaService_Verify_Success() } } -public class CaptchaServiceProvider +public class CaptchaServiceProvider : IDisposable { public ServiceProvider ServiceProvider { get; } = CreateServiceProvider(); public static ServiceProvider CreateServiceProvider() @@ -41,4 +40,9 @@ public static ServiceProvider CreateServiceProvider() return services.BuildServiceProvider(); } + public void Dispose() + { + ServiceProvider.Dispose(); + GC.SuppressFinalize(this); + } } \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs index bf0ce5b9..48490503 100644 --- a/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs @@ -1,17 +1,16 @@ using System.Net; using System.Net.Http.Json; using EssentialCSharp.Web.Models; -using System.Threading.Tasks; namespace EssentialCSharp.Web.Tests; -public class ListingSourceCodeControllerTests +[ClassDataSource(Shared = SharedType.PerClass)] +public class ListingSourceCodeControllerTests(WebApplicationFactory factory) { [Test] public async Task GetListing_WithValidChapterAndListing_Returns200WithContent() { // Arrange - using WebApplicationFactory factory = new(); HttpClient client = factory.CreateClient(); // Act @@ -22,10 +21,13 @@ public async Task GetListing_WithValidChapterAndListing_Returns200WithContent() ListingSourceCodeResponse? result = await response.Content.ReadFromJsonAsync(); await Assert.That(result).IsNotNull(); - await Assert.That(result.ChapterNumber).IsEqualTo(1); - await Assert.That(result.ListingNumber).IsEqualTo(1); - await Assert.That(result.FileExtension).IsNotEmpty(); - await Assert.That(result.Content).IsNotEmpty(); + using (Assert.Multiple()) + { + await Assert.That(result.ChapterNumber).IsEqualTo(1); + await Assert.That(result.ListingNumber).IsEqualTo(1); + await Assert.That(result.FileExtension).IsNotEmpty(); + await Assert.That(result.Content).IsNotEmpty(); + } } @@ -33,7 +35,6 @@ public async Task GetListing_WithValidChapterAndListing_Returns200WithContent() public async Task GetListing_WithInvalidChapter_Returns404() { // Arrange - using WebApplicationFactory factory = new(); HttpClient client = factory.CreateClient(); // Act @@ -47,7 +48,6 @@ public async Task GetListing_WithInvalidChapter_Returns404() public async Task GetListing_WithInvalidListing_Returns404() { // Arrange - using WebApplicationFactory factory = new(); HttpClient client = factory.CreateClient(); // Act @@ -61,7 +61,6 @@ public async Task GetListing_WithInvalidListing_Returns404() public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings() { // Arrange - using WebApplicationFactory factory = new(); HttpClient client = factory.CreateClient(); // Act @@ -81,7 +80,7 @@ public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings( } // Verify results are ordered by listing number - await Assert.That(results).IsEquivalentTo(results.OrderBy(r => r.ListingNumber).ToList()); + await Assert.That(results).IsOrderedBy(r => r.ListingNumber); // Verify each listing has required properties foreach (var r in results) @@ -95,7 +94,6 @@ public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings( public async Task GetListingsByChapter_WithInvalidChapter_ReturnsEmptyList() { // Arrange - using WebApplicationFactory factory = new(); HttpClient client = factory.CreateClient(); // Act diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs index 544c450e..bb4bb4cf 100644 --- a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.FileProviders; using Moq; using Moq.AutoMock; -using System.Threading.Tasks; namespace EssentialCSharp.Web.Tests; @@ -21,10 +20,13 @@ public async Task GetListingAsync_WithValidChapterAndListing_ReturnsCorrectListi // Assert await Assert.That(result).IsNotNull(); - await Assert.That(result.ChapterNumber).IsEqualTo(1); - await Assert.That(result.ListingNumber).IsEqualTo(1); - await Assert.That(result.FileExtension).IsEqualTo("cs"); - await Assert.That(result.Content).IsNotEmpty(); + using (Assert.Multiple()) + { + await Assert.That(result.ChapterNumber).IsEqualTo(1); + await Assert.That(result.ListingNumber).IsEqualTo(1); + await Assert.That(result.FileExtension).IsEqualTo("cs"); + await Assert.That(result.Content).IsNotEmpty(); + } } [Test] @@ -86,7 +88,7 @@ public async Task GetListingsByChapterAsync_WithValidChapter_ReturnsAllListings( } // Verify results are ordered - await Assert.That(results).IsEquivalentTo(results.OrderBy(r => r.ListingNumber).ToList()); + await Assert.That(results).IsOrderedBy(r => r.ListingNumber); } [Test] diff --git a/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs index 2b1a1797..ed5866fc 100644 --- a/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs +++ b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs @@ -1,6 +1,5 @@ using EssentialCSharp.Web.Services; using Microsoft.Extensions.DependencyInjection; -using System.Threading.Tasks; namespace EssentialCSharp.Web.Tests; diff --git a/EssentialCSharp.Web.Tests/SiteMappingTests.cs b/EssentialCSharp.Web.Tests/SiteMappingTests.cs index 4d56e5bf..91b4e7d9 100644 --- a/EssentialCSharp.Web.Tests/SiteMappingTests.cs +++ b/EssentialCSharp.Web.Tests/SiteMappingTests.cs @@ -1,5 +1,4 @@ using EssentialCSharp.Web.Extensions; -using System.Threading.Tasks; namespace EssentialCSharp.Web.Tests; diff --git a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs index 9603fed5..ba38cba2 100644 --- a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs +++ b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs @@ -5,11 +5,9 @@ using EssentialCSharp.Web.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using System.Threading.Tasks; - namespace EssentialCSharp.Web.Tests; -[NotInParallel] +[NotInParallel("SitemapTests")] [ClassDataSource(Shared = SharedType.PerClass)] public class SitemapXmlHelpersTests { @@ -117,8 +115,11 @@ public async Task GenerateSitemapXml_IncludesBaseUrl() // Verify the root URL has highest priority var rootNode = nodes.First(node => node.Url == baseUrl); - await Assert.That(rootNode.Priority).IsEqualTo(1.0M); - await Assert.That(rootNode.ChangeFrequency).IsEqualTo(ChangeFrequency.Daily); + using (Assert.Multiple()) + { + await Assert.That(rootNode.Priority).IsEqualTo(1.0M); + await Assert.That(rootNode.ChangeFrequency).IsEqualTo(ChangeFrequency.Daily); + } } [Test] diff --git a/EssentialCSharp.Web.Tests/StringExtensionsTests.cs b/EssentialCSharp.Web.Tests/StringExtensionsTests.cs index cc348d0d..254db0d2 100644 --- a/EssentialCSharp.Web.Tests/StringExtensionsTests.cs +++ b/EssentialCSharp.Web.Tests/StringExtensionsTests.cs @@ -1,6 +1,4 @@ -using System.Threading.Tasks; - -namespace EssentialCSharp.Web.Extensions.Tests; +namespace EssentialCSharp.Web.Extensions.Tests; public class StringExtensionsTests { From 798fa68f9c2169e1d53781fd00fc8abceea7b60a Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 25 Feb 2026 11:06:41 -0800 Subject: [PATCH 4/4] fix: Address TUnit migration issues from code review - Fix 8 NRE occurrences in MarkdownChunkingServiceTests.cs: replace FirstOrDefault + .Header access with Assert.That(sections).Contains(predicate) - Remove redundant string.Join assertion (line 208) in MarkdownChunkingServiceTests.cs - Remove #pragma CA1707 disable/restore from MarkdownChunkingServiceTests.cs - Add CA1707 suppression to .editorconfig for test projects (replaces csproj NoWarn) - Remove CA1707 from Web.Tests.csproj - Update TUnit 1.17.11 -> 1.17.20 in Directory.Packages.props - Add [NotInParallel('FunctionalTests')] to fix parallel SQLite transaction conflict: XUnit ran tests sequentially within a class; TUnit runs in parallel, causing multiple EF Core operations to call BeginTransaction on the same SQLite connection simultaneously - Add --coverage to CI dotnet test command (replaces removed coverlet.collector) - Add [Timeout(30_000)] with CancellationToken to CaptchaService_Verify_Success - Standardize StringComparison.OrdinalIgnoreCase in SitemapXmlHelpersTests URL predicates - Wrap foreach assertions with Assert.Multiple() in ListingSourceCode tests - Add IAsyncInitializer to WebApplicationFactory for eager server initialization --- .editorconfig | 5 +- .github/workflows/PR-Build-And-Test.yml | 2 +- Directory.Packages.props | 2 +- .../MarkdownChunkingServiceTests.cs | 48 +++++-------------- .../EssentialCSharp.Web.Tests.csproj | 4 -- EssentialCSharp.Web.Tests/FunctionalTests.cs | 1 + .../Integration/CaptchaTests.cs | 3 +- .../ListingSourceCodeControllerTests.cs | 12 +++-- .../ListingSourceCodeServiceTests.cs | 16 +++++-- .../SitemapXmlHelpersTests.cs | 6 +-- .../WebApplicationFactory.cs | 12 ++++- 11 files changed, 56 insertions(+), 55 deletions(-) diff --git a/.editorconfig b/.editorconfig index 8b6b66f0..90272961 100644 --- a/.editorconfig +++ b/.editorconfig @@ -156,4 +156,7 @@ csharp_style_expression_bodied_lambdas = true:silent csharp_style_expression_bodied_local_functions = false:silent # CA1848: Use the LoggerMessage delegates -dotnet_diagnostic.CA1848.severity = suggestion \ No newline at end of file +dotnet_diagnostic.CA1848.severity = suggestion +# Test files - allow underscore-separated test method names (CA1707) +[{EssentialCSharp.Web.Tests,EssentialCSharp.Chat.Tests}/**] +dotnet_diagnostic.CA1707.severity = none diff --git a/.github/workflows/PR-Build-And-Test.yml b/.github/workflows/PR-Build-And-Test.yml index 93507127..4117d976 100644 --- a/.github/workflows/PR-Build-And-Test.yml +++ b/.github/workflows/PR-Build-And-Test.yml @@ -35,7 +35,7 @@ jobs: run: dotnet build --configuration Release --no-restore /p:AccessToNugetFeed=false - name: Run .NET Tests - run: dotnet test --no-build --configuration Release --report-trx --results-directory ${{ runner.temp }} + run: dotnet test --no-build --configuration Release --report-trx --coverage --results-directory ${{ runner.temp }} - name: Convert TRX to VS Playlist if: failure() diff --git a/Directory.Packages.props b/Directory.Packages.props index d43565d5..5ae6f88a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,7 +21,7 @@ - + diff --git a/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs b/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs index 35aa978f..e14ef47e 100644 --- a/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs +++ b/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs @@ -2,8 +2,7 @@ using Moq; namespace EssentialCSharp.Chat.Tests; -// TODO: Move to editorconfig later, just moving quick -#pragma warning disable CA1707 // Identifiers should not contain underscores + public class MarkdownChunkingServiceTests { #region MarkdownContentToHeadersAndSection @@ -44,18 +43,12 @@ publicstaticvoid Main() // Method declaration var sections = MarkdownChunkingService.MarkdownContentToHeadersAndSection(markdown); await Assert.That(sections.Count).IsEqualTo(3); - var beginnerSection = sections.FirstOrDefault(s => s.Header == "Beginner Topic: What Is a Method?"); - await Assert.That(beginnerSection.Header).IsNotNull(); - await Assert.That(string.Join("\n", beginnerSection.Content)).Contains("Syntactically, a **method** in C# is a named block of code"); - - var mainMethodSection = sections.FirstOrDefault(s => s.Header == "Main Method"); - await Assert.That(mainMethodSection.Header).IsNotNull(); - await Assert.That(string.Join("\n", mainMethodSection.Content)).Contains("The location where C# programs begin execution is the **Main method**, which begins with `static void Main()`"); - await Assert.That(string.Join("\n", mainMethodSection.Content)).Contains("publicclass Program"); - - var advancedTopicSection = sections.FirstOrDefault(s => s.Header == "Main Method: Advanced Topic: Declaration of the Main Method"); - await Assert.That(advancedTopicSection.Header).IsNotNull(); - await Assert.That(string.Join("\n", advancedTopicSection.Content)).Contains("C# requires that the Main method return either `void` or `int`"); + 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")); + + 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()`")); + await Assert.That(sections).Contains(s => s.Header == "Main Method" && string.Join("\n", s.Content).Contains("publicclass Program")); + + 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`")); } [Test] @@ -96,9 +89,7 @@ publicstaticvoid Main() await Assert.That(sections.Count).IsEqualTo(2); // The code listing should be appended to the Working with Variables section, not as its own section - var workingWithVariablesSection = sections.FirstOrDefault(s => s.Header == "Working with Variables"); - await Assert.That(workingWithVariablesSection.Header).IsNotNull().And.IsNotEmpty(); - await Assert.That(string.Join("\n", workingWithVariablesSection.Content)).Contains("publicclass MiracleMax"); + await Assert.That(sections).Contains(s => s.Header == "Working with Variables" && string.Join("\n", s.Content).Contains("publicclass MiracleMax")); await Assert.That(sections).DoesNotContain(s => s.Header == "Listing 1.12: Declaring and Assigning a Variable"); } @@ -153,25 +144,15 @@ publicstaticvoid Main() var sections = MarkdownChunkingService.MarkdownContentToHeadersAndSection(markdown); await Assert.That(sections.Count).IsEqualTo(5); - var beginnerDataTypeSection = sections.FirstOrDefault(s => s.Header == "Beginner Topic: What Is a Data Type?"); - await Assert.That(beginnerDataTypeSection.Header).IsNotNull(); - await Assert.That(string.Join("\n", beginnerDataTypeSection.Content)).Contains("The type of data that a variable declaration specifies is called a **data type**"); + 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**")); - var declaringSection = sections.FirstOrDefault(s => s.Header == "Declaring a Variable"); - await Assert.That(declaringSection.Header).IsNotNull(); - await Assert.That(string.Join("\n", declaringSection.Content)).Contains("In Listing 1.12, `string max` is a variable declaration"); + 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")); - var declaringAnotherSection = sections.FirstOrDefault(s => s.Header == "Declaring a Variable: Declaring another thing"); - await Assert.That(declaringAnotherSection.Header).IsNotNull(); - await Assert.That(string.Join("\n", declaringAnotherSection.Content)).Contains("Because a multivariable declaration statement allows developers to provide the data type only once"); + 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")); - var assigningSection = sections.FirstOrDefault(s => s.Header == "Assigning a Variable"); - await Assert.That(assigningSection.Header).IsNotNull(); - await Assert.That(string.Join("\n", assigningSection.Content)).Contains("After declaring a local variable, you must assign it a value before reading from it."); + 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.")); - var continuedLearningSection = sections.FirstOrDefault(s => s.Header == "Assigning a Variable: Continued Learning"); - await Assert.That(continuedLearningSection.Header).IsNotNull(); - await Assert.That(string.Join("\n", continuedLearningSection.Content)).Contains("From this listing, observe that it is possible to assign a variable as part of the variable declaration"); + 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")); } #endregion MarkdownContentToHeadersAndSection @@ -205,10 +186,7 @@ public async Task ProcessSingleMarkdownFile_ProducesExpectedChunksAndHeaders() await Assert.That(result.FilePath).IsEqualTo(filePath); await Assert.That(string.Join("\n", result.Chunks)).Contains("This is the first section."); await Assert.That(string.Join("\n", result.Chunks)).Contains("Console.WriteLine(\"Hello World\");"); - await Assert.That(string.Join("\n", result.Chunks)).Contains("This is the second section."); await Assert.That(result.Chunks).Contains(c => c.Contains("This is the second section.")); } #endregion ProcessSingleMarkdownFile } - -#pragma warning restore CA1707 // Identifiers should not contain underscores diff --git a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj index a192c778..50848f91 100644 --- a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj +++ b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj @@ -5,10 +5,6 @@ false false - - $(NoWarn);CA1707 diff --git a/EssentialCSharp.Web.Tests/FunctionalTests.cs b/EssentialCSharp.Web.Tests/FunctionalTests.cs index 87175874..78edc27a 100644 --- a/EssentialCSharp.Web.Tests/FunctionalTests.cs +++ b/EssentialCSharp.Web.Tests/FunctionalTests.cs @@ -2,6 +2,7 @@ namespace EssentialCSharp.Web.Tests; +[NotInParallel("FunctionalTests")] [ClassDataSource(Shared = SharedType.PerClass)] public class FunctionalTests(WebApplicationFactory factory) { diff --git a/EssentialCSharp.Web.Tests/Integration/CaptchaTests.cs b/EssentialCSharp.Web.Tests/Integration/CaptchaTests.cs index e8483a49..02511ee1 100644 --- a/EssentialCSharp.Web.Tests/Integration/CaptchaTests.cs +++ b/EssentialCSharp.Web.Tests/Integration/CaptchaTests.cs @@ -9,7 +9,8 @@ namespace EssentialCSharp.Web.Extensions.Tests.Integration; public class CaptchaTests(CaptchaServiceProvider serviceProvider) { [Test] - public async Task CaptchaService_Verify_Success() + [Timeout(30_000)] + public async Task CaptchaService_Verify_Success(CancellationToken cancellationToken) { ICaptchaService captchaService = serviceProvider.ServiceProvider.GetRequiredService(); diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs index 48490503..6eda7819 100644 --- a/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs @@ -76,7 +76,10 @@ public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings( // Verify all results are from chapter 1 foreach (var r in results) { - await Assert.That(r.ChapterNumber).IsEqualTo(1); + using (Assert.Multiple()) + { + await Assert.That(r.ChapterNumber).IsEqualTo(1); + } } // Verify results are ordered by listing number @@ -85,8 +88,11 @@ public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings( // Verify each listing has required properties foreach (var r in results) { - await Assert.That(r.FileExtension).IsNotEmpty(); - await Assert.That(r.Content).IsNotEmpty(); + using (Assert.Multiple()) + { + await Assert.That(r.FileExtension).IsNotEmpty(); + await Assert.That(r.Content).IsNotEmpty(); + } } } diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs index bb4bb4cf..fd781e03 100644 --- a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs @@ -82,9 +82,12 @@ public async Task GetListingsByChapterAsync_WithValidChapter_ReturnsAllListings( await Assert.That(results).IsNotEmpty(); foreach (var r in results) { - await Assert.That(r.ChapterNumber).IsEqualTo(1); - await Assert.That(r.Content).IsNotEmpty(); - await Assert.That(r.FileExtension).IsNotEmpty(); + using (Assert.Multiple()) + { + await Assert.That(r.ChapterNumber).IsEqualTo(1); + await Assert.That(r.Content).IsNotEmpty(); + await Assert.That(r.FileExtension).IsNotEmpty(); + } } // Verify results are ordered @@ -106,8 +109,11 @@ public async Task GetListingsByChapterAsync_DirectoryContainsNonListingFiles_Exc // Ensure all results match the {CC}.{LL}.{ext} pattern foreach (var r in results) { - await Assert.That(r.ChapterNumber).IsEqualTo(10); - await Assert.That(r.ListingNumber).IsBetween(1, 99); + using (Assert.Multiple()) + { + await Assert.That(r.ChapterNumber).IsEqualTo(10); + await Assert.That(r.ListingNumber).IsBetween(1, 99); + } } } diff --git a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs index ba38cba2..ba9e029a 100644 --- a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs +++ b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs @@ -147,9 +147,9 @@ public async Task GenerateSitemapXml_IncludesSiteMappingsMarkedForXml() var allUrls = nodes.Select(n => n.Url).ToList(); - await Assert.That(allUrls).Contains(url => url.Contains("test-page-1")); - await Assert.That(allUrls).DoesNotContain(url => url.Contains("test-page-2")); // Not marked for XML - await Assert.That(allUrls).Contains(url => url.Contains("test-page-3")); + await Assert.That(allUrls).Contains(url => url.Contains("test-page-1", StringComparison.OrdinalIgnoreCase)); + await Assert.That(allUrls).DoesNotContain(url => url.Contains("test-page-2", StringComparison.OrdinalIgnoreCase)); // Not marked for XML + await Assert.That(allUrls).Contains(url => url.Contains("test-page-3", StringComparison.OrdinalIgnoreCase)); } [Test] diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index 07e60731..70bc647a 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -1,6 +1,7 @@ using System.Data.Common; using EssentialCSharp.Web.Data; using EssentialCSharp.Web.Services; +using TUnit.Core.Interfaces; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Data.Sqlite; @@ -13,8 +14,17 @@ namespace EssentialCSharp.Web.Tests; -public sealed class WebApplicationFactory : WebApplicationFactory +public sealed class WebApplicationFactory : WebApplicationFactory, IAsyncInitializer { + public Task InitializeAsync() + { + // Force eager server initialization before tests run. + // This is thread-safe and prevents race conditions from parallel tests + // calling CreateClient() concurrently during lazy init. + _ = Server; + return Task.CompletedTask; + } + private static string SqlConnectionString => $"DataSource=file:{Guid.NewGuid()}?mode=memory&cache=shared"; private SqliteConnection? _Connection;