Back to skills
extension
Category: Development & EngineeringNo API key required

csharp-unit-testing

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). Applies test design techniques, coverage strategies, and quality assurance principles from ISTQB Foundation and Advanced levels.

personAuthor: jakexiaohubgithub

C# Unit Testing Skill (ISTQB-Compliant)

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.

When to Use This Skill

Use this skill when you need to:

  • Create unit tests for C# classes, methods, and business logic
  • Review test quality for compliance with ISTQB standards and best practices
  • Design test cases using systematic test design techniques (equivalence partitioning, boundary value analysis, etc.)
  • Refactor existing tests to improve maintainability, coverage, and clarity
  • Establish test strategies for ensuring comprehensive coverage
  • Write integration tests that bridge unit and system testing levels
  • Debug failing tests and improve test reliability
  • Set up test fixtures, mocks, and test data properly

ISTQB Testing Levels

Unit tests primarily address the Component Testing (unit) level, which verifies:

  • Individual units of code work as specified
  • Interactions between closely related components
  • Error handling and boundary conditions
  • Performance characteristics at the component level

Core Testing Principles

1. Test Purpose & Scope

Every unit test must have a single, clear purpose. Identify:

  • What is being tested (specific method/behavior)
  • Why it matters (business value, risk mitigation)
  • How it will be validated (assertions, exceptions)

2. Naming Convention (AAA + Clear Intent)

[Public Method Name]_[Scenario]_[Expected Result]

Example: CalculateDiscount_WithValidPercentage_ReturnsCorrectAmount

Follow these naming rules:

  • Use descriptive names (3-5 words minimum)
  • Avoid cryptic abbreviations
  • Express the expected outcome, not implementation details
  • Use PascalCase consistently

3. Test Structure (Arrange-Act-Assert)

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

4. Test Characteristics (ISTQB Standards)

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 |

Test Design Techniques (ISTQB)

Equivalence Partitioning

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.

Boundary Value Analysis

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
}

State Transition Testing

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

Decision Table Testing

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.

Test Coverage Strategy

Coverage Levels (Hierarchy)

  1. Statement Coverage (Line Coverage): 70-80% minimum

    • Every executable line executed at least once
    • Does NOT guarantee logic errors are caught
  2. Branch Coverage (Decision Coverage): 80%+ target

    • Every if/else condition evaluated true AND false
    • Stronger than statement coverage
  3. Path Coverage: Target for critical paths

    • Every possible execution path tested
    • Often infeasible for complex code (combinatorial explosion)

Coverage Guidelines

  • Aim for 80%+ overall coverage for business logic
  • 100% coverage for:
    • Security-critical operations
    • Financial calculations
    • Authorization/authentication logic
    • Core business rules
  • Lower coverage acceptable for:
    • UI boilerplate
    • Framework-generated code
    • Trivial getters/setters
    • Logging-only methods

Mocking & Test Doubles (ISTQB)

Test Double Types

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

Dependency Injection in Tests

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

Exception Testing

Testing Expected Exceptions

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

Exception Validation Strategy

Always verify:

  1. Correct exception type is thrown
  2. Message contains actionable information
  3. Inner exception (if present) provides context
  4. Both happy path AND error paths are tested

Parameterized Testing (xUnit)

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

Test Fixtures & Setup

Fixture Management

// 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
    }
}

Test Data Builders

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();

Assertion Best Practices

Use Specific Assertions

// Bad: Generic
Assert.True(result == expected);

// Good: Specific
Assert.Equal(expected, result);
Assert.NotNull(user);
Assert.Empty(list);
Assert.Contains("text", result);

Multiple Assertions (When Appropriate)

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

Async Testing

Testing Async Methods

[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;
}

Testing Cancellation

[Fact]
public async Task Operation_WithCancellation_ThrowsOperationCanceledException()
{
    var cts = new CancellationTokenSource();
    cts.CancelAfter(100);
    
    await Assert.ThrowsAsync<OperationCanceledException>(
        () => _service.LongOperationAsync(cts.Token)
    );
}

Code Review Checklist for Unit Tests

When reviewing C# unit tests, verify:

  • [ ] Test name clearly describes scenario and expected result
  • [ ] AAA structure is evident (Arrange, Act, Assert)
  • [ ] Single responsibility (one logical concept per test)
  • [ ] No test interdependencies (can run in any order, isolated)
  • [ ] Appropriate use of mocks (external dependencies mocked, internal logic real)
  • [ ] Assertions are specific (not generic True/False checks)
  • [ ] Exception cases tested (both happy path and error paths)
  • [ ] Boundary values tested (if applicable via equivalence partitioning)
  • [ ] Setup/teardown is minimal (only essential initialization)
  • [ ] No hardcoded delays (no Thread.Sleep or Task.Delay)
  • [ ] Async/await used correctly (no .Result/.Wait())
  • [ ] Test data is realistic (represents real-world scenarios)
  • [ ] Coverage is adequate (80%+ for business logic)
  • [ ] Tests are deterministic (no time/random dependencies)
  • [ ] Documentation is clear (comments for non-obvious logic)

Common Pitfalls to Avoid

| 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() |

Links to Reference Files

For detailed guidance on specific topics, see:

  • references/istqb-standards-summary.md - ISTQB Foundation concepts
  • references/test-design-techniques.md - Equivalence partitioning, boundary analysis, decision tables
  • references/xunit-framework-guide.md - xUnit framework features and best practices
  • references/mocking-patterns.md - Moq library patterns and anti-patterns
  • references/async-testing-guide.md - Comprehensive async/await testing strategies

Example Tests

See examples/ directory for annotated example tests:

  • calculator-service.tests.cs - Basic arithmetic with boundary testing
  • user-repository.tests.cs - Data access with mocking
  • authorization-service.tests.cs - Business logic with decision tables
  • async-api-service.tests.cs - Async operations and exception handling

Keywords

unit testing, C#, .NET, ASP.NET Core, xUnit, Moq, ISTQB, test design, TDD, test coverage, mocking, assertions, async testing