Expert-level C# unit testing skill based on ISTQB standards and best practices. Use this skill when creating, reviewing, or refactoring unit tests for C# applications (.NET/ASP.NET Core)...
This skill provides comprehensive guidance for creating high-quality unit tests in C# following ISTQB (International Software Testing Qualifications Board) standards and industry best practices.
Use this skill when you need to:
Unit tests primarily address the Component Testing (unit) level, which verifies:
Every unit test must have a single, clear purpose. Identify:
[Public Method Name]_[Scenario]_[Expected Result]
Example: CalculateDiscount_WithValidPercentage_ReturnsCorrectAmount
Follow these naming rules:
Every test follows the AAA pattern:
[Fact]
public void Method_Scenario_ExpectedResult()
{
// Arrange: Set up test data and dependencies
var service = new CalculationService();
var input = 100m;
// Act: Execute the method being tested
var result = service.CalculateDiscount(input, 0.10m);
// Assert: Verify the outcome
Assert.Equal(90m, result);
}
Each test should be:
| Characteristic | Description |
|---|---|
| Isolated | No dependencies on other tests; tests can run in any order |
| Deterministic | Always passes or fails consistently (no random/time-dependent behavior) |
| Focused | Tests one logical concept; multiple assertions only if verifying single behavior |
| Readable | Clear intent without requiring code navigation |
| Fast | Executes in milliseconds; uses mocks for external dependencies |
| Maintainable | Uses DRY principle; minimizes setup complexity |
| Reliable | Fails only when actual behavior changes, not due to environmental factors |
Divide input domain into classes where behavior should be identical:
// For age-based eligibility
// Partition 1: < 18 (invalid)
// Partition 2: 18-65 (eligible)
// Partition 3: > 65 (senior eligible)
Create tests for one valid representative and one invalid representative per partition.
Test at partition boundaries ± 1:
[Theory]
[InlineData(17)] // Just below boundary
[InlineData(18)] // Boundary
[InlineData(19)] // Just above boundary
public void IsEligible_AgeAtBoundary_ReturnsExpected(int age)
{
// Test implementation
}
For objects with state machines, test valid transitions:
State A --[Valid Trigger]--> State B --[Valid Trigger]--> State C
Invalid transitions from each state should also be tested
For complex logic with multiple conditions:
| Condition A | Condition B | Expected Result |
|---|---|---|
| True | True | X |
| True | False | Y |
| False | True | Z |
| False | False | W |
Create a test for each row.
Statement Coverage (Line Coverage): 70-80% minimum
Branch Coverage (Decision Coverage): 80%+ target
Path Coverage: Target for critical paths
| Type | Purpose | When to Use |
|---|---|---|
| Stub | Returns predetermined values | External API, database queries |
| Mock | Verifies method calls and arguments | Dependency verification |
| Spy | Records interactions while calling real method | Partial mocking |
| Fake | Lightweight working implementation | In-memory database, file system |
Use Moq library for creating test doubles:
// Mock a dependency
var mockRepository = new Mock<IUserRepository>();
mockRepository
.Setup(r => r.GetUser(It.IsAny<int>()))
.ReturnsAsync(new User { Id = 1, Name = "Test" });
// Verify it was called
mockRepository.Verify(r => r.GetUser(1), Times.Once);
Always inject dependencies to enable testing:
// Bad: Hard to test
public class UserService
{
private readonly IRepository _repo = new Repository();
}
// Good: Testable with mock injection
public class UserService
{
private readonly IRepository _repo;
public UserService(IRepository repo) => _repo = repo;
}
[Fact]
public void Process_WithInvalidInput_ThrowsArgumentException()
{
var service = new Service();
// Assert that exception is thrown
var ex = Assert.Throws<ArgumentException>(
() => service.Process(null)
);
// Verify exception details
Assert.Contains("required", ex.Message, StringComparison.OrdinalIgnoreCase);
}
// For async operations
[Fact]
public async Task ProcessAsync_WithInvalidInput_ThrowsArgumentException()
{
var service = new Service();
await Assert.ThrowsAsync<ArgumentException>(
() => service.ProcessAsync(null)
);
}
Always verify:
Use [Theory] and [InlineData] to test multiple inputs:
[Theory]
[InlineData(0, 0)] // Boundary
[InlineData(1, 1)] // Single item
[InlineData(100, 100)] // Large value
[InlineData(-1, null)] // Invalid
public void Calculate_WithVariousInputs_ReturnsExpected(int input, int? expected)
{
var result = Calculator.Process(input);
Assert.Equal(expected, result);
}
// Or using MemberData for complex data
[Theory]
[MemberData(nameof(GetTestData))]
public void Test_WithComplexData(int input, object expected)
{
// Test implementation
}
public static IEnumerable<object[]> GetTestData()
{
yield return new object[] { 1, new { Value = 1 } };
yield return new object[] { 2, new { Value = 2 } };
}
// Use IAsyncLifetime for async setup/cleanup
public class TestClass : IAsyncLifetime
{
private readonly IRepository _repository;
public TestClass()
{
_repository = new TestRepository();
}
public async Task InitializeAsync()
{
// Async setup (e.g., database initialization)
await _repository.InitializeAsync();
}
public async Task DisposeAsync()
{
// Cleanup
await _repository.ClearAsync();
}
[Fact]
public async Task Test_WithAsyncSetup()
{
// Test body
}
}
Create reusable test data:
public class UserBuilder
{
private string _name = "TestUser";
private int _age = 25;
public UserBuilder WithName(string name)
{
_name = name;
return this;
}
public UserBuilder WithAge(int age)
{
_age = age;
return this;
}
public User Build() => new User { Name = _name, Age = _age };
}
// Usage
var user = new UserBuilder()
.WithAge(30)
.Build();
// Bad: Generic
Assert.True(result == expected);
// Good: Specific
Assert.Equal(expected, result);
Assert.NotNull(user);
Assert.Empty(list);
Assert.Contains("text", result);
Only when testing a single cohesive behavior:
[Fact]
public void User_Created_IsInitializedCorrectly()
{
var user = new User("John", 30);
// All assertions verify the SAME behavior: proper initialization
Assert.Equal("John", user.Name);
Assert.Equal(30, user.Age);
Assert.False(user.IsActive);
}
[Fact]
public async Task GetUser_WithValidId_ReturnsUser()
{
// Setup
var repository = new UserRepository();
// Act
var user = await repository.GetUserAsync(1);
// Assert
Assert.NotNull(user);
Assert.Equal(1, user.Id);
}
// Avoid .Result (causes deadlocks)
[Fact]
public void BadAsyncTest()
{
// WRONG: This can deadlock
var result = _service.GetDataAsync().Result;
}
[Fact]
public async Task Operation_WithCancellation_ThrowsOperationCanceledException()
{
var cts = new CancellationTokenSource();
cts.CancelAfter(100);
await Assert.ThrowsAsync<OperationCanceledException>(
() => _service.LongOperationAsync(cts.Token)
);
}
When reviewing C# unit tests, verify:
| Pitfall | Issue | Solution |
|---|---|---|
| Magic values | Hard to understand test intent | Use named variables: const int ValidAge = 25; |
| Test interdependency | One failing test breaks others | Ensure each test is completely independent |
| Over-mocking | Tests pass but integration fails | Mock only external dependencies, test real logic |
| Brittle assertions | Fails on unrelated changes | Assert on behavior, not implementation details |
| Ignored tests | Dead code accumulates | Remove or fix; never use [Ignore] long-term |
| Excessive setup | Tests hard to understand | Extract to helper methods or builders |
| Testing implementation | Tests break during refactoring | Test behavior/contracts, not internal details |
| No negative tests | Missing error scenarios | Always test error cases and boundaries |
| Async antipatterns | Deadlocks or race conditions | Use async/await, avoid .Result/.Wait() |
For detailed guidance on specific topics, see:
references/istqb-standards-summary.md - ISTQB Foundation conceptsreferences/test-design-techniques.md - Equivalence partitioning, boundary analysis, decision tablesreferences/xunit-framework-guide.md - xUnit framework features and best practicesreferences/mocking-patterns.md - Moq library patterns and anti-patternsreferences/async-testing-guide.md - Comprehensive async/await testing strategiesSee examples/ directory for annotated example tests:
calculator-service.tests.cs - Basic arithmetic with boundary testinguser-repository.tests.cs - Data access with mockingauthorization-service.tests.cs - Business logic with decision tablesasync-api-service.tests.cs - Async operations and exception handlingunit testing, C#, .NET, ASP.NET Core, xUnit, Moq, ISTQB, test design, TDD, test coverage, mocking, assertions, async testing