What you will learn
Unit testing helps you verify that small pieces of your application behave correctly. In this lesson, you will create a test project, write simple and parameterized tests, run tests in Visual Studio, use mocks for dependencies, measure code coverage, and use Copilot as a test assistant rather than a replacement for judgment.
Why unit testing matters
A unit test checks a small piece of code without depending on a real database, real payment gateway, real email service, or manual clicking. Good tests make refactoring safer because Visual Studio can quickly show whether a change broke an existing rule.
| Test type | What it checks | Typical tool |
|---|---|---|
| Unit test | One method or class with dependencies isolated | xUnit.net, NUnit, MSTest |
| Integration test | Several parts working together, often with a test database or API host | ASP.NET Core test host, containers |
| End-to-end test | A full user workflow through UI or API | Playwright, Selenium, API clients |
Create an xUnit.net v3 test project
xUnit.net v3 can be used from the .NET SDK and Visual Studio. A clean way to start is to install the v3 templates, create a test project, then add a reference to the project you want to test.
dotnet new install xunit.v3.templates
dotnet new sln -n StoreApp
dotnet new classlib -n StoreApp.Core
dotnet new xunit3 -n StoreApp.Tests --language C#
dotnet sln add StoreApp.Core/StoreApp.Core.csproj
dotnet sln add StoreApp.Tests/StoreApp.Tests.csproj
dotnet add StoreApp.Tests/StoreApp.Tests.csproj reference StoreApp.Core/StoreApp.Core.csprojIn Visual Studio 2026, open the solution and use Test > Test Explorer to discover, run, group, filter, and debug tests.
Write your first Fact test
A [Fact] is a test with one fixed scenario. Use the Arrange-Act-Assert pattern so readers can understand the test quickly.
namespace StoreApp.Core;
public sealed class DiscountService
{
public decimal ApplyPercentageDiscount(decimal subtotal, decimal percent)
{
if (subtotal < 0) throw new ArgumentOutOfRangeException(nameof(subtotal));
if (percent < 0 || percent > 100) throw new ArgumentOutOfRangeException(nameof(percent));
return subtotal - (subtotal * percent / 100m);
}
}using StoreApp.Core;
using Xunit;
namespace StoreApp.Tests;
public sealed class DiscountServiceTests
{
[Fact]
public void ApplyPercentageDiscount_WithTenPercent_ReturnsReducedTotal()
{
// Arrange
var service = new DiscountService();
// Act
var result = service.ApplyPercentageDiscount(100m, 10m);
// Assert
Assert.Equal(90m, result);
}
}Use Theory tests for multiple inputs
A [Theory] lets one test run with several data rows. Use it when the same behavior should be verified across multiple values.
[Theory]
[InlineData(100, 0, 100)]
[InlineData(100, 10, 90)]
[InlineData(80, 25, 60)]
[InlineData(50, 100, 0)]
public void ApplyPercentageDiscount_WithValidInputs_ReturnsExpectedTotal(
decimal subtotal,
decimal percent,
decimal expected)
{
var service = new DiscountService();
var result = service.ApplyPercentageDiscount(subtotal, percent);
Assert.Equal(expected, result);
}Use InlineData for simple values, MemberData for reusable static data, and ClassData when the test data deserves its own class.
Test exceptions and edge cases
Many bugs appear at boundaries: negative numbers, zero values, null values, empty collections, maximum limits, and invalid states. Do not only test the happy path.
[Theory]
[InlineData(-1, 10)]
[InlineData(100, -5)]
[InlineData(100, 150)]
public void ApplyPercentageDiscount_WithInvalidValues_ThrowsArgumentOutOfRange(
decimal subtotal,
decimal percent)
{
var service = new DiscountService();
Assert.Throws<ArgumentOutOfRangeException>(() =>
service.ApplyPercentageDiscount(subtotal, percent));
}Write async tests correctly
Most modern .NET applications use asynchronous services. An async test should return Task and use await. Avoid .Result and .Wait() because they can hide timing and deadlock issues.
public interface IOrderRepository
{
Task<Order?> FindAsync(int id, CancellationToken cancellationToken = default);
}
public sealed class OrderService(IOrderRepository repository)
{
public async Task<decimal> GetOrderTotalAsync(int id, CancellationToken cancellationToken = default)
{
var order = await repository.FindAsync(id, cancellationToken);
return order?.Total ?? 0m;
}
}[Fact]
public async Task GetOrderTotalAsync_WhenOrderExists_ReturnsTotal()
{
var repository = Substitute.For<IOrderRepository>();
repository.FindAsync(1, Arg.Any<CancellationToken>())
.Returns(new Order { Id = 1, Total = 99.95m });
var service = new OrderService(repository);
var total = await service.GetOrderTotalAsync(1);
Assert.Equal(99.95m, total);
}Mock dependencies with NSubstitute
Mocking lets you test a service without connecting to external systems. In the example below, the repository is replaced with a controlled fake object.
dotnet add StoreApp.Tests package NSubstitute[Fact]
public async Task GetOrderTotalAsync_CallsRepositoryOnce()
{
var repository = Substitute.For<IOrderRepository>();
repository.FindAsync(1, Arg.Any<CancellationToken>())
.Returns(new Order { Id = 1, Total = 45m });
var service = new OrderService(repository);
await service.GetOrderTotalAsync(1);
await repository.Received(1).FindAsync(1, Arg.Any<CancellationToken>());
}Run and debug tests in Test Explorer
Visual Studio Test Explorer helps you run all tests, run selected tests, group by project or trait, debug a failing test, and re-run failed tests after a fix.
| Task | Visual Studio action | Why it helps |
|---|---|---|
| Run all tests | Test > Run All Tests | Checks the whole solution after a change. |
| Debug one test | Right-click test > Debug | Stops at breakpoints inside the tested code. |
| Group tests | Use grouping menu in Test Explorer | Makes large test suites easier to navigate. |
| Rerun failed tests | Use the failed test filter | Speeds up the edit-test-debug cycle. |
Use code coverage wisely
Code coverage shows which lines were exercised by tests. It is useful for spotting untested branches, but it is not a guarantee that your application is correct.
Run coverage after core tests pass
Use coverage as a quality signal, not as the first goal.
Look for risky uncovered branches
Error handling, validation, discounts, permissions, and calculation rules deserve attention.
Improve meaningful coverage
A few strong tests are better than many shallow tests that only execute lines.
Generate better tests with GitHub Copilot
Copilot can help generate tests, suggest missing edge cases, explain failures, and draft assertions. The best results come from giving it business rules and constraints, not only asking for “write tests”.
Generate xUnit.net tests for DiscountService.
Use Arrange-Act-Assert.
Cover valid discounts, 0 percent, 100 percent, negative subtotal,
negative percent, and percent greater than 100.
Use clear test names and avoid weak assertions such as Assert.NotNull.Review these tests for missing edge cases.
Tell me which business rules are not tested.
Do not rewrite the production code unless a test reveals a real bug.Unit testing habits for maintainable projects
Hands-on exercise: Test an order discount service
- Create a
StoreApp.Coreproject and aStoreApp.TestsxUnit.net v3 project. - Add a
DiscountServicewith rules for percentage discounts. - Write one
[Fact]test for a normal discount. - Write one
[Theory]test with at least four valid input rows. - Write exception tests for invalid values.
- Run the tests in Test Explorer and fix any failures.
- Ask Copilot to suggest missing edge cases, then manually review the suggestions.
Recommended companion book
Visual Studio 2026 Made Easy gives you a structured path for learning Visual Studio, C#, .NET, debugging, testing, web development, databases, and AI-assisted workflows.