Skip to content

Tabular Attributes

Aryeh Citron edited this page May 10, 2026 · 7 revisions

Tabular Attributes

Added in v2.32.0 | Auto-verify & TUnit support added in v2.33.0

Why Tabular Attributes Exist

Component tests are valuable because they exercise your code through real HTTP endpoints, middleware, DI containers, and database layers — catching integration bugs that unit tests miss. But they're slow. A component test that boots a WebApplicationFactory, sends an HTTP request, and tears down might take 50-200ms per test case. When you have hundreds of simple validation scenarios (required fields, format rules, boundary values, status codes), that cost adds up fast.

The traditional trade-off is to fall back to unit tests for high-volume validation scenarios — sacrificing the confidence of testing through your real stack just to keep test run times manageable. Tabular attributes eliminate that trade-off. They let you batch many test cases into a single test execution, paying the setup/teardown cost once while still exercising every scenario through your full component test stack.

The problem: one test per case doesn't scale

// 20 test cases × 150ms each = 3 seconds just for field validation
[Theory]
[InlineData("")]          // ← separate WebApplicationFactory boot
[InlineData(null)]        // ← separate WebApplicationFactory boot
[InlineData("x")]         // ← separate WebApplicationFactory boot
// ... 17 more rows, each paying full setup cost
public async Task Create_user_rejects_invalid_email(string? email) { ... }

The solution: batch cases into one execution

// 20 test cases × 1 execution = 150ms total
[Theory]
[HeadIn("Email"),   HeadOut("Status",     "Error")]
[Inputs(""),        Outputs("BadRequest", "Email is required")]
[Inputs(null),      Outputs("BadRequest", "Email is required")]
[Inputs("x"),       Outputs("BadRequest", "Invalid email format")]
// ... 17 more rows, all processed in one test run
public async Task Create_user_rejects_invalid_email(
    TabularInputs<EmailInput> inputs, TabularOutputs<ValidationResult> outputs)
{
    foreach (var input in inputs)
    {
        var response = await _client.PostAsJsonAsync("/users", input);
        outputs.RecordActualResult(await ParseResult(response));
    }
}

One WebApplicationFactory boot. One HTTP pipeline warmup. All 20 scenarios validated through the real stack. The report shows each row's expected vs actual result in a clear table, with per-row diagram delimiters showing each scenario's interactions in the sequence diagram.


Quick Start

xUnit v3 / TUnit (no .Verify() needed)

using TestTrackingDiagrams.TabularAttributes;

public class CakeValidationTests
{
    [Theory]
    [HeadIn("Missing Ingredient"), HeadOut("Status",     "Error Message")]
    [Inputs("Eggs"),               Outputs("BadRequest", "The Eggs field is required.")]
    [Inputs("Milk"),               Outputs("BadRequest", "The Milk field is required.")]
    [Inputs("Flour"),              Outputs("BadRequest", "The Flour field is required.")]
    public async Task Creating_cake_without_ingredient_returns_error(
        TabularInputs<MissingIngredient> inputs,
        TabularOutputs<CakeErrorResult> outputs)
    {
        foreach (var input in inputs)
        {
            var response = await _client.PostAsJsonAsync("/cake", BuildRequest(input));
            outputs.RecordActualResult(await ParseError(response));
        }
        // Automatically verified after the test — no .Verify() call needed
    }
}

On xUnit v3 and TUnit, the framework automatically disposes TabularOutputs<T> after the test, which triggers verification. You never need to call .Verify().

xUnit v2 / NUnit 4 / MSTest (explicit .Verify() required)

public async Task Creating_cake_without_ingredient_returns_error(
    TabularInputs<MissingIngredient> inputs,
    TabularOutputs<CakeErrorResult> outputs)
{
    foreach (var input in inputs)
    {
        var response = await _client.PostAsJsonAsync("/cake", BuildRequest(input));
        outputs.RecordActualResult(await ParseError(response));
    }
    outputs.Verify(); // Required — these frameworks don't auto-dispose test data
}

How it works

  1. [HeadIn("Missing Ingredient")] declares input column names and acts as the data source (replaces [InlineData]/[MemberData]).
  2. [HeadOut("Status", "Error Message")] declares expected output column names.
  3. [Inputs("Eggs")] defines one row of input data. Add as many rows as you need.
  4. [Outputs("BadRequest", "The Eggs field is required.")] defines the corresponding expected output row.
  5. TabularInputs<T> is an IReadOnlyList<T> — iterate with foreach to process all rows in one test execution.
  6. TabularOutputs<T> collects actual results via RecordActualResult() and compares them positionally against expected rows.

When to Use Tabular Attributes

Tabular attributes are designed for high-volume, structurally similar test cases that you want to run through your real component test stack without paying per-case setup costs.

Ideal use cases

  • API validation rules — required fields, format validation, boundary values, error messages
  • Status code mapping — different inputs producing different HTTP status codes
  • Authorization checks — different roles/claims producing allowed/denied outcomes
  • Data transformation — input records mapped to expected output records
  • Batch operations — verifying a service correctly processes multiple items

When to use native parameterisation instead

If each test case needs independent pass/fail status (e.g. you want CI to report exactly which case failed as a separate test), use your framework's native parameterisation ([InlineData], [TestCase], [Arguments], etc.). Tabular attributes run all rows in a single test — if row 3 fails, the test fails, and the report table shows exactly which row mismatched, but it's still one test result.


Attributes

Core Attributes (in TestTrackingDiagrams package)

Attribute AllowMultiple Purpose
[Inputs(...)] Yes One row of input values (positional, matching [HeadIn] columns)
[Outputs(...)] Yes One row of expected output values (positional, matching [HeadOut] columns)
[HeadOut(...)] No Declares output column names

Framework-Specific [HeadIn] (separate packages)

Framework Package
xUnit v3 TestTrackingDiagrams.xUnit3
xUnit v2 TestTrackingDiagrams.xUnit2
NUnit 4 TestTrackingDiagrams.NUnit4
MSTest TestTrackingDiagrams.MSTest
TUnit TestTrackingDiagrams.TUnit

All [HeadIn] attributes live in namespace TestTrackingDiagrams.TabularAttributes, so a single using covers everything.


Data Types

TabularInputs<T>

IReadOnlyList<T> containing deserialized input rows. Iterating with foreach automatically emits per-row diagram delimiters (hnote annotations that visually separate each row's interactions in the sequence diagram).

// Access by index
var first = inputs[0];

// Iterate (emits diagram delimiters — recommended)
foreach (var input in inputs)
{
    await sut.Process(input);
}

TabularOutputs<T>

IReadOnlyList<T> containing expected output rows. Collects actual results and verifies them against expectations.

// Record each actual output
outputs.RecordActualResult(actualResult);

Verification behaviour by framework:

Framework .Verify() needed? Mechanism
xUnit v3 No DisposalTracker — xUnit v3 disposes test data after the test
TUnit No TestBuilderContext.Events.OnDispose — TUnit calls disposal hooks
xUnit v2 Yes No disposal tracking — call .Verify() explicitly
NUnit 4 Yes No disposal tracking — call .Verify() explicitly
MSTest Yes No disposal tracking — call .Verify() explicitly

On any framework, you can also use using to trigger auto-verify:

using var o = outputs;
foreach (var input in inputs)
    o.RecordActualResult(Process(input));
// Verify() called automatically when `using` scope ends

Verification rules:

  • Matching rows: expected and actual at the same position are compared cell by cell.
  • Surplus rows: more actuals than expected → fail.
  • Missing rows: fewer actuals than expected → fail.

Column-to-Property Matching

Column names are matched to T's public properties using a sanitization process:

Column Name Matches Property
"Name" Name
"First Name" FirstName (spaces removed)
"Name & Age" NameAndAge (&And)
"username" USERNAME (case-insensitive)

If [HeadIn] is used without arguments ([HeadIn]), column names are inferred from T's public property names.


Real-World Example

This example from the Example.Api project tests missing ingredient validation for a cake endpoint — three scenarios exercised through the full component test stack in a single test execution:

[Scenario]
[HeadIn("Ingredient")][HeadOut("Response Status", "Error Message")]
[Inputs("Eggs"      )][Outputs("BadRequest",      "The Eggs field is required.")]
[Inputs("Milk"      )][Outputs("BadRequest",      "The Milk field is required.")]
[Inputs("Flour"     )][Outputs("BadRequest",      "The Flour field is required.")]
public async Task Calling_Create_Cake_Endpoint_Without_Ingredient()
{
    await Runner.RunScenarioAsync(
        given => A_valid_post_request_for_the_Cake_endpoint(),
        and => The_request_body_is_missing_a_specified_ingredient(
            TableFrom.Inputs<MissingIngredientFromCakeRequest>()),
        when => The_requests_are_sent_to_the_cake_post_endpoint(),
        then => The_response_http_status_and_error_message_should_be_matching(
            VerifiableTableFrom.Outputs<CakeErrorResult>()));
}

Without tabular attributes, this would be three separate tests — three WebApplicationFactory boots and three HTTP pipeline setups. With tabular attributes, it's one.


Inputs Only (No Outputs)

For cases where you just need to exercise multiple inputs without comparing structured outputs:

[Theory]
[HeadIn("Name", "Age")]
[Inputs("Alice", 30)]
[Inputs("Bob",   25)]
[Inputs("Carol", 45)]
public async Task Process_people(TabularInputs<Person> inputs)
{
    foreach (var person in inputs)
    {
        var response = await _client.PostAsJsonAsync("/people", person);
        response.EnsureSuccessStatusCode();
    }
}

Step Tracking Integration

When TabularInputs<T> or TabularOutputs<T> is passed as a step parameter, StepCollector automatically detects it and renders it as a tabular parameter in the report (instead of inline text):

Track.That.Given("the test data", "inputs", inputs);

This produces a table in the step parameters showing all rows and columns.


Related

Home


Demo


Getting Started

Common Tasks

Integration Guides

Extensions

Configuration

Features

Reference

Clone this wiki locally