Qaskills xUnit.net Testing
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.
install
source · Clone the upstream repo
git clone https://github.com/PramodDutta/qaskills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/PramodDutta/qaskills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/seed-skills/xunit-testing" ~/.claude/skills/pramoddutta-qaskills-xunit-net-testing && rm -rf "$T"
manifest:
seed-skills/xunit-testing/SKILL.mdsource content
xUnit.net Testing
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.
Core Principles
- Convention Over Configuration — xUnit uses constructor injection and IDisposable for setup/teardown instead of attributes. Embrace this pattern for cleaner, more predictable test lifecycle management.
- Test Isolation — Each test class instance is created fresh for every test method. Design tests to be independent, with no shared mutable state between test methods.
- Parameterized Testing — Use
with[Theory]
,[InlineData]
, or[MemberData]
for data-driven tests instead of duplicating[ClassData]
methods with slight variations.[Fact] - Meaningful Assertions — Use xUnit's built-in assertions (
,Assert.Equal
,Assert.Throws
) or FluentAssertions for expressive, readable test verification.Assert.Collection - Parallel by Default — xUnit runs test collections in parallel. Design tests accordingly and use
attributes to control parallelism when tests share resources.[Collection] - Arrange-Act-Assert — Structure every test with clear Arrange (setup), Act (execute), and Assert (verify) sections. Keep each section focused and minimal.
- Test Naming — Use descriptive method names that describe the scenario and expected outcome:
.MethodName_Scenario_ExpectedBehavior
Project Structure
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
Detailed Code Examples
Basic Fact and Theory Tests
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); } }
MemberData and ClassData for Complex Scenarios
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); } }
Class Fixtures for Shared Context
// 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); } }
Collection Fixtures for Cross-Class Sharing
// 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); } }
ASP.NET Core Integration Tests
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); } }
Custom Assertions and Test Helpers
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 }; }
Parallel Execution Configuration
// 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 }
CI/CD Integration (GitHub Actions)
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'
Best Practices
- Use [Theory] for data-driven tests to avoid duplicating test logic across multiple [Fact] methods with similar patterns.
- Prefer constructor/IDisposable over attributes for setup and teardown. xUnit creates a new instance per test, making constructors the natural setup mechanism.
- Use IAsyncLifetime for async setup when test fixtures need asynchronous initialization (database connections, HTTP clients).
- Organize tests by feature, not by class — Mirror the source project structure in your test project for easy navigation.
- Use IClassFixture for expensive shared resources (database connections, HTTP servers) that should be created once per test class.
- Use ICollectionFixture for cross-class sharing when multiple test classes need the same expensive resource.
- Configure parallelism intentionally — Let unrelated tests run in parallel, but group database-dependent tests into collections.
- Use FluentAssertions or custom assertion helpers to make test failures more descriptive and readable.
- Follow the Arrange-Act-Assert pattern strictly. Use blank lines to visually separate the three sections.
- Mock external dependencies using Moq or NSubstitute. Never let unit tests make real HTTP calls or database queries.
Anti-Patterns to Avoid
- Avoid shared mutable state between tests. xUnit creates new instances, but static fields persist. Never use
mutable fields in test classes.static - Avoid [Fact] for parameterized tests — Duplicate test methods with different inputs should be refactored to [Theory] with [InlineData].
- Avoid catching exceptions manually — Use
orAssert.Throws<T>()
instead of try-catch blocks.Assert.ThrowsAsync<T>() - Avoid complex test setup — If a test requires more than 10 lines of arrangement, extract setup into builder methods or fixtures.
- Avoid testing private methods directly — Test public API behavior. If private methods need testing, the class likely violates SRP.
- Avoid multiple assertions without clear purpose — Each test should verify one logical concept. Use
in xUnit v3 for grouped assertions.Assert.Multiple() - Avoid ignoring test output — Use
for diagnostic logging instead ofITestOutputHelper
, which xUnit does not capture.Console.WriteLine - Avoid hardcoded connection strings — Use environment variables or configuration files for test infrastructure settings.
- Avoid skipping tests without explanation —
must always include a meaningful reason and a tracking issue.[Fact(Skip = "reason")] - Avoid test logic (if/else, loops) — Tests should be linear and predictable. Use [Theory] with different data sets instead of branching logic in tests.