Unit Testing with xUnit

Master unit testing fundamentals using xUnit framework, including test organization, assertions, AAA pattern, and best practices

Testing
xUnit
Unit Testing
Quality
TDD
25 min read

Overview

Unit testing is fundamental to building maintainable, reliable software. Understanding xUnit thoroughly is essential for .NET developers at all levels, as it's commonly used in production applications and frequently discussed in technical interviews.

Core Concepts

Fact vs Theory

// Fact - single test case
[Fact]
public void IsEven_EvenNumber_ReturnsTrue()
{
    var result = MathHelper.IsEven(4);
    Assert.True(result);
}

// Theory - multiple test cases with data
[Theory]
[InlineData(2)]
[InlineData(4)]
[InlineData(100)]
public void IsEven_EvenNumbers_ReturnsTrue(int number)
{
    var result = MathHelper.IsEven(number);
    Assert.True(result);
}

[Theory]
[InlineData(1, false)]
[InlineData(2, true)]
[InlineData(3, false)]
[InlineData(4, true)]
public void IsEven_VariousNumbers_ReturnsExpectedResult(int number, bool expected)
{
    var result = MathHelper.IsEven(number);
    Assert.Equal(expected, result);
}

AAA Pattern (Arrange-Act-Assert)

public class UserServiceTests
{
    [Fact]
    public void CreateUser_ValidData_ReturnsUserId()
    {
        // Arrange - setup test data and dependencies
        var service = new UserService();
        var request = new CreateUserRequest
        {
            Name = "John Doe",
            Email = "john@example.com"
        };

        // Act - execute the method under test
        var userId = service.CreateUser(request);

        // Assert - verify the expected outcome
        Assert.True(userId > 0);
    }
}

Test Naming Conventions

// Pattern: MethodName_Scenario_ExpectedBehavior

[Fact]
public void GetUser_ExistingId_ReturnsUser() { }

[Fact]
public void GetUser_NonExistingId_ReturnsNull() { }

[Fact]
public void SaveUser_NullUser_ThrowsArgumentNullException() { }

[Fact]
public void ValidateEmail_InvalidFormat_ReturnsFalse() { }

Common Assertions

public class AssertionExamples
{
    [Fact]
    public void DemonstrateAssertions()
    {
        // Equality
        Assert.Equal(5, 2 + 3);
        Assert.NotEqual(5, 2 + 2);

        // Boolean
        Assert.True(5 > 3);
        Assert.False(5 < 3);

        // Null checks
        Assert.NotNull(new object());
        Assert.Null(null);

        // Collections
        var list = new List<int> { 1, 2, 3 };
        Assert.Contains(2, list);
        Assert.DoesNotContain(5, list);
        Assert.Empty(new List<int>());
        Assert.NotEmpty(list);
        Assert.Equal(3, list.Count);

        // Exceptions
        Assert.Throws<ArgumentNullException>(() => throw new ArgumentNullException());

        // Types
        Assert.IsType<string>("hello");
        Assert.IsAssignableFrom<IEnumerable<int>>(list);

        // Ranges (for floating point)
        Assert.Equal(3.14, 3.1415, precision: 2);
    }
}

Test Data from External Sources

// MemberData - from static method/property
public class CalculatorTests
{
    public static IEnumerable<object[]> AddTestData =>
        new List<object[]>
        {
            new object[] { 1, 2, 3 },
            new object[] { -1, 1, 0 },
            new object[] { 100, 200, 300 }
        };

    [Theory]
    [MemberData(nameof(AddTestData))]
    public void Add_VariousInputs_ReturnsCorrectSum(int a, int b, int expected)
    {
        var calculator = new Calculator();
        var result = calculator.Add(a, b);
        Assert.Equal(expected, result);
    }
}

// ClassData - from separate class
public class AddTestDataProvider : IEnumerable<object[]>
{
    public IEnumerator<object[]> GetEnumerator()
    {
        yield return new object[] { 1, 2, 3 };
        yield return new object[] { -1, 1, 0 };
        yield return new object[] { 100, 200, 300 };
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

[Theory]
[ClassData(typeof(AddTestDataProvider))]
public void Add_ClassData_ReturnsCorrectSum(int a, int b, int expected)
{
    var calculator = new Calculator();
    var result = calculator.Add(a, b);
    Assert.Equal(expected, result);
}

When to Use vs. When to Avoid

| Scenario | Use It? | Why | |----------|---------|-----| | Business logic, services | ✅ Yes | Core value - verify correctness | | Controllers/API endpoints | ⚠️ Depends | Consider integration tests for full flow | | Simple POCO mappings | ❌ No | Low value, high maintenance | | External API wrappers | ⚠️ Depends | Mock the external call, test your logic | | Framework-generated code | ❌ No | Trust the framework |

Common Patterns

Test Naming Convention

// Pattern: MethodName_Scenario_ExpectedBehavior
[Fact] public void GetUser_ValidId_ReturnsUser() { }
[Fact] public void GetUser_InvalidId_ReturnsNull() { }
[Fact] public void CreateUser_DuplicateEmail_ThrowsException() { }

Common Mistakes

Mistake: Testing Multiple Behaviors

// ❌ Bad - tests too much, unclear what's actually being verified
[Fact]
public void ProcessOrder_ValidOrder_Works()
{
    var result = service.ProcessOrder(order);

    Assert.NotNull(result);
    Assert.True(result.Id > 0);
    Assert.Equal(10, result.Total);
    Assert.Equal("Processed", result.Status);  // What are we really testing?
}

// ✅ Better - each test verifies one behavior
[Fact]
public void ProcessOrder_ValidOrder_AssignsId()
{
    var result = service.ProcessOrder(order);
    Assert.True(result.Id > 0);
}

[Fact]
public void ProcessOrder_ValidOrder_SetsStatusToProcessed()
{
    var result = service.ProcessOrder(order);
    Assert.Equal("Processed", result.Status);
}

Why: When a test with multiple assertions fails, you don't immediately know which behavior broke.

Mistake: Tests Depend on External Resources

// ❌ Bad - requires real database
[Fact]
public void GetUser_ReturnsUser()
{
    var service = new UserService(new SqlConnection(connectionString));  // Real DB!
    var user = service.GetUser(1);
    Assert.NotNull(user);
}

// ✅ Better - use test doubles
[Fact]
public void GetUser_ExistingUser_ReturnsUser()
{
    var fakeRepo = new FakeUserRepository();
    fakeRepo.Users.Add(new User { Id = 1, Name = "Test" });
    var service = new UserService(fakeRepo);

    var user = service.GetUser(1);

    Assert.NotNull(user);
}

Why: Tests must be fast, isolated, and repeatable. External dependencies make tests slow and flaky.

Practical Example

Scenario: Test a service that validates and creates users.

public class UserServiceTests
{
    [Fact]
    public void CreateUser_ValidData_ReturnsUserId()
    {
        // Arrange
        var repo = new FakeUserRepository();
        var service = new UserService(repo);
        var request = new CreateUserRequest { Name = "John", Email = "john@test.com" };

        // Act
        var userId = service.CreateUser(request);

        // Assert
        Assert.True(userId > 0);
        Assert.Single(repo.Users);
    }

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public void CreateUser_InvalidName_ThrowsArgumentException(string name)
    {
        var service = new UserService(new FakeUserRepository());
        var request = new CreateUserRequest { Name = name, Email = "test@test.com" };

        Assert.Throws<ArgumentException>(() => service.CreateUser(request));
    }
}

Interview Tips

Tip 1: Emphasize the AAA pattern - it makes tests readable and maintainable. Interviewers appreciate structured thinking.

Tip 2: Know when to use [Fact] vs [Theory] - Facts for single cases, Theories for parameterized tests with multiple inputs.

Tip 3: Understand test naming conventions - clear names document behavior and make failures easier to understand.

Common Interview Questions

  1. What's the difference between [Fact] and [Theory] in xUnit?

    • [Fact] is for a single test case with no parameters. [Theory] is for parameterized tests with data from [InlineData], [MemberData], or [ClassData].
  2. What is the AAA pattern in unit testing?

    • Arrange (setup test data), Act (execute method under test), Assert (verify results). Provides clear test structure and readability.
  3. How do you test for exceptions in xUnit?

    • Use Assert.Throws<TException>(() => /* code that throws */). It verifies the exception type is thrown and returns the exception for further assertions.
  4. What makes a good unit test?

    • Fast, isolated, repeatable, self-validating, timely (FIRST). Also: single responsibility, descriptive name, independent of execution order.
  5. How do you handle test data in xUnit?

    • [InlineData] for simple values, [MemberData] for data from methods/properties, [ClassData] for complex data from separate classes.
  6. What's the difference between Assert.Equal and Assert.Same?

    • Equal checks value equality (uses .Equals()). Same checks reference equality (same object instance).
  7. Why should unit tests be isolated?

    • Isolated tests are independent, don't share state, can run in any order or parallel. Makes tests reliable and easier to debug.

Related Topics

  • Mocking with Moq: Replace dependencies with test doubles
  • Integration Testing: Test multiple components together
  • TDD (Test-Driven Development): Write tests before code
  • Code Coverage: Measure how much code is tested