From csharp-foundation
xUnit, Moq/NSubstitute, FluentAssertions, and coverlet patterns for any .NET project. Covers test structure ([Fact]/[Theory]/[InlineData]), mocking discipline, fluent assertions, integration test setup with WebApplicationFactory, and coverage measurement. Stack-agnostic — referenced by every .NET plugin in the marketplace. Use this skill to: - Write clear, maintainable unit tests with xUnit [Fact] and [Theory]. - Mock dependencies with Moq or NSubstitute without overusing mocks. - Write expressive assertions with FluentAssertions. - Measure coverage with coverlet and enforce a minimum threshold. Do NOT use this skill for: - ASP.NET Core-specific integration tests (WebApplicationFactory, HttpClient — those are in aspnet-core-plugin:aspnet-conventions). - EF Core in-memory or SQL Server LocalDB test patterns (aspnet-core-plugin:efcore-patterns). - C# language idioms — see csharp-foundation:csharp-conventions.
How this skill is triggered — by the user, by Claude, or both
Slash command
/csharp-foundation:dotnet-testingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
xUnit is the primary test framework for .NET. Use NUnit or MSTest only when the project already uses them — do not introduce xUnit into a project that uses another framework.
xUnit is the primary test framework for .NET. Use NUnit or MSTest only when the project already uses them — do not introduce xUnit into a project that uses another framework.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.12.1" />
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>
using FluentAssertions;
using Moq;
using Xunit;
public class UserServiceTests
{
private readonly Mock<IUserRepository> _repoMock;
private readonly UserService _sut;
public UserServiceTests()
{
_repoMock = new Mock<IUserRepository>(MockBehavior.Strict);
_sut = new UserService(_repoMock.Object);
}
[Fact]
public async Task RegisterAsync_WithValidData_ReturnsActiveUser()
{
// Arrange
var command = new RegisterUserCommand("[email protected]", "Secret1!");
_repoMock.Setup(r => r.ExistsByEmailAsync("[email protected]", default))
.ReturnsAsync(false);
_repoMock.Setup(r => r.SaveAsync(It.IsAny<User>(), default))
.ReturnsAsync((User u, CancellationToken _) => u);
// Act
var user = await _sut.RegisterAsync(command);
// Assert
user.Email.Should().Be("[email protected]");
user.IsActive.Should().BeTrue();
_repoMock.VerifyAll();
}
[Fact]
public async Task RegisterAsync_WithDuplicateEmail_ThrowsDomainException()
{
_repoMock.Setup(r => r.ExistsByEmailAsync(It.IsAny<string>(), default))
.ReturnsAsync(true);
await _sut.Invoking(s => s.RegisterAsync(new RegisterUserCommand("[email protected]", "pass")))
.Should().ThrowAsync<DomainException>()
.WithMessage("*already registered*");
}
}
Test method naming: MethodName_Condition_ExpectedOutcome — readable without additional comments.
xUnit constructor vs [Theory] setup: Use the constructor for shared setup of the system under test; use [ClassFixture<T>] for expensive shared resources (DB connections, servers) that are reused across tests in the class.
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void IsValidEmail_BlankInput_ReturnsFalse(string? input)
{
EmailValidator.IsValid(input).Should().BeFalse();
}
[Theory]
[InlineData("[email protected]", true)]
[InlineData("not-an-email", false)]
[InlineData("@nodomain", false)]
public void IsValidEmail_VariousInputs_MatchesExpected(string email, bool expected)
{
EmailValidator.IsValid(email).Should().Be(expected);
}
// MemberData for complex objects
[Theory]
[MemberData(nameof(InvalidCommands))]
public async Task RegisterAsync_InvalidCommand_ThrowsValidationException(RegisterUserCommand command)
{
await _sut.Invoking(s => s.RegisterAsync(command))
.Should().ThrowAsync<ValidationException>();
}
public static IEnumerable<object[]> InvalidCommands =>
[
[new RegisterUserCommand(null!, "pass")],
[new RegisterUserCommand("", "pass")],
[new RegisterUserCommand("[email protected]", "")],
];
Prefer FluentAssertions over xUnit's Assert.* — it gives richer failure messages and fluent chaining.
// Collections
result.Items.Should().HaveCount(3)
.And.ContainSingle(i => i.Name == "Alice");
// Strings
response.ContentType.Should().StartWith("application/json");
// Exceptions (sync and async)
Action act = () => service.Compute(-1);
act.Should().Throw<ArgumentOutOfRangeException>()
.WithParameterName("value");
await service.Awaiting(s => s.GetAsync(-1))
.Should().ThrowAsync<KeyNotFoundException>();
// Numeric / date
price.Should().BeGreaterThan(0).And.BeLessThan(1000);
createdAt.Should().BeCloseTo(DateTimeOffset.UtcNow, precision: TimeSpan.FromSeconds(5));
// Equivalence (structural equality, ignores property order)
result.Should().BeEquivalentTo(expected, opt => opt.Excluding(x => x.Id));
Never use Assert.True(a.Equals(b)) — the failure message shows only "expected true". Use a.Should().Be(b).
// Constructor injection — preferred (no reflection, no [Inject])
var repo = new Mock<IUserRepository>(MockBehavior.Strict);
var sut = new UserService(repo.Object);
// Stubbing — be specific with arguments
repo.Setup(r => r.FindAsync(42, default)).ReturnsAsync(testUser);
// Argument matchers when exact value doesn't matter
repo.Setup(r => r.ExistsByEmailAsync(It.IsAny<string>(), default))
.ReturnsAsync(false);
// Capture arguments for detailed assertions
repo.Setup(r => r.SaveAsync(It.IsAny<User>(), default))
.Callback<User, CancellationToken>((u, _) => savedUser = u)
.ReturnsAsync((User u, CancellationToken _) => u);
// Verification
repo.Verify(r => r.SaveAsync(It.IsAny<User>(), default), Times.Once);
repo.Verify(r => r.DeleteAsync(It.IsAny<int>(), default), Times.Never);
repo.VerifyAll(); // MockBehavior.Strict enforces no unexpected calls
Use MockBehavior.Strict for new tests — it forces you to set up all calls explicitly, making unexpected dependencies visible.
Do not mock value objects, records, or simple DTOs. Only mock boundaries: repositories, HTTP clients, external services, the clock (IDateTimeProvider).
Some projects prefer NSubstitute for its concise syntax:
var repo = Substitute.For<IUserRepository>();
repo.FindAsync(42, default).Returns(testUser);
repo.ExistsByEmailAsync(Arg.Any<string>(), default).Returns(false);
// Verification
await repo.Received(1).SaveAsync(Arg.Any<User>(), default);
await repo.DidNotReceive().DeleteAsync(Arg.Any<int>(), default);
Match the mocking framework that is already used in the project. Do not mix Moq and NSubstitute in the same test project.
Measure coverage when running tests:
# Run tests with coverage
dotnet test --collect:"XPlat Code Coverage"
# Generate HTML report (requires reportgenerator global tool)
dotnet tool install --global dotnet-reportgenerator-globaltool
reportgenerator -reports:**/coverage.cobertura.xml -targetdir:coverage -reporttypes:Html
# Open report
open coverage/index.html # macOS
start coverage/index.html # Windows
Enforce a minimum coverage threshold in CI:
<!-- .csproj — dotnet test fails if line coverage < 80% -->
<PropertyGroup>
<CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>cobertura</CoverletOutputFormat>
<Threshold>80</Threshold>
<ThresholdType>line</ThresholdType>
</PropertyGroup>
Target ≥ 80% line coverage on business logic (services, domain objects, validators). Exclude framework glue code (Program.cs, Startup, configuration classes, migrations) using [ExcludeFromCodeCoverage] or coverlet filters.
MyApp.sln
├── src/
│ └── MyApp/
│ ├── Users/
│ │ ├── User.cs
│ │ ├── IUserRepository.cs
│ │ └── UserService.cs
│ └── ...
└── tests/
├── MyApp.UnitTests/ # Unit tests — no external dependencies
│ └── Users/
│ └── UserServiceTests.cs
└── MyApp.IntegrationTests/ # Integration tests — real DB, HTTP
└── Users/
└── UserEndpointsTests.cs
Naming: Unit test classes: {Subject}Tests. Integration test classes: {Subject}IntegrationTests or {Subject}Tests in the .IntegrationTests project.
Mirror the main project's namespace structure: MyApp.Users.UserServiceTests tests MyApp.Users.UserService.
// Shared fixture — created once per test class, disposed after all tests
public class DatabaseFixture : IDisposable
{
public AppDbContext Db { get; }
public DatabaseFixture()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
Db = new AppDbContext(options);
}
public void Dispose() => Db.Dispose();
}
public class UserRepositoryTests : IClassFixture<DatabaseFixture>
{
private readonly AppDbContext _db;
public UserRepositoryTests(DatabaseFixture fixture) => _db = fixture.Db;
[Fact]
public async Task SaveAndFind_RoundTrip()
{
var repo = new UserRepository(_db);
var user = new User { Email = "[email protected]" };
await repo.SaveAsync(user, default);
var found = await repo.FindAsync(user.Id, default);
found.Should().NotBeNull();
found!.Email.Should().Be("[email protected]");
}
}
For ASP.NET Core HTTP integration tests using WebApplicationFactory<TProgram>, see aspnet-core-plugin:aspnet-conventions.
npx claudepluginhub aratkruglik/claude-sdlc --plugin csharp-foundationProvides C# and .NET testing patterns using xUnit, FluentAssertions, mocking, and integration tests. Useful for writing, reviewing, and debugging test suites.
Deciding how to test .NET code. Unit vs integration vs E2E decision tree, test doubles.
Patterns for C# .NET testing using xUnit, FluentAssertions, NSubstitute/Moq, Testcontainers, and WebApplicationFactory for unit and integration tests.