diff --git a/aspnetcore/test/integration-tests.md b/aspnetcore/test/integration-tests.md index b4ffeb1bc0e7..dfcb93ff9821 100644 --- a/aspnetcore/test/integration-tests.md +++ b/aspnetcore/test/integration-tests.md @@ -7,6 +7,7 @@ ms.author: tdykstra ms.custom: mvc ms.date: 3/24/2025 uid: test/integration-tests +zone_pivot_groups: unit-testing-framework --- # Integration tests in ASP.NET Core @@ -63,7 +64,21 @@ The following test class, `BasicTests`, uses the `WebApplicationFactory` to boot creates an instance of `HttpClient` that automatically follows redirects and handles cookies. -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/10.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/BasicTests.cs?name=snippet1)] +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/BasicTests.cs" id="snippet1"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/BasicTests.cs" id="snippet1"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/BasicTests.cs" id="snippet1"::: + +:::zone-end By default, non-essential cookies aren't preserved across requests when the [General Data Protection Regulation consent policy](xref:security/gdpr) is enabled. To preserve non-essential cookies, such as those used by the TempData provider, mark them as essential in your tests. For instructions on marking a cookie as essential, see [Essential cookies](xref:security/gdpr#essential-cookies). @@ -79,7 +94,21 @@ Web host configuration can be created independently of the test classes by inher 1. Inherit from `WebApplicationFactory` and override . The allows the configuration of the service collection with [`IWebHostBuilder.ConfigureServices`](xref:Microsoft.AspNetCore.Hosting.IWebHostBuilder.ConfigureServices%2A) - [!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/10.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/CustomWebApplicationFactory.cs?name=snippet1)] + :::zone pivot="xunit" + + :::code language="csharp" source="~/test/integration-tests/snippets/xunit/CustomWebApplicationFactory.cs" id="snippet1"::: + + :::zone-end + :::zone pivot="mstest" + + :::code language="csharp" source="~/test/integration-tests/snippets/mstest/CustomWebApplicationFactory.cs" id="snippet1"::: + + :::zone-end + :::zone pivot="nunit" + + :::code language="csharp" source="~/test/integration-tests/snippets/nunit/CustomWebApplicationFactory.cs" id="snippet1"::: + + :::zone-end Database seeding in the [sample app](https://github.com/dotnet/AspNetCore.Docs.Samples/tree/main/test/integration-tests/10.x/IntegrationTestsSample) is performed by the `InitializeDbForTests` method. The method is described in the [Integration tests sample: Test app organization](#test-app-organization) section. @@ -96,13 +125,41 @@ Web host configuration can be created independently of the test classes by inher --> 2. Use the custom `CustomWebApplicationFactory` in test classes. The following example uses the factory in the `IndexPageTests` class: - [!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/10.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/IndexPageTests.cs?name=snippet1)] + :::zone pivot="xunit" + + :::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/IndexPageTests.cs" id="snippet1"::: + + :::zone-end + :::zone pivot="mstest" + + :::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/IndexPageTests.cs" id="snippet1"::: + + :::zone-end + :::zone pivot="nunit" + + :::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/IndexPageTests.cs" id="snippet1"::: + + :::zone-end The sample app's client is configured to prevent the `HttpClient` from following redirects. As explained later in the [Mock authentication](#mock-authentication) section, this permits tests to check the result of the app's first response. The first response is a redirect in many of these tests with a `Location` header. 3. A typical test uses the `HttpClient` and helper methods to process the request and the response: - [!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/10.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/IndexPageTests.cs?name=snippet2)] + :::zone pivot="xunit" + + :::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/IndexPageTests.cs" id="snippet2"::: + + :::zone-end + :::zone pivot="mstest" + + :::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/IndexPageTests.cs" id="snippet2"::: + + :::zone-end + :::zone pivot="nunit" + + :::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/IndexPageTests.cs" id="snippet2"::: + + :::zone-end Any POST request to the SUT must satisfy the antiforgery check that's automatically made by the app's [data protection antiforgery system](xref:security/data-protection/introduction). In order to arrange for a test's POST request, the test app must: @@ -135,7 +192,21 @@ The `Post_DeleteMessageHandler_ReturnsRedirectToRoot` test method of the [sample Because another test in the `IndexPageTests` class performs an operation that deletes all of the records in the database and may run before the `Post_DeleteMessageHandler_ReturnsRedirectToRoot` method, the database is reseeded in this test method to ensure that a record is present for the SUT to delete. Selecting the first delete button of the `messages` form in the SUT is simulated in the request to the SUT: -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/10.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/IndexPageTests.cs?name=snippet3)] +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/IndexPageTests.cs" id="snippet3"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/IndexPageTests.cs" id="snippet3"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/IndexPageTests.cs" id="snippet3"::: + +:::zone-end ## Client options @@ -143,9 +214,23 @@ See the method: -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/10.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/IndexPageTests.cs?name=snippet1)] +:::zone pivot="xunit" -***NOTE:*** To avoid HTTPS redirection warnings in logs when using HTTPS Redirection Middleware, set `BaseAddress = new Uri("https://localhost")` +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/IndexPageTests.cs" id="snippet1"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/IndexPageTests.cs" id="snippet1"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/IndexPageTests.cs" id="snippet1"::: + +:::zone-end + +**_NOTE:_** To avoid HTTPS redirection warnings in logs when using HTTPS Redirection Middleware, set `BaseAddress = new Uri("https://localhost")` ## Inject mock services @@ -188,11 +273,39 @@ To test the service and quote injection in an integration test, a mock service i `IntegrationTests.IndexPageTests.cs`: -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/10.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/IndexPageTests.cs?name=snippet4)] +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/IndexPageTests.cs" id="snippet4"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/IndexPageTests.cs" id="snippet4"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/IndexPageTests.cs" id="snippet4"::: + +:::zone-end `ConfigureTestServices` is called, and the scoped service is registered: -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/10.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/IndexPageTests.cs?name=snippet5&highlight=7-10,17,20-21)] +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/IndexPageTests.cs" id="snippet5" highlight="7-10,17,20-21"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/IndexPageTests.cs" id="snippet5" highlight="7-10,17,20-21"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/IndexPageTests.cs" id="snippet5" highlight="7-10,17,20-22"::: + +:::zone-end The markup produced during the test's execution reflects the quote text supplied by `TestQuoteService`, thus the assertion passes: @@ -214,7 +327,21 @@ In the SUT, the `/SecurePage` page uses an is set to disallow redirects by setting to `false`: -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/10.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/AuthTests.cs?name=snippet2)] +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/AuthTests.cs" id="snippet2"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/AuthTests.cs" id="snippet2"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/AuthTests.cs" id="snippet2"::: + +:::zone-end By disallowing the client to follow the redirect, the following checks can be made: @@ -223,11 +350,39 @@ By disallowing the client to follow the redirect, the following checks can be ma The test app can mock an in in order to test aspects of authentication and authorization. A minimal scenario returns an : -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/10.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/AuthTests.cs?name=snippet4&highlight=11-18)] +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/AuthTests.cs" id="snippet4" highlight="11-18"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/AuthTests.cs" id="snippet4" highlight="11-18"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/AuthTests.cs" id="snippet4" highlight="11-18"::: + +:::zone-end The `TestAuthHandler` is called to authenticate a user when the authentication scheme is set to `TestScheme` where `AddAuthentication` is registered for `ConfigureTestServices`. It's important for the `TestScheme` scheme to match the scheme your app expects. Otherwise, authentication won't work. -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/10.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/AuthTests.cs?name=snippet3&highlight=7-12)] +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/AuthTests.cs" id="snippet3" highlight="7-12"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/AuthTests.cs" id="snippet3" highlight="7-12"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/AuthTests.cs" id="snippet3" highlight="7-12"::: + +:::zone-end For more information on `WebApplicationFactoryClientOptions`, see the [Client options](#client-options) section. @@ -239,7 +394,21 @@ See [this GitHub repository](https://github.com/blowdart/idunno.Authentication/t Set the [environment](xref:fundamentals/environments) in the custom application factory: -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/10.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/CustomWebApplicationFactory.cs?name=snippet1&highlight=36)] +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/CustomWebApplicationFactory.cs" id="snippet1" highlight="36"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/CustomWebApplicationFactory.cs" id="snippet1" highlight="36"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/CustomWebApplicationFactory.cs" id="snippet1" highlight="36"::: + +:::zone-end ## How the test infrastructure infers the app content root path @@ -259,8 +428,22 @@ To disable shadow copying when using xUnit, create a `xunit.runner.json` file in ## Disposal of objects +:::zone pivot="xunit" + After the tests of the `IClassFixture` implementation are executed, and are disposed when xUnit disposes of the [`WebApplicationFactory`](xref:Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory%601). If objects instantiated by the developer require disposal, dispose of them in the `IClassFixture` implementation. For more information, see [Implementing a Dispose method](/dotnet/standard/garbage-collection/implementing-dispose). +:::zone-end +:::zone pivot="mstest" + +After the tests of the `TestClass` are executed, and are disposed when MSTest disposes of the [`WebApplicationFactory`](xref:Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory%601) in the `ClassCleanup` method. If objects instantiated by the developer require disposal, dispose of them in the `ClassCleanup` method. For more information, see [Implementing a Dispose method](/dotnet/standard/garbage-collection/implementing-dispose). + +:::zone-end +:::zone pivot="nunit" + +After the tests of the test class are executed, and are disposed when NUnit disposes of the [`WebApplicationFactory`](xref:Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory%601) in the `TearDown` method. If objects instantiated by the developer require disposal, dispose of them in the `TearDown` method. For more information, see [Implementing a Dispose method](/dotnet/standard/garbage-collection/implementing-dispose). + +:::zone-end + ## Integration tests sample The [sample app](https://github.com/dotnet/AspNetCore.Docs.Samples/tree/main/test/integration-tests/10.x/IntegrationTestsSample) is composed of two apps: @@ -325,4 +508,4 @@ The SUT's database context is registered in `Program.cs`. The test app's `builde [!INCLUDE[](~/test/integration-tests/includes/integration-tests5.md)] [!INCLUDE[](~/test/integration-tests/includes/integration-tests7.md)] [!INCLUDE[](~/test/integration-tests/includes/integration-tests8.md)] -[!INCLUDE[](~/test/integration-tests/includes/integration-tests9.md)] \ No newline at end of file +[!INCLUDE[](~/test/integration-tests/includes/integration-tests9.md)] diff --git a/aspnetcore/test/integration-tests/includes/integration-tests9.md b/aspnetcore/test/integration-tests/includes/integration-tests9.md index 59853681eb21..6e9bb363cf29 100644 --- a/aspnetcore/test/integration-tests/includes/integration-tests9.md +++ b/aspnetcore/test/integration-tests/includes/integration-tests9.md @@ -66,8 +66,22 @@ Test classes implement a *class fixture* interface ([`IClassFixture`](https://xu The following test class, `BasicTests`, uses the `WebApplicationFactory` to bootstrap the SUT and provide an to a test method, `Get_EndpointsReturnSuccessAndCorrectContentType`. The method verifies the response status code is successful (200-299) and the `Content-Type` header is `text/html; charset=utf-8` for several app pages. creates an instance of `HttpClient` that automatically follows redirects and handles cookies. - -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/9.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/BasicTests.cs?name=snippet1)] + +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/BasicTests.cs" id="snippet1"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/BasicTests.cs" id="snippet1"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/BasicTests.cs" id="snippet1"::: + +:::zone-end By default, non-essential cookies aren't preserved across requests when the [General Data Protection Regulation consent policy](xref:security/gdpr) is enabled. To preserve non-essential cookies, such as those used by the TempData provider, mark them as essential in your tests. For instructions on marking a cookie as essential, see [Essential cookies](xref:security/gdpr#essential-cookies). @@ -83,7 +97,21 @@ Web host configuration can be created independently of the test classes by inher 1. Inherit from `WebApplicationFactory` and override . The allows the configuration of the service collection with [`IWebHostBuilder.ConfigureServices`](xref:Microsoft.AspNetCore.Hosting.IWebHostBuilder.ConfigureServices%2A) - [!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/9.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/CustomWebApplicationFactory.cs?name=snippet1)] + :::zone pivot="xunit" + + :::code language="csharp" source="~/test/integration-tests/snippets/xunit/CustomWebApplicationFactory.cs" id="snippet1"::: + + :::zone-end + :::zone pivot="mstest" + + :::code language="csharp" source="~/test/integration-tests/snippets/mstest/CustomWebApplicationFactory.cs" id="snippet1"::: + + :::zone-end + :::zone pivot="nunit" + + :::code language="csharp" source="~/test/integration-tests/snippets/nunit/CustomWebApplicationFactory.cs" id="snippet1"::: + + :::zone-end Database seeding in the [sample app](https://github.com/dotnet/AspNetCore.Docs.Samples/tree/main/test/integration-tests/9.x/IntegrationTestsSample) is performed by the `InitializeDbForTests` method. The method is described in the [Integration tests sample: Test app organization](#test-app-organization) section. @@ -100,13 +128,41 @@ Web host configuration can be created independently of the test classes by inher --> 2. Use the custom `CustomWebApplicationFactory` in test classes. The following example uses the factory in the `IndexPageTests` class: - [!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/9.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/IndexPageTests.cs?name=snippet1)] + :::zone pivot="xunit" + + :::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/IndexPageTests.cs" id="snippet1"::: + + :::zone-end + :::zone pivot="mstest" + + :::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/IndexPageTests.cs" id="snippet1"::: + + :::zone-end + :::zone pivot="nunit" + + :::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/IndexPageTests.cs" id="snippet1"::: + + :::zone-end The sample app's client is configured to prevent the `HttpClient` from following redirects. As explained later in the [Mock authentication](#mock-authentication) section, this permits tests to check the result of the app's first response. The first response is a redirect in many of these tests with a `Location` header. 3. A typical test uses the `HttpClient` and helper methods to process the request and the response: - [!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/9.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/IndexPageTests.cs?name=snippet2)] + :::zone pivot="xunit" + + :::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/IndexPageTests.cs" id="snippet2"::: + + :::zone-end + :::zone pivot="mstest" + + :::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/IndexPageTests.cs" id="snippet2"::: + + :::zone-end + :::zone pivot="nunit" + + :::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/IndexPageTests.cs" id="snippet2"::: + + :::zone-end Any POST request to the SUT must satisfy the antiforgery check that's automatically made by the app's [data protection antiforgery system](xref:security/data-protection/introduction). In order to arrange for a test's POST request, the test app must: @@ -139,7 +195,21 @@ The `Post_DeleteMessageHandler_ReturnsRedirectToRoot` test method of the [sample Because another test in the `IndexPageTests` class performs an operation that deletes all of the records in the database and may run before the `Post_DeleteMessageHandler_ReturnsRedirectToRoot` method, the database is reseeded in this test method to ensure that a record is present for the SUT to delete. Selecting the first delete button of the `messages` form in the SUT is simulated in the request to the SUT: -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/9.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/IndexPageTests.cs?name=snippet3)] +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/IndexPageTests.cs" id="snippet3"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/IndexPageTests.cs" id="snippet3"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/IndexPageTests.cs" id="snippet3"::: + +:::zone-end ## Client options @@ -147,7 +217,21 @@ See the method: -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/9.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/IndexPageTests.cs?name=snippet1)] +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/IndexPageTests.cs" id="snippet1"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/IndexPageTests.cs" id="snippet1"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/IndexPageTests.cs" id="snippet1"::: + +:::zone-end ***NOTE:*** To avoid HTTPS redirection warnings in logs when using HTTPS Redirection Middleware, set `BaseAddress = new Uri("https://localhost")` @@ -192,11 +276,39 @@ To test the service and quote injection in an integration test, a mock service i `IntegrationTests.IndexPageTests.cs`: -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/9.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/IndexPageTests.cs?name=snippet4)] +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/IndexPageTests.cs" id="snippet4"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/IndexPageTests.cs" id="snippet4"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/IndexPageTests.cs" id="snippet4"::: + +:::zone-end `ConfigureTestServices` is called, and the scoped service is registered: -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/9.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/IndexPageTests.cs?name=snippet5&highlight=7-10,17,20-21)] +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/IndexPageTests.cs" id="snippet5" highlight="7-10,17,20-21"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/IndexPageTests.cs" id="snippet5" highlight="7-10,17,20-21"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/IndexPageTests.cs" id="snippet5" highlight="7-10,17,20-22"::: + +:::zone-end The markup produced during the test's execution reflects the quote text supplied by `TestQuoteService`, thus the assertion passes: @@ -218,7 +330,21 @@ In the SUT, the `/SecurePage` page uses an is set to disallow redirects by setting to `false`: -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/9.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/AuthTests.cs?name=snippet2)] +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/AuthTests.cs" id="snippet2"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/AuthTests.cs" id="snippet2"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/AuthTests.cs" id="snippet2"::: + +:::zone-end By disallowing the client to follow the redirect, the following checks can be made: @@ -227,11 +353,39 @@ By disallowing the client to follow the redirect, the following checks can be ma The test app can mock an in in order to test aspects of authentication and authorization. A minimal scenario returns an : -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/9.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/AuthTests.cs?name=snippet4&highlight=11-18)] +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/AuthTests.cs" id="snippet4" highlight="11-18"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/AuthTests.cs" id="snippet4" highlight="11-18"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/AuthTests.cs" id="snippet4" highlight="11-18"::: + +:::zone-end The `TestAuthHandler` is called to authenticate a user when the authentication scheme is set to `TestScheme` where `AddAuthentication` is registered for `ConfigureTestServices`. It's important for the `TestScheme` scheme to match the scheme your app expects. Otherwise, authentication won't work. -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/9.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/IntegrationTests/AuthTests.cs?name=snippet3&highlight=7-12)] +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/IntegrationTests/AuthTests.cs" id="snippet3" highlight="7-12"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/IntegrationTests/AuthTests.cs" id="snippet3" highlight="7-12"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/IntegrationTests/AuthTests.cs" id="snippet3" highlight="7-12"::: + +:::zone-end For more information on `WebApplicationFactoryClientOptions`, see the [Client options](#client-options) section. @@ -243,7 +397,21 @@ See [this GitHub repository](https://github.com/blowdart/idunno.Authentication/t Set the [environment](xref:fundamentals/environments) in the custom application factory: -[!code-csharp[](~/../AspNetCore.Docs.Samples/test/integration-tests/9.x/IntegrationTestsSample/tests/RazorPagesProject.Tests/CustomWebApplicationFactory.cs?name=snippet1&highlight=36)] +:::zone pivot="xunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/xunit/CustomWebApplicationFactory.cs" id="snippet1" highlight="36"::: + +:::zone-end +:::zone pivot="mstest" + +:::code language="csharp" source="~/test/integration-tests/snippets/mstest/CustomWebApplicationFactory.cs" id="snippet1" highlight="36"::: + +:::zone-end +:::zone pivot="nunit" + +:::code language="csharp" source="~/test/integration-tests/snippets/nunit/CustomWebApplicationFactory.cs" id="snippet1" highlight="36"::: + +:::zone-end ## How the test infrastructure infers the app content root path @@ -263,8 +431,22 @@ To disable shadow copying when using xUnit, create a `xunit.runner.json` file in ## Disposal of objects +:::zone pivot="xunit" + After the tests of the `IClassFixture` implementation are executed, and are disposed when xUnit disposes of the [`WebApplicationFactory`](xref:Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory%601). If objects instantiated by the developer require disposal, dispose of them in the `IClassFixture` implementation. For more information, see [Implementing a Dispose method](/dotnet/standard/garbage-collection/implementing-dispose). +:::zone-end +:::zone pivot="mstest" + +After the tests of the `TestClass` are executed, and are disposed when MSTest disposes of the [`WebApplicationFactory`](xref:Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory%601) in the `ClassCleanup` method. If objects instantiated by the developer require disposal, dispose of them in the `ClassCleanup` method. For more information, see [Implementing a Dispose method](/dotnet/standard/garbage-collection/implementing-dispose). + +:::zone-end +:::zone pivot="nunit" + +After the tests of the test class are executed, and are disposed when NUnit disposes of the [`WebApplicationFactory`](xref:Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory%601) in the `TearDown` method. If objects instantiated by the developer require disposal, dispose of them in the `TearDown` method. For more information, see [Implementing a Dispose method](/dotnet/standard/garbage-collection/implementing-dispose). + +:::zone-end + ## Integration tests sample The [sample app](https://github.com/dotnet/AspNetCore.Docs.Samples/tree/main/test/integration-tests/9.x/IntegrationTestsSample) is composed of two apps: @@ -324,4 +506,4 @@ The SUT's database context is registered in `Program.cs`. The test app's `builde * * [Basic tests for authentication middleware](https://github.com/blowdart/idunno.Authentication/tree/dev/test/idunno.Authentication.Basic.Test) -:::moniker-end \ No newline at end of file +:::moniker-end diff --git a/aspnetcore/test/integration-tests/snippets/mstest/CustomWebApplicationFactory.cs b/aspnetcore/test/integration-tests/snippets/mstest/CustomWebApplicationFactory.cs new file mode 100644 index 000000000000..57738c369ce4 --- /dev/null +++ b/aspnetcore/test/integration-tests/snippets/mstest/CustomWebApplicationFactory.cs @@ -0,0 +1,51 @@ +using System.Data.Common; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using RazorPagesProject.Data; + +namespace RazorPagesProject.Tests; + +// +public class CustomWebApplicationFactory + : WebApplicationFactory where TProgram : class +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + var dbContextDescriptor = services.SingleOrDefault( + d => d.ServiceType == + typeof(IDbContextOptionsConfiguration)); + + services.Remove(dbContextDescriptor); + + var dbConnectionDescriptor = services.SingleOrDefault( + d => d.ServiceType == + typeof(DbConnection)); + + services.Remove(dbConnectionDescriptor); + + // Create open SqliteConnection so EF won't automatically close it. + services.AddSingleton(container => + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + return connection; + }); + + services.AddDbContext((container, options) => + { + var connection = container.GetRequiredService(); + options.UseSqlite(connection); + }); + }); + + builder.UseEnvironment("Development"); + } +} +// diff --git a/aspnetcore/test/integration-tests/snippets/mstest/IntegrationTests/AuthTests.cs b/aspnetcore/test/integration-tests/snippets/mstest/IntegrationTests/AuthTests.cs new file mode 100644 index 000000000000..54d18c487640 --- /dev/null +++ b/aspnetcore/test/integration-tests/snippets/mstest/IntegrationTests/AuthTests.cs @@ -0,0 +1,158 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Encodings.Web; +using AngleSharp.Html.Dom; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RazorPagesProject.Services; +using RazorPagesProject.Tests.Helpers; + +namespace RazorPagesProject.Tests.IntegrationTests; + +[TestClass] +public class AuthTests +{ + + private static CustomWebApplicationFactory _factory; + + [ClassInitialize] + public static void AssemblyInitialize(TestContext _) + { + _factory = new CustomWebApplicationFactory(); + } + + [ClassCleanup(ClassCleanupBehavior.EndOfClass)] + public static void AssemblyCleanup(TestContext _) + { + _factory.Dispose(); + } + + // + [TestMethod] + public async Task Get_GithubProfilePageCanGetAGithubUser() + { + // Arrange + void ConfigureTestServices(IServiceCollection services) => + services.AddSingleton(new TestGithubClient()); + var client = _factory + .WithWebHostBuilder(builder => + builder.ConfigureTestServices(ConfigureTestServices)) + .CreateClient(); + + // Act + var profile = await client.GetAsync("/GithubProfile"); + Assert.AreEqual(HttpStatusCode.OK, profile.StatusCode); + var profileHtml = await HtmlHelpers.GetDocumentAsync(profile); + + var profileWithUserName = await client.SendAsync( + (IHtmlFormElement)profileHtml.QuerySelector("#user-profile"), + new Dictionary { ["Input_UserName"] = "user" }); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, profileWithUserName.StatusCode); + var profileWithUserHtml = + await HtmlHelpers.GetDocumentAsync(profileWithUserName); + var userLogin = profileWithUserHtml.QuerySelector("#user-login"); + Assert.AreEqual("user", userLogin.TextContent); + } + + public class TestGithubClient : IGithubClient + { + public Task GetUserAsync(string userName) + { + if (userName == "user") + { + return Task.FromResult( + new GithubUser + { + Login = "user", + Company = "Contoso Blockchain", + Name = "John Doe" + }); + } + else + { + return Task.FromResult(null); + } + } + } + // + + // + [TestMethod] + public async Task Get_SecurePageRedirectsAnUnauthenticatedUser() + { + // Arrange + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + // Act + var response = await client.GetAsync("/SecurePage"); + + // Assert + Assert.AreEqual(HttpStatusCode.Redirect, response.StatusCode); + StringAssert.StartsWith(response.Headers.Location.OriginalString, "http://localhost/Identity/Account/Login"); + } + // + + // + [TestMethod] + public async Task Get_SecurePageIsReturnedForAnAuthenticatedUser() + { + // Arrange + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddAuthentication(defaultScheme: "TestScheme") + .AddScheme( + "TestScheme", options => { }); + }); + }) + .CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + }); + + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue(scheme: "TestScheme"); + + //Act + var response = await client.GetAsync("/SecurePage"); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + } + // +} + +// +public class TestAuthHandler : AuthenticationHandler +{ + public TestAuthHandler(IOptionsMonitor options, + ILoggerFactory logger, UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + var claims = new[] { new Claim(ClaimTypes.Name, "Test user") }; + var identity = new ClaimsIdentity(claims, "Test"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "TestScheme"); + + var result = AuthenticateResult.Success(ticket); + + return Task.FromResult(result); + } +} +// diff --git a/aspnetcore/test/integration-tests/snippets/mstest/IntegrationTests/BasicTests.cs b/aspnetcore/test/integration-tests/snippets/mstest/IntegrationTests/BasicTests.cs new file mode 100644 index 000000000000..481b6110dddb --- /dev/null +++ b/aspnetcore/test/integration-tests/snippets/mstest/IntegrationTests/BasicTests.cs @@ -0,0 +1,41 @@ +namespace RazorPagesProject.Tests.IntegrationTests; + +// +[TestClass] +public class BasicTests +{ + private static CustomWebApplicationFactory _factory; + + [ClassInitialize] + public static void AssemblyInitialize(TestContext _) + { + _factory = new CustomWebApplicationFactory(); + } + + [ClassCleanup(ClassCleanupBehavior.EndOfClass)] + public static void AssemblyCleanup(TestContext _) + { + _factory.Dispose(); + } + + [TestMethod] + [DataRow("/")] + [DataRow("/Index")] + [DataRow("/About")] + [DataRow("/Privacy")] + [DataRow("/Contact")] + public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url) + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync(url); + + // Assert + response.EnsureSuccessStatusCode(); // Status Code 200-299 + Assert.AreEqual("text/html; charset=utf-8", + response.Content.Headers.ContentType.ToString()); + } +} +// diff --git a/aspnetcore/test/integration-tests/snippets/mstest/IntegrationTests/IndexPageTests.cs b/aspnetcore/test/integration-tests/snippets/mstest/IntegrationTests/IndexPageTests.cs new file mode 100644 index 000000000000..8d9f4ab53ec0 --- /dev/null +++ b/aspnetcore/test/integration-tests/snippets/mstest/IntegrationTests/IndexPageTests.cs @@ -0,0 +1,191 @@ +using System.Net; +using AngleSharp.Html.Dom; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using RazorPagesProject.Data; +using RazorPagesProject.Services; +using RazorPagesProject.Tests.Helpers; + +namespace RazorPagesProject.Tests.IntegrationTests; + +// +[TestClass] +public class IndexPageTests +{ + private static HttpClient _client; + private static CustomWebApplicationFactory + _factory; + + [ClassInitialize] + public static void AssemblyInitialize(TestContext _) + { + _factory = new CustomWebApplicationFactory(); + _client = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [ClassCleanup(ClassCleanupBehavior.EndOfClass)] + public static void AssemblyCleanup(TestContext _) + { + _factory.Dispose(); + } + // + + // + [TestMethod] + public async Task Post_DeleteAllMessagesHandler_ReturnsRedirectToRoot() + { + // Arrange + var defaultPage = await _client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + + //Act + var response = await _client.SendAsync( + (IHtmlFormElement)content.QuerySelector("form[id='messages']"), + (IHtmlButtonElement)content.QuerySelector("button[id='deleteAllBtn']")); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, defaultPage.StatusCode); + Assert.AreEqual(HttpStatusCode.Redirect, response.StatusCode); + Assert.AreEqual("/", response.Headers.Location.OriginalString); + } + // + + // + [TestMethod] + public async Task Post_DeleteMessageHandler_ReturnsRedirectToRoot() + { + // Arrange + using (var scope = _factory.Services.CreateScope()) + { + var scopedServices = scope.ServiceProvider; + var db = scopedServices.GetRequiredService(); + + Utilities.ReinitializeDbForTests(db); + } + + var defaultPage = await _client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + + //Act + var response = await _client.SendAsync( + (IHtmlFormElement)content.QuerySelector("form[id='messages']"), + (IHtmlButtonElement)content.QuerySelector("form[id='messages']") + .QuerySelector("div[class='panel-body']") + .QuerySelector("button")); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, defaultPage.StatusCode); + Assert.AreEqual(HttpStatusCode.Redirect, response.StatusCode); + Assert.AreEqual("/", response.Headers.Location.OriginalString); + } + // + + [TestMethod] + public async Task Post_AddMessageHandler_ReturnsSuccess_WhenMissingMessageText() + { + // Arrange + var defaultPage = await _client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + var messageText = string.Empty; + + // Act + var response = await _client.SendAsync( + (IHtmlFormElement)content.QuerySelector("form[id='addMessage']"), + (IHtmlButtonElement)content.QuerySelector("button[id='addMessageBtn']"), + new Dictionary + { + ["Message.Text"] = messageText + }); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, defaultPage.StatusCode); + // A ModelState failure returns to Page (200-OK) and doesn't redirect. + response.EnsureSuccessStatusCode(); + Assert.IsNull(response.Headers.Location?.OriginalString); + } + + [TestMethod] + public async Task Post_AddMessageHandler_ReturnsSuccess_WhenMessageTextTooLong() + { + // Arrange + var defaultPage = await _client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + var messageText = new string('X', 201); + + // Act + var response = await _client.SendAsync( + (IHtmlFormElement)content.QuerySelector("form[id='addMessage']"), + (IHtmlButtonElement)content.QuerySelector("button[id='addMessageBtn']"), + new Dictionary + { + ["Message.Text"] = messageText + }); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, defaultPage.StatusCode); + // A ModelState failure returns to Page (200-OK) and doesn't redirect. + response.EnsureSuccessStatusCode(); + Assert.IsNull(response.Headers.Location?.OriginalString); + } + + [TestMethod] + public async Task Post_AnalyzeMessagesHandler_ReturnsRedirectToRoot() + { + // Arrange + var defaultPage = await _client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + + //Act + var response = await _client.SendAsync( + (IHtmlFormElement)content.QuerySelector("form[id='analyze']"), + (IHtmlButtonElement)content.QuerySelector("button[id='analyzeBtn']")); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, defaultPage.StatusCode); + Assert.AreEqual(HttpStatusCode.Redirect, response.StatusCode); + Assert.AreEqual("/", response.Headers.Location.OriginalString); + } + + // + // Quote ©1975 BBC: The Doctor (Tom Baker); Pyramids of Mars + // https://www.bbc.co.uk/programmes/p00pys55 + public class TestQuoteService : IQuoteService + { + public Task GenerateQuote() + { + return Task.FromResult( + "Something's interfering with time, Mr. Scarman, " + + "and time is my business."); + } + } + // + + // + [TestMethod] + public async Task Get_QuoteService_ProvidesQuoteInPage() + { + // Arrange + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddScoped(); + }); + }) + .CreateClient(); + + //Act + var defaultPage = await client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + var quoteElement = content.QuerySelector("#quote"); + + // Assert + Assert.AreEqual("Something's interfering with time, Mr. Scarman, " + + "and time is my business.", quoteElement.Attributes["value"].Value); + } + // +} diff --git a/aspnetcore/test/integration-tests/snippets/nunit/CustomWebApplicationFactory.cs b/aspnetcore/test/integration-tests/snippets/nunit/CustomWebApplicationFactory.cs new file mode 100644 index 000000000000..720b8a7a60b1 --- /dev/null +++ b/aspnetcore/test/integration-tests/snippets/nunit/CustomWebApplicationFactory.cs @@ -0,0 +1,49 @@ +using System.Data.Common; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using RazorPagesProject.Data; + +namespace RazorPagesProject.Tests; + +// +public class CustomWebApplicationFactory + : WebApplicationFactory where TProgram : class +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + var dbContextDescriptor = services.SingleOrDefault( + d => d.ServiceType == + typeof(IDbContextOptionsConfiguration)); + + services.Remove(dbContextDescriptor); + + var dbConnectionDescriptor = services.SingleOrDefault( + d => d.ServiceType == + typeof(DbConnection)); + + services.Remove(dbConnectionDescriptor); + + // Create open SqliteConnection so EF won't automatically close it. + services.AddSingleton(container => + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + return connection; + }); + + services.AddDbContext((container, options) => + { + var connection = container.GetRequiredService(); + options.UseSqlite(connection); + }); + }); + + builder.UseEnvironment("Development"); + } +} +// diff --git a/aspnetcore/test/integration-tests/snippets/nunit/IntegrationTests/AuthTests.cs b/aspnetcore/test/integration-tests/snippets/nunit/IntegrationTests/AuthTests.cs new file mode 100644 index 000000000000..6e374e63af14 --- /dev/null +++ b/aspnetcore/test/integration-tests/snippets/nunit/IntegrationTests/AuthTests.cs @@ -0,0 +1,156 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Encodings.Web; +using AngleSharp.Html.Dom; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Options; +using RazorPagesProject.Services; +using RazorPagesProject.Tests.Helpers; + +namespace RazorPagesProject.Tests.IntegrationTests; + +[TestFixture] +public class AuthTests +{ + private CustomWebApplicationFactory + _factory; + + [SetUp] + public void SetUp() + { + _factory = new CustomWebApplicationFactory(); + } + + [TearDown] + public void TearDown() + { + _factory.Dispose(); + } + + // + [Test] + public async Task Get_GithubProfilePageCanGetAGithubUser() + { + // Arrange + void ConfigureTestServices(IServiceCollection services) => + services.AddSingleton(new TestGithubClient()); + var client = _factory + .WithWebHostBuilder(builder => + builder.ConfigureTestServices(ConfigureTestServices)) + .CreateClient(); + + // Act + var profile = await client.GetAsync("/GithubProfile"); + Assert.That(profile.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + var profileHtml = await HtmlHelpers.GetDocumentAsync(profile); + + var profileWithUserName = await client.SendAsync( + (IHtmlFormElement)profileHtml.QuerySelector("#user-profile"), + new Dictionary { ["Input_UserName"] = "user" }); + + // Assert + Assert.That(profileWithUserName.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + var profileWithUserHtml = + await HtmlHelpers.GetDocumentAsync(profileWithUserName); + var userLogin = profileWithUserHtml.QuerySelector("#user-login"); + Assert.That(userLogin.TextContent, Is.EqualTo("user")); + } + + public class TestGithubClient : IGithubClient + { + public Task GetUserAsync(string userName) + { + if (userName == "user") + { + return Task.FromResult( + new GithubUser + { + Login = "user", + Company = "Contoso Blockchain", + Name = "John Doe" + }); + } + else + { + return Task.FromResult(null); + } + } + } + // + + // + [Test] + public async Task Get_SecurePageRedirectsAnUnauthenticatedUser() + { + // Arrange + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + // Act + var response = await client.GetAsync("/SecurePage"); + + // Assert + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Redirect)); + Assert.That(response.Headers.Location.OriginalString, Does.StartWith("http://localhost/Identity/Account/Login")); + } + // + + // + [Test] + public async Task Get_SecurePageIsReturnedForAnAuthenticatedUser() + { + // Arrange + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddAuthentication(defaultScheme: "TestScheme") + .AddScheme( + "TestScheme", options => { }); + }); + }) + .CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + }); + + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue(scheme: "TestScheme"); + + //Act + var response = await client.GetAsync("/SecurePage"); + + // Assert + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + // +} + +// +public class TestAuthHandler : AuthenticationHandler +{ + public TestAuthHandler(IOptionsMonitor options, + ILoggerFactory logger, UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + var claims = new[] { new Claim(ClaimTypes.Name, "Test user") }; + var identity = new ClaimsIdentity(claims, "Test"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "TestScheme"); + + var result = AuthenticateResult.Success(ticket); + + return Task.FromResult(result); + } +} +// diff --git a/aspnetcore/test/integration-tests/snippets/nunit/IntegrationTests/BasicTests.cs b/aspnetcore/test/integration-tests/snippets/nunit/IntegrationTests/BasicTests.cs new file mode 100644 index 000000000000..9e82ba66c951 --- /dev/null +++ b/aspnetcore/test/integration-tests/snippets/nunit/IntegrationTests/BasicTests.cs @@ -0,0 +1,38 @@ +namespace RazorPagesProject.Tests.IntegrationTests; + +// +public class BasicTests +{ + private CustomWebApplicationFactory + _factory; + + [SetUp] + public void SetUp() + { + _factory = new CustomWebApplicationFactory(); + } + + [TearDown] + public void TearDown() + { + _factory.Dispose(); + } + + [DatapointSource] + public string[] values = ["/", "/Index", "/About", "/Privacy", "/Contact"]; + + [Theory] + public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url) + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync(url); + + // Assert + response.EnsureSuccessStatusCode(); // Status Code 200-299 + Assert.That(response.Content.Headers.ContentType.ToString(), Is.EqualTo("text/html; charset=utf-8")); + } +} +// diff --git a/aspnetcore/test/integration-tests/snippets/nunit/IntegrationTests/IndexPageTests.cs b/aspnetcore/test/integration-tests/snippets/nunit/IntegrationTests/IndexPageTests.cs new file mode 100644 index 000000000000..ff19cec8b28a --- /dev/null +++ b/aspnetcore/test/integration-tests/snippets/nunit/IntegrationTests/IndexPageTests.cs @@ -0,0 +1,192 @@ +using System.Net; +using AngleSharp.Html.Dom; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using RazorPagesProject.Data; +using RazorPagesProject.Services; +using RazorPagesProject.Tests.Helpers; + +namespace RazorPagesProject.Tests.IntegrationTests; + +// +public class IndexPageTests +{ + + private HttpClient _client; + private CustomWebApplicationFactory + _factory; + + [SetUp] + public void SetUp() + { + _factory = new CustomWebApplicationFactory(); + _client = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [TearDown] + public void TearDown() + { + _factory.Dispose(); + _client.Dispose(); + } + // + + // + [Test] + public async Task Post_DeleteAllMessagesHandler_ReturnsRedirectToRoot() + { + // Arrange + var defaultPage = await _client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + + //Act + var response = await _client.SendAsync( + (IHtmlFormElement)content.QuerySelector("form[id='messages']"), + (IHtmlButtonElement)content.QuerySelector("button[id='deleteAllBtn']")); + + // Assert + Assert.That(defaultPage.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Redirect)); + Assert.That(response.Headers.Location.OriginalString, Is.EqualTo("/")); + } + // + + // + [Test] + public async Task Post_DeleteMessageHandler_ReturnsRedirectToRoot() + { + // Arrange + using (var scope = _factory.Services.CreateScope()) + { + var scopedServices = scope.ServiceProvider; + var db = scopedServices.GetRequiredService(); + + Utilities.ReinitializeDbForTests(db); + } + + var defaultPage = await _client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + + //Act + var response = await _client.SendAsync( + (IHtmlFormElement)content.QuerySelector("form[id='messages']"), + (IHtmlButtonElement)content.QuerySelector("form[id='messages']") + .QuerySelector("div[class='panel-body']") + .QuerySelector("button")); + + // Assert + Assert.That(defaultPage.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Redirect)); + Assert.That(response.Headers.Location.OriginalString, Is.EqualTo("/")); + } + // + + [Test] + public async Task Post_AddMessageHandler_ReturnsSuccess_WhenMissingMessageText() + { + // Arrange + var defaultPage = await _client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + var messageText = string.Empty; + + // Act + var response = await _client.SendAsync( + (IHtmlFormElement)content.QuerySelector("form[id='addMessage']"), + (IHtmlButtonElement)content.QuerySelector("button[id='addMessageBtn']"), + new Dictionary + { + ["Message.Text"] = messageText + }); + + // Assert + Assert.That(defaultPage.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + // A ModelState failure returns to Page (200-OK) and doesn't redirect. + Assert.That(response.IsSuccessStatusCode, Is.True); + Assert.That(response.Headers.Location?.OriginalString, Is.Null); + } + + [Test] + public async Task Post_AddMessageHandler_ReturnsSuccess_WhenMessageTextTooLong() + { + // Arrange + var defaultPage = await _client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + var messageText = new string('X', 201); + + // Act + var response = await _client.SendAsync( + (IHtmlFormElement)content.QuerySelector("form[id='addMessage']"), + (IHtmlButtonElement)content.QuerySelector("button[id='addMessageBtn']"), + new Dictionary + { + ["Message.Text"] = messageText + }); + + // Assert + Assert.That(defaultPage.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + // A ModelState failure returns to Page (200-OK) and doesn't redirect. + Assert.That(response.IsSuccessStatusCode, Is.True); + Assert.That(response.Headers.Location?.OriginalString, Is.Null); + } + + [Test] + public async Task Post_AnalyzeMessagesHandler_ReturnsRedirectToRoot() + { + // Arrange + var defaultPage = await _client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + + //Act + var response = await _client.SendAsync( + (IHtmlFormElement)content.QuerySelector("form[id='analyze']"), + (IHtmlButtonElement)content.QuerySelector("button[id='analyzeBtn']")); + + // Assert + Assert.That(defaultPage.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Redirect)); + Assert.That(response.Headers.Location.OriginalString, Is.EqualTo("/")); + } + + // + // Quote ©1975 BBC: The Doctor (Tom Baker); Pyramids of Mars + // https://www.bbc.co.uk/programmes/p00pys55 + public class TestQuoteService : IQuoteService + { + public Task GenerateQuote() + { + return Task.FromResult( + "Something's interfering with time, Mr. Scarman, " + + "and time is my business."); + } + } + // + + // + [Test] + public async Task Get_QuoteService_ProvidesQuoteInPage() + { + // Arrange + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddScoped(); + }); + }) + .CreateClient(); + + //Act + var defaultPage = await client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + var quoteElement = content.QuerySelector("#quote"); + + // Assert + Assert.That(quoteElement.Attributes["value"].Value, Is.EqualTo( + "Something's interfering with time, Mr. Scarman, " + + "and time is my business.")); + } + // +} diff --git a/aspnetcore/test/integration-tests/snippets/xunit/CustomWebApplicationFactory.cs b/aspnetcore/test/integration-tests/snippets/xunit/CustomWebApplicationFactory.cs new file mode 100644 index 000000000000..0faad8f8f33d --- /dev/null +++ b/aspnetcore/test/integration-tests/snippets/xunit/CustomWebApplicationFactory.cs @@ -0,0 +1,49 @@ +using System.Data.Common; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using RazorPagesProject.Data; + +namespace RazorPagesProject.Tests; + +// +public class CustomWebApplicationFactory + : WebApplicationFactory where TProgram : class +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + var dbContextDescriptor = services.SingleOrDefault( + d => d.ServiceType == + typeof(IDbContextOptionsConfiguration)); + + services.Remove(dbContextDescriptor); + + var dbConnectionDescriptor = services.SingleOrDefault( + d => d.ServiceType == + typeof(DbConnection)); + + services.Remove(dbConnectionDescriptor); + + // Create open SqliteConnection so EF won't automatically close it. + services.AddSingleton(container => + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + return connection; + }); + + services.AddDbContext((container, options) => + { + var connection = container.GetRequiredService(); + options.UseSqlite(connection); + }); + }); + + builder.UseEnvironment("Development"); + } +} +// diff --git a/aspnetcore/test/integration-tests/snippets/xunit/IntegrationTests/AuthTests.cs b/aspnetcore/test/integration-tests/snippets/xunit/IntegrationTests/AuthTests.cs new file mode 100644 index 000000000000..0c3ae7d3fa4d --- /dev/null +++ b/aspnetcore/test/integration-tests/snippets/xunit/IntegrationTests/AuthTests.cs @@ -0,0 +1,152 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Encodings.Web; +using AngleSharp.Html.Dom; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Options; +using RazorPagesProject.Services; +using RazorPagesProject.Tests.Helpers; +using Xunit; + +namespace RazorPagesProject.Tests.IntegrationTests; + +public class AuthTests : + IClassFixture> +{ + private readonly CustomWebApplicationFactory + _factory; + + public AuthTests( + CustomWebApplicationFactory factory) + { + _factory = factory; + } + +// + [Fact] + public async Task Get_GithubProfilePageCanGetAGithubUser() + { + // Arrange + void ConfigureTestServices(IServiceCollection services) => + services.AddSingleton(new TestGithubClient()); + var client = _factory + .WithWebHostBuilder(builder => + builder.ConfigureTestServices(ConfigureTestServices)) + .CreateClient(); + + // Act + var profile = await client.GetAsync("/GithubProfile"); + Assert.Equal(HttpStatusCode.OK, profile.StatusCode); + var profileHtml = await HtmlHelpers.GetDocumentAsync(profile); + + var profileWithUserName = await client.SendAsync( + (IHtmlFormElement)profileHtml.QuerySelector("#user-profile"), + new Dictionary { ["Input_UserName"] = "user" }); + + // Assert + Assert.Equal(HttpStatusCode.OK, profileWithUserName.StatusCode); + var profileWithUserHtml = + await HtmlHelpers.GetDocumentAsync(profileWithUserName); + var userLogin = profileWithUserHtml.QuerySelector("#user-login"); + Assert.Equal("user", userLogin.TextContent); + } + + public class TestGithubClient : IGithubClient + { + public Task GetUserAsync(string userName) + { + if (userName == "user") + { + return Task.FromResult( + new GithubUser + { + Login = "user", + Company = "Contoso Blockchain", + Name = "John Doe" + }); + } + else + { + return Task.FromResult(null); + } + } + } +// + +// + [Fact] + public async Task Get_SecurePageRedirectsAnUnauthenticatedUser() + { + // Arrange + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + // Act + var response = await client.GetAsync("/SecurePage"); + + // Assert + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.StartsWith("http://localhost/Identity/Account/Login", + response.Headers.Location.OriginalString); + } +// + +// + [Fact] + public async Task Get_SecurePageIsReturnedForAnAuthenticatedUser() + { + // Arrange + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddAuthentication(defaultScheme: "TestScheme") + .AddScheme( + "TestScheme", options => { }); + }); + }) + .CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + }); + + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue(scheme: "TestScheme"); + + //Act + var response = await client.GetAsync("/SecurePage"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +// +} + +// +public class TestAuthHandler : AuthenticationHandler +{ + public TestAuthHandler(IOptionsMonitor options, + ILoggerFactory logger, UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + var claims = new[] { new Claim(ClaimTypes.Name, "Test user") }; + var identity = new ClaimsIdentity(claims, "Test"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "TestScheme"); + + var result = AuthenticateResult.Success(ticket); + + return Task.FromResult(result); + } +} +// diff --git a/aspnetcore/test/integration-tests/snippets/xunit/IntegrationTests/BasicTests.cs b/aspnetcore/test/integration-tests/snippets/xunit/IntegrationTests/BasicTests.cs new file mode 100644 index 000000000000..50594787f8d2 --- /dev/null +++ b/aspnetcore/test/integration-tests/snippets/xunit/IntegrationTests/BasicTests.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace RazorPagesProject.Tests.IntegrationTests; + +// +public class BasicTests + : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + public BasicTests(WebApplicationFactory factory) + { + _factory = factory; + } + + [Theory] + [InlineData("/")] + [InlineData("/Index")] + [InlineData("/About")] + [InlineData("/Privacy")] + [InlineData("/Contact")] + public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url) + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync(url); + + // Assert + response.EnsureSuccessStatusCode(); // Status Code 200-299 + Assert.Equal("text/html; charset=utf-8", + response.Content.Headers.ContentType.ToString()); + } +} +// diff --git a/aspnetcore/test/integration-tests/snippets/xunit/IntegrationTests/IndexPageTests.cs b/aspnetcore/test/integration-tests/snippets/xunit/IntegrationTests/IndexPageTests.cs new file mode 100644 index 000000000000..7a1f7fd1b535 --- /dev/null +++ b/aspnetcore/test/integration-tests/snippets/xunit/IntegrationTests/IndexPageTests.cs @@ -0,0 +1,185 @@ +using System.Net; +using AngleSharp.Html.Dom; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using RazorPagesProject.Data; +using RazorPagesProject.Services; +using RazorPagesProject.Tests.Helpers; +using Xunit; + +namespace RazorPagesProject.Tests.IntegrationTests; + +// +public class IndexPageTests : + IClassFixture> +{ + private readonly HttpClient _client; + private readonly CustomWebApplicationFactory + _factory; + + public IndexPageTests( + CustomWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } +// + +// + [Fact] + public async Task Post_DeleteAllMessagesHandler_ReturnsRedirectToRoot() + { + // Arrange + var defaultPage = await _client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + + //Act + var response = await _client.SendAsync( + (IHtmlFormElement)content.QuerySelector("form[id='messages']"), + (IHtmlButtonElement)content.QuerySelector("button[id='deleteAllBtn']")); + + // Assert + Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode); + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/", response.Headers.Location.OriginalString); + } +// + +// + [Fact] + public async Task Post_DeleteMessageHandler_ReturnsRedirectToRoot() + { + // Arrange + using (var scope = _factory.Services.CreateScope()) + { + var scopedServices = scope.ServiceProvider; + var db = scopedServices.GetRequiredService(); + + Utilities.ReinitializeDbForTests(db); + } + + var defaultPage = await _client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + + //Act + var response = await _client.SendAsync( + (IHtmlFormElement)content.QuerySelector("form[id='messages']"), + (IHtmlButtonElement)content.QuerySelector("form[id='messages']") + .QuerySelector("div[class='panel-body']") + .QuerySelector("button")); + + // Assert + Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode); + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/", response.Headers.Location.OriginalString); + } +// + + [Fact] + public async Task Post_AddMessageHandler_ReturnsSuccess_WhenMissingMessageText() + { + // Arrange + var defaultPage = await _client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + var messageText = string.Empty; + + // Act + var response = await _client.SendAsync( + (IHtmlFormElement)content.QuerySelector("form[id='addMessage']"), + (IHtmlButtonElement)content.QuerySelector("button[id='addMessageBtn']"), + new Dictionary + { + ["Message.Text"] = messageText + }); + + // Assert + Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode); + // A ModelState failure returns to Page (200-OK) and doesn't redirect. + response.EnsureSuccessStatusCode(); + Assert.Null(response.Headers.Location?.OriginalString); + } + + [Fact] + public async Task Post_AddMessageHandler_ReturnsSuccess_WhenMessageTextTooLong() + { + // Arrange + var defaultPage = await _client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + var messageText = new string('X', 201); + + // Act + var response = await _client.SendAsync( + (IHtmlFormElement)content.QuerySelector("form[id='addMessage']"), + (IHtmlButtonElement)content.QuerySelector("button[id='addMessageBtn']"), + new Dictionary + { + ["Message.Text"] = messageText + }); + + // Assert + Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode); + // A ModelState failure returns to Page (200-OK) and doesn't redirect. + response.EnsureSuccessStatusCode(); + Assert.Null(response.Headers.Location?.OriginalString); + } + + [Fact] + public async Task Post_AnalyzeMessagesHandler_ReturnsRedirectToRoot() + { + // Arrange + var defaultPage = await _client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + + //Act + var response = await _client.SendAsync( + (IHtmlFormElement)content.QuerySelector("form[id='analyze']"), + (IHtmlButtonElement)content.QuerySelector("button[id='analyzeBtn']")); + + // Assert + Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode); + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/", response.Headers.Location.OriginalString); + } + +// + // Quote ©1975 BBC: The Doctor (Tom Baker); Pyramids of Mars + // https://www.bbc.co.uk/programmes/p00pys55 + public class TestQuoteService : IQuoteService + { + public Task GenerateQuote() + { + return Task.FromResult( + "Something's interfering with time, Mr. Scarman, " + + "and time is my business."); + } + } +// + +// + [Fact] + public async Task Get_QuoteService_ProvidesQuoteInPage() + { + // Arrange + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddScoped(); + }); + }) + .CreateClient(); + + //Act + var defaultPage = await client.GetAsync("/"); + var content = await HtmlHelpers.GetDocumentAsync(defaultPage); + var quoteElement = content.QuerySelector("#quote"); + + // Assert + Assert.Equal("Something's interfering with time, Mr. Scarman, " + + "and time is my business.", quoteElement.Attributes["value"].Value); + } +// +} diff --git a/aspnetcore/zone-pivot-groups.yml b/aspnetcore/zone-pivot-groups.yml index dbe928426833..88d0a36b0be4 100644 --- a/aspnetcore/zone-pivot-groups.yml +++ b/aspnetcore/zone-pivot-groups.yml @@ -108,3 +108,13 @@ groups: title: Non-BFF pattern - id: bff-pattern title: BFF pattern +- id: unit-testing-framework + title: Unit testing framework + prompt: Choose a unit testing framework + pivots: + - id: xunit + title: xUnit + - id: mstest + title: MSTest + - id: nunit + title: NUnit