Appearance
Are you an LLM? You can read better optimized documentation at /testing/unit_tests.md for this page in Markdown format
Unit tests
This document provides a comprehensive guide to the unit testing strategy for TheExampleApp. It covers the frameworks, patterns, and procedures for writing, running, and maintaining high-quality unit tests. A robust unit testing suite is essential for ensuring code correctness, preventing regressions, and enabling safe refactoring.
For information on other forms of testing, please see the documentation for Integration Tests and E2E Tests.
Testing framework
The unit testing suite is built on a standard set of tools within the .NET Core ecosystem, ensuring broad compatibility and a familiar developer experience.
The test project, TheExampleApp.Tests, is a .NET Core 2.1 class library that references the main TheExampleApp project. The core testing framework is xUnit.net (v2.3.1), a popular, modern framework for C#.
Key components defined in TheExampleApp.Tests.csproj include:
Microsoft.NET.Test.Sdk: Provides the core infrastructure and entry point for the test execution engine.xunit: The core xUnit.net library containing attributes like[Fact]and[Theory]as well as assertion APIs (e.g.,Assert.Equal).xunit.runner.visualstudio: An adapter that allows tests to be discovered and executed directly within Visual Studio's Test Explorer.
Tests are organized into classes, and individual test methods are decorated with the [Fact] attribute. The standard Arrange-Act-Assert pattern is used to structure tests, ensuring they are clear, concise, and easy to understand.
csharp
// Example of a basic test structure from CoursesModelTests.cs
[Fact]
public async Task CoursesModelShouldLoadAllCoursesWhenNoCategoryIsSelected()
{
// Arrange: Set up mock objects and test data.
var courses = new ContentfulCollection<Course>();
// ... more setup ...
var client = new Mock<IContentfulClient>();
client.Setup(/* ... */).Returns(Task.FromResult(courses));
var model = new CoursesModel(client.Object, /* ... */);
// Act: Execute the method under test.
await model.OnGet(null);
// Assert: Verify the outcome.
Assert.Equal(courses, model.Courses);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Mocking with Moq
To achieve true unit testing, components must be tested in isolation from their dependencies. This application uses Moq (v4.7.145) as its mocking framework to create test doubles (mocks, stubs, fakes) for dependencies. This is particularly important for isolating our code from external services like the Contentful API and from ASP.NET Core framework components.
Effective mocking is tightly coupled with the application's use of Dependency Injection. By injecting interfaces instead of concrete classes, we can easily substitute a mock implementation during testing.
Common Mocking Patterns
1. Mocking an Interface and Setting Up a Method Return Value: This is the most common pattern, used here to simulate a response from the IContentfulClient.
csharp
// From: TheExampleApp.Tests/Pages/CoursesModelTests.cs
// Arrange
var courses = new ContentfulCollection<Course>(); // Create a dummy collection
courses.Items = new List<Course>() { /* ... */ };
var client = new Mock<IContentfulClient>();
// Setup the GetEntries method to return our dummy collection
// It.IsAny<> is used to match any QueryBuilder argument.
client.Setup(c => c.GetEntries(It.IsAny<QueryBuilder<Course>>(), default(CancellationToken)))
.Returns(Task.FromResult(courses));
// The mocked client.Object is then passed to the constructor.
var model = new CoursesModel(client.Object, ...);1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2. Mocking Framework Dependencies (IHttpContextAccessor, ISession): Testing components that interact with the HTTP context requires mocking framework-provided services.
csharp
// From: TheExampleApp.Tests/Configuration/ContentfulOptionsManagerTests.cs
// Arrange
var mockSession = new Mock<ISession>();
byte[] dummy = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new ContentfulOptions { DeliveryApiKey = "Schönebrunn" }));
// Setup TryGetValue to simulate finding a value in the session.
// The 'out' parameter is populated with our dummy byte array.
mockSession.Setup(x => x.TryGetValue(nameof(ContentfulOptions), out dummy)).Returns(true);
var mockContext = new Mock<HttpContext>();
mockContext.SetupGet(c => c.Session).Returns(mockSession.Object);
var httpContextAccessor = new Mock<IHttpContextAccessor>();
httpContextAccessor.SetupGet(c => c.HttpContext).Returns(mockContext.Object);
// Act
var manager = new ContentfulOptionsManager(mockOptions.Object, httpContextAccessor.Object);
var options = manager.Options;
// Assert
Assert.Equal("Schönebrunn", options.DeliveryApiKey);1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
3. Verifying Method Calls: Sometimes, it's important to verify that a method was called with specific parameters, especially for methods that don't return a value (e.g., writing to a cache or session).
csharp
// From: TheExampleApp.Tests/ViewComponents/LocalesViewComponentTests.cs
// Arrange
var mockSession = new Mock<ISession>();
mockSession.Setup(x => x.Set("locale", It.IsAny<byte[]>())).Verifiable();
// ... other setup ...
// Act
await component.InvokeAsync();
// Assert
// Verify that the Set method was called exactly once with the correct key ("locale")
// and a byte array that decodes to "sv-SE".
mockSession.Verify(x => x.Set("locale", It.Is<byte[]>(b => Encoding.UTF8.GetString(b) == "sv-SE")), Times.Once);1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
Test organization
To ensure maintainability and ease of navigation, the TheExampleApp.Tests project mirrors the directory and namespace structure of the main TheExampleApp project.
| Main Application Component | Corresponding Test File |
|---|---|
TheExampleApp/Pages/CoursesModel.cs | TheExampleApp.Tests/Pages/CoursesModelTests.cs |
TheExampleApp/TagHelpers/MarkdownTagHelper.cs | TheExampleApp.Tests/TagHelpers/MarkdownTagHelperTests.cs |
TheExampleApp/ViewComponents/LocalesViewComponent.cs | TheExampleApp.Tests/ViewComponents/LocalesViewComponentTests.cs |
TheExampleApp/Configuration/ContentfulOptionsManager.cs | TheExampleApp.Tests/Configuration/ContentfulOptionsManagerTests.cs |
This convention makes it trivial to locate the tests for a specific piece of application logic.
Configuration tests
Configuration logic, while seemingly simple, can have complex dependencies on the request context. The ContentfulOptionsManagerTests provide an excellent example of how to test this. The ContentfulOptionsManager is responsible for supplying the correct Contentful API keys, which can be dynamically switched via session data.
The tests cover two primary scenarios:
- No session data exists: The manager should return the default options configured in
appsettings.json. - Session data exists: The manager should deserialize the options from the session and return them, overriding the default configuration.
csharp
// From: TheExampleApp.Tests/Configuration/ContentfulOptionsManagerTests.cs
[Fact]
public void OptionsManagerShouldReturnSessionOptionsIfSessionIsAvailable()
{
// Arrange
// 1. Mock default options (IOptions<ContentfulOptions>)
var mockOptions = new Mock<IOptions<ContentfulOptions>>();
mockOptions.Setup(x => x.Value).Returns(new ContentfulOptions() { DeliveryApiKey = "brunsås" });
// 2. Mock session state with custom options
var mockSession = new Mock<ISession>();
byte[] dummy = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new ContentfulOptions { DeliveryApiKey = "Schönebrunn" }));
mockSession.Setup(x => x.TryGetValue(nameof(ContentfulOptions), out dummy)).Returns(true);
// 3. Mock HttpContext and its accessor to provide the session
var mockContext = new Mock<HttpContext>();
mockContext.SetupGet(c => c.Session).Returns(mockSession.Object);
var httpContextAccessor = new Mock<IHttpContextAccessor>();
httpContextAccessor.SetupGet(c => c.HttpContext).Returns(mockContext.Object);
var manager = new ContentfulOptionsManager(mockOptions.Object, httpContextAccessor.Object);
// Act
var options = manager.Options;
// Assert
// The API key from the session should be returned, not the default one.
Assert.Equal("Schönebrunn", options.DeliveryApiKey);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Page model tests
Razor Page Models contain the core business logic for handling HTTP requests and preparing data for views. The CoursesModelTests demonstrate how to test this logic in isolation from the database and the Contentful API.
The tests for CoursesModel verify its ability to fetch and filter courses.
CoursesModelShouldLoadAllCoursesWhenNoCategoryIsSelected: This test ensures that when theOnGethandler is called with anullcategory, all courses are fetched and assigned to theCoursesproperty.CoursesModelShouldFilterCoursesWhenCategoryIsSelected: This test provides a category slug to theOnGethandler and asserts that the resultingCoursescollection contains only the courses matching that category. This is a critical test for the application's filtering logic.
csharp
// From: TheExampleApp.Tests/Pages/CoursesModelTests.cs
[Fact]
public async Task CoursesModelShouldFilterCoursesWhenCategoryIsSelected()
{
// Arrange
// Create mock data for categories and courses, including relationships
var categories = new ContentfulCollection<Category> { /* ... */ };
var courses = new ContentfulCollection<Course> { /* ... with categories linked ... */ };
// Mock the Contentful client to return the predefined data
var client = new Mock<IContentfulClient>();
client.Setup(c => c.GetEntries(It.IsAny<QueryBuilder<Course>>(), default(CancellationToken)))
.Returns(Task.FromResult(courses));
client.Setup(c => c.GetEntriesByType("category", It.IsAny<QueryBuilder<Category>>(), default(CancellationToken)))
.Returns(Task.FromResult(categories));
var crumbs = new Mock<IBreadcrumbsManager>();
var localizer = new Mock<IViewLocalizer>();
var model = new CoursesModel(client.Object, crumbs.Object, localizer.Object);
// Act
// Call the handler with a specific category slug. Note the case difference.
await model.OnGet("another-sluG");
// Assert
// Verify that only the course with the matching category remains.
Assert.Collection(model.Courses, (c) => { Assert.Equal("Cruiser2", c.Title); });
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
View component tests
View Components encapsulate reusable rendering logic. The LocalesViewComponentTests show how to test a component that has dependencies on both an external service (IContentfulClient) and the request context (HttpContext).
The tests cover several important execution paths:
- Success Path: The current culture is found in the list of locales from Contentful.
- Fallback Path: The current culture is not found, so the component correctly falls back to the default locale specified by Contentful.
- Error Path: The Contentful client throws an exception, and the component gracefully handles it by falling back to a hardcoded default (
en-US).
A key technique here is the manual creation and assignment of a ViewComponentContext to provide the necessary HttpContext to the component under test.
csharp
// From: TheExampleApp.Tests/ViewComponents/LocalesViewComponentTests.cs
[Fact]
public async Task ComponentShouldGetTheDefaultLocaleIfSelectedLocaleDoesNotExist()
{
// Arrange
// 1. Setup Contentful client to return a space with specific locales
var space = new Space { /* sv-SE is default, Klingon is other */ };
var client = new Mock<IContentfulClient>();
client.Setup(c => c.GetSpace(default(CancellationToken))).Returns(Task.FromResult(space));
// 2. Set the thread's culture to one that does not exist in the space
Thread.CurrentThread.CurrentCulture = new CultureInfo("e-US"); // Mismatched locale
// 3. Mock the HttpContext and Session
var mockSession = new Mock<ISession>();
var httpContext = new Mock<HttpContext>();
httpContext.SetupGet(c => c.Session).Returns(mockSession.Object);
// 4. Create and assign the ViewComponentContext
var viewContext = new ViewContext { HttpContext = httpContext.Object };
var componentContext = new ViewComponentContext { ViewContext = viewContext };
var component = new LocalesViewComponent(client.Object) { ViewComponentContext = componentContext };
// Act
var res = await component.InvokeAsync();
// Assert
// The model's selected locale should be the default one from the space ("sv-SE")
var model = (res as ViewViewComponentResult).ViewData.Model as LocalesInfo;
Assert.Equal("sv-SE", model.SelectedLocale.Code);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Tag helper tests
Tag Helpers are a powerful feature for encapsulating server-side logic that generates HTML. The MarkdownTagHelperTests demonstrate how to unit test a custom tag helper that processes Markdown.
Testing a tag helper involves manually creating the TagHelperContext and TagHelperOutput objects that the Razor engine would normally provide.
The tests verify two use cases:
- When used as a custom element (
<markdown>## Banana</markdown>), the tag itself is removed and replaced with the rendered HTML. - When used as an attribute on another element (
<div markdown># Mr French</div>), the host tag is preserved, and only its content is replaced with rendered HTML.
csharp
// From: TheExampleApp.Tests/TagHelpers/MarkdownTagHelperTests.cs
[Fact]
public async Task MarkdownTagWithChildContentShouldReturnRenderedMarkdown()
{
// Arrange
var helper = new MarkdownTagHelper();
var context = new TagHelperContext(new TagHelperAttributeList(), new Dictionary<object, object>(), Guid.NewGuid().ToString());
// Simulate the content inside the tag helper
var output = new TagHelperOutput("markdown",
new TagHelperAttributeList(),
(useCachedResult, encoder) => {
var tagHelperContent = new DefaultTagHelperContent();
tagHelperContent.SetContent("## Banana");
return Task.FromResult<TagHelperContent>(tagHelperContent);
});
// Act
await helper.ProcessAsync(context, output);
// Assert
// The <markdown> tag itself should be suppressed
Assert.Null(output.TagName);
// The content should be rendered as an H2 tag
Assert.StartsWith("<h2>Banana</h2>", output.Content.GetContent());
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Running tests
Tests can be executed from the command line or directly within an IDE like Visual Studio.
Command-Line Interface
To run all tests in the solution from the root directory, use the .NET CLI:
bash
dotnet test1
The command will automatically discover the test project, build it, and execute all tests. The results will be printed to the console.
Visual Studio
Thanks to the xunit.runner.visualstudio package, all tests are automatically discovered by the Test Explorer window in Visual Studio. From there, you can:
- Run all tests.
- Run a subset of tests (e.g., by project, class, or individual test).
- Debug tests by setting breakpoints and running them with the debugger attached.
This is often the most productive way to work with tests during development. For more details on the initial project setup, refer to the Installation Guide.
Test coverage
Test coverage is a metric that measures the percentage of your codebase that is executed by your automated tests. While a high percentage does not guarantee bug-free code, it is a useful indicator of untested logic paths.
We can use Coverlet, a cross-platform code coverage framework for .NET, to generate coverage reports.
To run tests and generate a coverage report, execute the following command:
bash
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=opencover1
This will create a coverage.opencover.xml file in the test project's directory. This file can be used with report generators (like ReportGenerator) to create a human-readable HTML report.
Our goal is not to achieve 100% coverage, but to ensure that:
- All complex business logic is thoroughly tested.
- Common user paths are covered.
- Edge cases and error conditions are handled gracefully.
Unit tests form the base of the testing pyramid. They should be fast, reliable, and numerous. For testing interactions between components and with external infrastructure, refer to our Integration Tests documentation.