Writing Tests with xUnit

[Fact] tests one case, [Theory] with [InlineData] tests many, and Assert checks the result. Arrange, act, assert.

Syntax[Fact] public void Method_Situation_ExpectedResult() { Assert.Equal(expected, actual); }

xUnit is a test framework: a library of attributes and checks, plus a runner that finds and executes your test methods. An attribute is a label in square brackets attached to a method, which tools read to decide what to do with it.

Fact and Theory

[Fact] marks a method as one test case. The method takes no parameters and returns void. [Theory] marks a test that takes parameters, and each [InlineData] attribute supplies one set of arguments. A theory with three [InlineData] lines is the same thing as three separate [Fact] methods that differ only in their numbers; the runner reports it as three separate tests, each named with its arguments. Use [Theory] when only the data changes and [Fact] when the situation changes.

Arrange, act, assert

Most tests have three parts, and a blank line between them makes a test readable in one glance. Arrange builds the inputs. Act calls the one method under test. Assert states what should now be true.

The assertions worth knowing first: Assert.Equal(expected, actual), Assert.True and Assert.False, Assert.Null and Assert.NotNull, Assert.Contains, Assert.Empty and Assert.Single for collections, and Assert.Throws<TException> for code that should fail. Expected value first, actual second, every time.

Assert.Equal compares by value for strings, numbers and records, and by reference for ordinary classes that do not override equality. That difference surprises people more than anything else in the API.

Example

Example · csharp
using Xunit;

namespace Shop.Tests;

public static class Pricing
{
    // Price in whole cents after a percentage discount.
    public static int Discount(int cents, int percent)
    {
        return cents - cents * percent / 100;
    }
}

public class PricingTests
{
    [Fact]
    public void Discount_TenPercentOf2000_Returns1800()
    {
        int cents = 2000;                           // arrange

        int result = Pricing.Discount(cents, 10);   // act

        Assert.Equal(1800, result);                 // assert
    }

    // One method, three test cases. Equivalent to three [Fact] methods.
    [Theory]
    [InlineData(100, 0, 100)]
    [InlineData(100, 50, 50)]
    [InlineData(100, 100, 0)]
    public void Discount_ValidPercent_ReturnsExpected(int cents, int percent, int expected)
    {
        Assert.Equal(expected, Pricing.Discount(cents, percent));
    }
}

// dotnet test reports: Failed: 0, Passed: 4, Skipped: 0, Total: 4

When to use it

  • Cover the boundary values of a calculation (zero, one, the maximum) as separate theory cases.
  • Lock in the exception type and parameter name a public method throws for invalid input.
  • Turn a bug report into one named [Fact] that reproduces it before you touch the code.

More examples

The longhand the theory replaces

Worth writing out once so the short form is readable later. The longhand gives each case a descriptive name, which is better when the cases mean genuinely different things. The theory removes the copied body, which is better when the only difference is a number. Both produce three results in the test report, so a failure tells you which case broke either way.

Example · csharp
using Xunit;

namespace Shop.Tests;

public class PricingLonghandTests
{
    // These three methods are exactly what the [Theory] above compiles down to
    // in terms of what the runner does: three independent test cases.
    [Fact]
    public void Discount_ZeroPercent_ReturnsFullPrice()
    {
        Assert.Equal(100, Pricing.Discount(100, 0));
    }

    [Fact]
    public void Discount_FiftyPercent_ReturnsHalfPrice()
    {
        Assert.Equal(50, Pricing.Discount(100, 50));
    }

    [Fact]
    public void Discount_HundredPercent_ReturnsZero()
    {
        Assert.Equal(0, Pricing.Discount(100, 100));
    }
}

Asserting that code throws

Failing correctly is behaviour too, and this is how you pin it down. Assert.Throws<T> requires that exact type, not a derived one, and returns the caught exception so you can check a property such as ParamName. Prefer asserting on the type or a structured property over asserting on the message text, which tends to be reworded later.

Example · csharp
using System;
using Xunit;

namespace Shop.Tests;

public static class Quantities
{
    public static int Parse(string text)
    {
        int value = int.Parse(text);

        if (value <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(text), "Quantity must be positive.");
        }

        return value;
    }
}

public class QuantitiesTests
{
    [Fact]
    public void Parse_Zero_ThrowsArgumentOutOfRange()
    {
        // The lambda is not called until Assert.Throws calls it, so the
        // exception happens inside the assertion and is caught there.
        ArgumentOutOfRangeException error = Assert.Throws<ArgumentOutOfRangeException>(
            () => Quantities.Parse("0"));

        Assert.Equal("text", error.ParamName);
    }
}

[MemberData] for cases that cannot be attribute constants

Reach for [MemberData] when the cases involve decimal, DateTime, or constructed objects, none of which can appear inside [InlineData]. The member must be public and static, and nameof keeps the link checked by the compiler instead of relying on a string. It costs more lines than [InlineData], so use it only when the simpler form cannot express the data.

Example · csharp
using System.Collections.Generic;
using Xunit;

namespace Shop.Tests;

public class TaxTests
{
    // A public static method returning one object array per test case.
    public static IEnumerable<object[]> Cases()
    {
        yield return new object[] { 100m, 0.20m, 120m };
        yield return new object[] { 50m, 0.00m, 50m };
    }

    [Theory]
    [MemberData(nameof(Cases))]
    public void WithTax_ReturnsExpectedTotal(decimal net, decimal rate, decimal expected)
    {
        decimal total = net + net * rate;

        Assert.Equal(expected, total);
    }
}

Discussion

  • Be the first to comment on this lesson.