by thetestingacademy
Comprehensive xUnit.net testing skill for writing reliable unit, integration, and acceptance tests in C# with [Fact], [Theory], fixtures, dependency injection, and parallel execution strategies.
npx @qaskills/cli add xunit-testingAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
You are an expert QA engineer specializing in xUnit.net for C# and .NET applications. When the user asks you to write, review, debug, or set up xUnit.net tests, follow these detailed instructions. You understand the xUnit ecosystem deeply including [Fact]/[Theory] attributes, class fixtures, collection fixtures, dependency injection, parallel execution, and integration with ASP.NET Core test infrastructure.
[Theory] with [InlineData], [MemberData], or [ClassData] for data-driven tests instead of duplicating [Fact] methods with slight variations.Assert.Equal, Assert.Throws, Assert.Collection) or FluentAssertions for expressive, readable test verification.[Collection] attributes to control parallelism when tests share resources.MethodName_Scenario_ExpectedBehavior.ProjectName.Tests/
├── ProjectName.Tests.csproj
├── GlobalUsings.cs
├── Unit/
│ ├── Services/
│ │ ├── UserServiceTests.cs
│ │ ├── OrderServiceTests.cs
│ │ └── PaymentServiceTests.cs
│ ├── Models/
│ │ ├── UserTests.cs
│ │ └── OrderTests.cs
│ └── Validators/
│ └── UserValidatorTests.cs
├── Integration/
│ ├── Api/
│ │ ├── UsersControllerTests.cs
│ │ └── OrdersControllerTests.cs
│ ├── Database/
│ │ └── UserRepositoryTests.cs
│ └── Fixtures/
│ ├── DatabaseFixture.cs
│ ├── WebApplicationFixture.cs
│ └── TestCollectionDefinitions.cs
├── Helpers/
│ ├── TestDataBuilder.cs
│ ├── FakeUserGenerator.cs
│ └── AssertionExtensions.cs
└── xunit.runner.json
using Xunit;
public class CalculatorTests
{
private readonly Calculator _calculator;
public CalculatorTests()
{
// Constructor acts as setup - runs before each test
_calculator = new Calculator();
}
[Fact]
public void Add_TwoPositiveNumbers_ReturnsSum()
{
// Arrange
var a = 5;
var b = 3;
// Act
var result = _calculator.Add(a, b);
// Assert
Assert.Equal(8, result);
}
[Fact]
public void Divide_ByZero_ThrowsDivideByZeroException()
{
// Act & Assert
var exception = Assert.Throws<DivideByZeroException>(
() => _calculator.Divide(10, 0)
);
Assert.Equal("Cannot divide by zero", exception.Message);
}
[Theory]
[InlineData(1, 1, 2)]
[InlineData(-1, 1, 0)]
[InlineData(0, 0, 0)]
[InlineData(int.MaxValue, 0, int.MaxValue)]
public void Add_VariousInputs_ReturnsCorrectSum(int a, int b, int expected)
{
var result = _calculator.Add(a, b);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("")]
[InlineData(null)]
[InlineData(" ")]
public void Validate_InvalidInput_ReturnsFalse(string input)
{
var result = _calculator.IsValidExpression(input);
Assert.False(result);
}
}
public class UserServiceTests
{
private readonly Mock<IUserRepository> _mockRepo;
private readonly Mock<IEmailService> _mockEmail;
private readonly UserService _service;
public UserServiceTests()
{
_mockRepo = new Mock<IUserRepository>();
_mockEmail = new Mock<IEmailService>();
_service = new UserService(_mockRepo.Object, _mockEmail.Object);
}
public static IEnumerable<object[]> InvalidUserData =>
new List<object[]>
{
new object[] { "", "valid@email.com", "Name is required" },
new object[] { "John", "", "Email is required" },
new object[] { "John", "invalid-email", "Email format is invalid" },
new object[] { new string('a', 256), "valid@email.com", "Name too long" },
};
[Theory]
[MemberData(nameof(InvalidUserData))]
public async Task CreateUser_InvalidData_ReturnsValidationError(
string name, string email, string expectedError)
{
// Arrange
var request = new CreateUserRequest { Name = name, Email = email };
// Act
var result = await _service.CreateUser(request);
// Assert
Assert.False(result.IsSuccess);
Assert.Contains(expectedError, result.Error);
}
[Fact]
public async Task CreateUser_ValidData_SavesAndSendsWelcomeEmail()
{
// Arrange
var request = new CreateUserRequest { Name = "Alice", Email = "alice@test.com" };
_mockRepo.Setup(r => r.SaveAsync(It.IsAny<User>()))
.ReturnsAsync(new User { Id = 1, Name = "Alice", Email = "alice@test.com" });
_mockEmail.Setup(e => e.SendWelcomeEmail(It.IsAny<string>()))
.Returns(Task.CompletedTask);
// Act
var result = await _service.CreateUser(request);
// Assert
Assert.True(result.IsSuccess);
Assert.Equal("Alice", result.Value.Name);
_mockRepo.Verify(r => r.SaveAsync(It.Is<User>(u => u.Email == "alice@test.com")), Times.Once);
_mockEmail.Verify(e => e.SendWelcomeEmail("alice@test.com"), Times.Once);
}
}
// Fixture class - created once for all tests in the class
public class DatabaseFixture : IAsyncLifetime
{
public string ConnectionString { get; private set; }
public AppDbContext DbContext { get; private set; }
public async Task InitializeAsync()
{
// Create test database
ConnectionString = $"Server=localhost;Database=TestDb_{Guid.NewGuid():N};Trusted_Connection=true";
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(ConnectionString)
.Options;
DbContext = new AppDbContext(options);
await DbContext.Database.EnsureCreatedAsync();
}
public async Task DisposeAsync()
{
await DbContext.Database.EnsureDeletedAsync();
await DbContext.DisposeAsync();
}
}
// Test class using the fixture
public class UserRepositoryTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;
private readonly UserRepository _repository;
public UserRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture;
_repository = new UserRepository(_fixture.DbContext);
}
[Fact]
public async Task GetById_ExistingUser_ReturnsUser()
{
// Arrange
var user = new User { Name = "Alice", Email = "alice@test.com" };
_fixture.DbContext.Users.Add(user);
await _fixture.DbContext.SaveChangesAsync();
// Act
var result = await _repository.GetByIdAsync(user.Id);
// Assert
Assert.NotNull(result);
Assert.Equal("Alice", result.Name);
}
}
// Define the collection
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture>
{
// This class has no code, just the attributes
}
// First test class in the collection
[Collection("Database")]
public class UserRepositoryTests
{
private readonly DatabaseFixture _fixture;
public UserRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task CreateUser_ValidData_PersistsToDatabase()
{
var repo = new UserRepository(_fixture.DbContext);
var user = new User { Name = "Bob", Email = "bob@test.com" };
await repo.CreateAsync(user);
var saved = await _fixture.DbContext.Users.FindAsync(user.Id);
Assert.NotNull(saved);
}
}
// Second test class sharing the same fixture
[Collection("Database")]
public class OrderRepositoryTests
{
private readonly DatabaseFixture _fixture;
public OrderRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task CreateOrder_ValidUser_PersistsOrder()
{
var repo = new OrderRepository(_fixture.DbContext);
var order = new Order { UserId = 1, Total = 99.99m };
await repo.CreateAsync(order);
var saved = await _fixture.DbContext.Orders.FindAsync(order.Id);
Assert.NotNull(saved);
}
}
public class WebApplicationFixture : IAsyncLifetime
{
public HttpClient Client { get; private set; }
private WebApplicationFactory<Program> _factory;
public async Task InitializeAsync()
{
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace real database with in-memory
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null) services.Remove(descriptor);
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
// Seed test data
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
SeedTestData(db);
});
});
Client = _factory.CreateClient();
await Task.CompletedTask;
}
private static void SeedTestData(AppDbContext db)
{
db.Users.Add(new User { Id = 1, Name = "TestUser", Email = "test@example.com" });
db.SaveChanges();
}
public async Task DisposeAsync()
{
Client?.Dispose();
await _factory.DisposeAsync();
}
}
public class UsersControllerTests : IClassFixture<WebApplicationFixture>
{
private readonly HttpClient _client;
public UsersControllerTests(WebApplicationFixture fixture)
{
_client = fixture.Client;
}
[Fact]
public async Task GetUsers_ReturnsOkWithUserList()
{
var response = await _client.GetAsync("/api/users");
response.EnsureSuccessStatusCode();
var users = await response.Content.ReadFromJsonAsync<List<UserDto>>();
Assert.NotEmpty(users);
}
[Fact]
public async Task CreateUser_ValidPayload_ReturnsCreated()
{
var payload = new { Name = "NewUser", Email = "new@example.com" };
var response = await _client.PostAsJsonAsync("/api/users", payload);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var created = await response.Content.ReadFromJsonAsync<UserDto>();
Assert.Equal("NewUser", created.Name);
}
[Fact]
public async Task CreateUser_DuplicateEmail_ReturnsConflict()
{
var payload = new { Name = "Duplicate", Email = "test@example.com" };
var response = await _client.PostAsJsonAsync("/api/users", payload);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}
}
public static class AssertionExtensions
{
public static void ShouldBeValidEmail(string email)
{
Assert.Matches(@"^[\w\.-]+@[\w\.-]+\.\w+$", email);
}
public static void ShouldBeWithinRange(decimal value, decimal min, decimal max)
{
Assert.InRange(value, min, max);
}
public static async Task ShouldComplete<T>(Task<T> task, int timeoutMs = 5000)
{
var completed = await Task.WhenAny(task, Task.Delay(timeoutMs));
Assert.Equal(task, completed);
}
}
// Test Data Builder Pattern
public class UserBuilder
{
private string _name = "Default User";
private string _email = "default@test.com";
private string _role = "User";
public UserBuilder WithName(string name) { _name = name; return this; }
public UserBuilder WithEmail(string email) { _email = email; return this; }
public UserBuilder WithRole(string role) { _role = role; return this; }
public UserBuilder AsAdmin() { _role = "Admin"; return this; }
public User Build() => new User { Name = _name, Email = _email, Role = _role };
}
// xunit.runner.json
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": 0,
"diagnosticMessages": false,
"methodDisplay": "classAndMethod",
"internalDiagnosticMessages": false
}
name: .NET Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- run: dotnet restore
- run: dotnet build --no-restore
- run: dotnet test --no-build --verbosity normal --logger "trx;LogFileName=results.trx"
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: '**/*.trx'
static mutable fields in test classes.Assert.Throws<T>() or Assert.ThrowsAsync<T>() instead of try-catch blocks.Assert.Multiple() in xUnit v3 for grouped assertions.ITestOutputHelper for diagnostic logging instead of Console.WriteLine, which xUnit does not capture.[Fact(Skip = "reason")] must always include a meaningful reason and a tracking issue.- name: Install QA Skills
run: npx @qaskills/cli add xunit-testing10 of 29 agents supported