-
Notifications
You must be signed in to change notification settings - Fork 1
Tabular Attributes
Added in v2.32.0 | Auto-verify & TUnit support added in v2.33.0
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 at least an order of magnitude slower than unit tests, even when ran in memory only. Even when sharing a WebApplicationFactory across tests via a collection fixture, each individual test case still pays for HTTP request construction, middleware pipeline traversal, response parsing, and xUnit's per-test lifecycle overhead. That might only be 10-200ms per case, but 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 per-test lifecycle cost once while still exercising every scenario through your full component test stack.
// 20 test cases × ~30ms each = 600ms just for field validation (even with shared factory)
[Theory]
[InlineData("")] // ← separate test lifecycle, HTTP pipeline, response parse
[InlineData(null)] // ← separate test lifecycle, HTTP pipeline, response parse
[InlineData("x")] // ← separate test lifecycle, HTTP pipeline, response parse
// ... 17 more rows, each paying per-test overhead
public async Task Create_user_rejects_invalid_email(string? email) { ... }// 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.
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().
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
}-
[HeadIn("Missing Ingredient")]declares input column names and acts as the data source (replaces[InlineData]/[MemberData]). -
[HeadOut("Status", "Error Message")]declares expected output column names. -
[Inputs("Eggs")]defines one row of input data. Add as many rows as you need. -
[Outputs("BadRequest", "The Eggs field is required.")]defines the corresponding expected output row. -
TabularInputs<T>is anIReadOnlyList<T>— iterate withforeachto process all rows in one test execution. -
TabularOutputs<T>collects actual results viaRecordActualResult()and compares them positionally against expected rows.
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.
- 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
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.
| 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 | 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.
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);
}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 endsVerification 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 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.
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.
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();
}
}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.
- Step Tracking — BDD-style step methods
- Generated Reports — how tabular parameters appear in reports
- Assertion Tracking — tracking assertion pass/fail
Getting Started
Common Tasks
Integration Guides
- Integration xUnit3
- Integration xUnit2
- Integration NUnit
- Integration MSTest
- Integration TUnit
- Integration BDDfy xUnit3
- Integration LightBDD xUnit2
- Integration LightBDD xUnit3
- Integration LightBDD TUnit
- Integration ReqNRoll xUnit2
- Integration ReqNRoll xUnit3
- Integration ReqNRoll TUnit
Extensions
- Integration AtlasDataApi Extension
- Integration BigQuery Extension
- Integration Bigtable Extension
- Integration BlobStorage Extension
- Integration ClickHouse Extension
- Integration CloudStorage Extension
- Integration CosmosDB Extension
- Integration Dapper Extension
- Integration DynamoDB Extension
- Integration EF Core Relational Extension
- Integration Elasticsearch Extension
- Integration EventBridge Extension
- Integration EventHubs Extension
- Integration Grpc Extension
- Integration Kafka Extension
- Integration MassTransit Extension
- Integration MongoDB Extension
- Integration MySqlConnector Extension
- Integration Npgsql Extension
- Integration Oracle Extension
- Integration PubSub Extension
- Integration Redis Extension
- Integration S3 Extension
- Integration ServiceBus Extension
- Integration SNS Extension
- Integration Spanner Extension
- Integration SqlClient Extension
- Integration Sqlite Extension
- Integration SQS Extension
- Integration StorageQueues Extension
- Integration OpenTelemetry Extension
- Integration DispatchProxy Extension
- Integration MediatR Extension
- Integration PlantUML IKVM
Configuration
- Tracking Dependencies
- Tracking Custom Dependencies
- HTTP Tracking Setup
- Report Configuration
- Diagram Customisation
- Phase-Aware Tracking
- Content Formatting
- PlantUML Server Configuration
Features
- Generated Reports
- Search Syntax
- Component Diagrams
- PlantUML Browser Rendering
- Inline SVG Rendering
- Internal Flow Tracking
- Tags and Attributes
- Excluding Requests
- Excluded Headers
- Multi-Host Test Architectures
- Event-Driven Architecture Testing
- Service Bus Tracking Patterns
- Background Thread Correlation
- Parallel-Safe Background Correlation
- Event & Message Tracking
- Assertion Tracking
- Step Tracking
- Tabular Attributes
- Large Response and Diagram Handling
- Diagnostics and Debugging
- CI Summary Integration
- CI Artifact Upload
- Merging Parallel Reports
Reference