Skip to main content
๐Ÿ“œ WAYPOINT LESSON

Anatomy of a Test Framework

โญโญ intermediateโณ 18 min read๐Ÿ“ Lesson 135 of 265

AAA, naming, a runner that continues past failures, and parameterized tables โ€” the 60 lines under every test framework.

What a test framework actually is

Strip away the attributes and the runner UI, and a test framework is a foreach with a try/catch:

public sealed record TestResult(string Name, bool Passed, string Message);

public sealed class TestSummary
{
    public int Passed;
    public int Failed;
    public List<string> Failures = new();   // "name: message"
}

public static TestSummary Run(IEnumerable<(string Name, Action Body)> tests)
{
    var summary = new TestSummary();
    foreach (var (name, body) in tests)
    {
        try { body(); summary.Passed++; }
        catch (Exception ex)
        {
            summary.Failed++;
            summary.Failures.Add(name + ": " + ex.Message);
        }
    }
    return summary;
}

Two design decisions matter more than the rest:

  1. Keep going after a failure. A runner that stops at the first red test hides everything behind it. Real frameworks run every test and report all failures โ€” that is what makes a failed CI run useful.
  2. A test "fails" by throwing. Assertions are just small methods that throw on mismatch. xUnit's Assert.Equal and the Cj helper this platform grades with are the same idea.

AAA โ€” the shape of every test

Arrange the world, act once, assert on the outcome:

// Arrange
var cart = new Cart();
cart.Add("tea", price: 3m, qty: 2);
// Act
var total = cart.Total();
// Assert
// total == 6m

If you cannot see the three blocks, the test is doing too much โ€” split it.

Naming: the test name is the specification

Prefer sentence names that state the expected behavior: Total_sums_price_times_quantity, PlaceOrder_rejects_when_out_of_stock. When the name is right, the failure list reads like a to-do list; when it is wrong ("Test1", "works now"), every future reader re-derives the intent.

Parameterized tests: one body, many cases

Same behavior over many inputs โ†’ loop a table, don't copy the test:

var cases = new (string Input, int Expected)[]
{
    ("", 0), ("a", 1), ("ab", 2),
};
foreach (var (input, expected) in cases)
{
    // assert Count(input) == expected, labeling with input
}

Each iteration should carry its inputs in the failure message โ€” a bare "expected 2, got 3" over a table of 40 rows is unusable.

Check your understanding

  • Why must the runner continue after a failure? (One red test should not hide ten others โ€” CI reports all.)
  • What makes a test fail in every framework? (An exception escaping the test body.)