Lesson 7 of 40 Testing Intermediate 45 min

Unit Testing with xUnit 3 and Copilot

Write reliable C# tests in Visual Studio 2026 using xUnit.net v3, Test Explorer, Arrange-Act-Assert, theory data, mocks, async test patterns, code coverage, and GitHub Copilot-assisted test generation.

xUnit v3Modern .NET testing framework
Test ExplorerRun and debug tests inside VS
CoverageFind untested paths
Visual Studio 2026 · Test Explorer
PASSCalculateTotal_ValidItems
PASSApplyDiscount_WithCoupon
FAILCheckout_EmptyCart
SKIPGateway_Integration
AIGenerate edge cases
[Theory]
[InlineData(100, 10, 90)]
public void ApplyDiscount_ReturnsExpectedTotal()
{
  var total = service.ApplyDiscount(100, 10);
  Assert.Equal(90, total);
}

// Run tests, inspect failures, ask Copilot for missing cases
Lesson overview

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.

Test small unitsFocus on methods, services, validators, and business rules.
Use clear namesDescribe the scenario, action, and expected outcome.
Review AI testsGenerated tests still need human review and meaningful assertions.
Part 1

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 typeWhat it checksTypical tool
Unit testOne method or class with dependencies isolatedxUnit.net, NUnit, MSTest
Integration testSeveral parts working together, often with a test database or API hostASP.NET Core test host, containers
End-to-end testA full user workflow through UI or APIPlaywright, Selenium, API clients
Beginner rule: Start with unit tests for services and business rules. Add integration tests after the core behavior is stable.
Part 2

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.csproj

In Visual Studio 2026, open the solution and use Test > Test Explorer to discover, run, group, filter, and debug tests.

Part 3

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); } }
Good test name: MethodName_Scenario_ExpectedResult. A clear name makes Test Explorer useful before you even open the code.
Part 4

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.

Part 5

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)); }
Important: A generated test that only checks “not null” may be weak. Prefer tests that check the exact rule your application promises.
Part 6

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); }
Part 7

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>()); }
Mock external boundariesRepositories, HTTP clients, email services, payment gateways, and file systems are good candidates.
Avoid mocking everythingIf a class is simple and stable, using the real object can make tests clearer.
Part 8

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.

TaskVisual Studio actionWhy it helps
Run all testsTest > Run All TestsChecks the whole solution after a change.
Debug one testRight-click test > DebugStops at breakpoints inside the tested code.
Group testsUse grouping menu in Test ExplorerMakes large test suites easier to navigate.
Rerun failed testsUse the failed test filterSpeeds up the edit-test-debug cycle.
Part 9

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.

1

Run coverage after core tests pass

Use coverage as a quality signal, not as the first goal.

2

Look for risky uncovered branches

Error handling, validation, discounts, permissions, and calculation rules deserve attention.

3

Improve meaningful coverage

A few strong tests are better than many shallow tests that only execute lines.

Part 10

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.
Review checklist for AI-generated tests: Do the tests compile? Do they test real behavior? Are assertions specific? Are edge cases included? Did Copilot invent APIs that do not exist?
Part 11

Unit testing habits for maintainable projects

One behavior per testA test should fail for one clear reason.
Keep tests deterministicAvoid time, randomness, network, and real database dependencies unless controlled.
Use builders for complex objectsReduce repeated setup code by using test data builders.
Test public behaviorAvoid tests that break whenever private implementation details change.

Hands-on exercise: Test an order discount service

  1. Create a StoreApp.Core project and a StoreApp.Tests xUnit.net v3 project.
  2. Add a DiscountService with rules for percentage discounts.
  3. Write one [Fact] test for a normal discount.
  4. Write one [Theory] test with at least four valid input rows.
  5. Write exception tests for invalid values.
  6. Run the tests in Test Explorer and fix any failures.
  7. Ask Copilot to suggest missing edge cases, then manually review the suggestions.
Visual Studio 2026 Made Easy book cover

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.