This guide provides step-by-step instructions for implementing TinyBDD best practices across the test suite.
/// <summary>Feature: Fluent API</summary>
public sealed class BddFluentApiTests(ITestOutputHelper output) : TinyBddXunitBase(output)
{
[Fact]
public async Task Scenario_Do_something()
{
string? tempDir = null;
try
{
await Given("setup", () => { tempDir = CreateTemp(); return "data"; })
.When("action", x => x + "!")
.Then("verify", x => x == "data!")
.AssertPassed();
}
finally
{
if (tempDir != null) Directory.Delete(tempDir, true);
}
}
}[Feature("Fluent API")]
public sealed class BddFluentApiTests(ITestOutputHelper output) : TinyBddXunitBase(output)
{
[Scenario("Do something")]
[Fact]
[Tag("smoke")]
public async Task DoSomething()
{
await Given("setup", CreateSetup)
.Finally("cleanup", CleanupTemp)
.When("action", PerformAction)
.Then("verify", VerifyResult)
.AssertPassed();
}
private static (string data, string tempDir) CreateSetup()
{
var tempDir = CreateTemp();
return ("data", tempDir);
}
private static void CleanupTemp((string data, string tempDir) ctx)
{
if (Directory.Exists(ctx.tempDir))
Directory.Delete(ctx.tempDir, true);
}
private static string PerformAction((string data, string tempDir) ctx) =>
ctx.data + "!";
private static bool VerifyResult(string result)
{
result.Should().Be("data!");
return true;
}
}For each test file:
- Add
[Feature("...")]attribute to class - Add
[Scenario("...")]attribute to each test method - Add
[Tag("...")]attributes for categorization - Remove "Scenario_" prefix from method names
- Extract lambda expressions to named methods
- Replace try/finally with
.Finally() - Use FluentAssertions in Then/And/But steps
- Ensure class inherits from
TinyBddXunitBase - Consider feature lifecycle hooks for repeated setup
Check and update TinyBDD version in Directory.Packages.props:
<PackageVersion Include="TinyBDD.Xunit" Version="1.0.0" />Latest version check:
dotnet list package --outdatedBefore:
/// <summary>Feature: Fluent API - Package Definition</summary>
public sealed class BddFluentApiTests(ITestOutputHelper output) : TinyBddXunitBase(output)After:
[Feature("Fluent API - Package Definition")]
public sealed class FluentApiTests(ITestOutputHelper output) : TinyBddXunitBase(output)Benefits:
- Appears in test output
- Creates xUnit trait for filtering:
-filter "Feature=Fluent API - Package Definition" - Better discoverability in Test Explorer
Before:
[Fact]
public async Task Scenario_Define_basic_package()After:
[Scenario("Define basic package")]
[Fact]
public async Task DefineBasicPackage()Benefits:
- Cleaner method names
- Scenario name in test output
- xUnit trait:
-filter "Scenario=Define basic package"
[Scenario("Generate package to disk")]
[Fact]
[Tag("integration")]
[Tag("file-system")]
[Tag("slow")]
public async Task GeneratePackageToDisk()Tag Categories:
- smoke: Quick sanity tests (< 100ms)
- integration: Tests with I/O or external dependencies
- slow: Tests taking > 1 second
- category: Domain-specific (e.g., "generation", "validation", "rendering")
Filter Examples:
# Run only smoke tests
dotnet test --filter "Tag=smoke"
# Exclude slow tests
dotnet test --filter "Tag!=slow"
# Integration tests only
dotnet test --filter "Tag=integration"Before:
await Given("a package ID", () => "MyPackage")
.When("defining package", id => Package.Define(id).Build())
.Then("package ID is set", def => def.Id == "MyPackage")
.AssertPassed();After:
await Given("a package ID", CreatePackageId)
.When("defining package", DefinePackage)
.Then("package ID is set", VerifyPackageId)
.AssertPassed();
// At class level
private static string CreatePackageId() => "MyPackage";
private static PackageDefinition DefinePackage(string id) =>
Package.Define(id).Build();
private static bool VerifyPackageId(PackageDefinition def)
{
def.Id.Should().Be("MyPackage");
return true;
}Benefits:
- Easier debugging (can set breakpoints)
- Better stack traces
- Reduced allocations (TinyBDD recommendation)
- Reusable across tests
- Better IDE support (Go to Definition, Find References)
Before:
string? tempDir = null;
try
{
await Given("setup", () =>
{
tempDir = Path.GetTempPath() + Guid.NewGuid();
Directory.CreateDirectory(tempDir);
return tempDir;
})
.When("do work", dir => /* ... */)
.Then("verify", result => /* ... */)
.AssertPassed();
}
finally
{
if (tempDir != null && Directory.Exists(tempDir))
Directory.Delete(tempDir, recursive: true);
}After:
await Given("setup", CreateTempDirectory)
.Finally("cleanup temp directory", CleanupDirectory)
.When("do work", DoWork)
.Then("verify", Verify)
.AssertPassed();
private static string CreateTempDirectory()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
return tempDir;
}
private static void CleanupDirectory(string tempDir)
{
if (Directory.Exists(tempDir))
Directory.Delete(tempDir, recursive: true);
}Benefits:
- Cleanup guaranteed even on assertion failures
- Shows in test output:
Finally cleanup temp directory [OK] 5 ms - Cleaner code, no try/finally
- Multiple Finally handlers supported
For tests with expensive setup (e.g., TestContainers, temp directories):
[Feature("Integration Tests with Database")]
public sealed class DatabaseIntegrationTests : TinyBddXunitBase
{
public DatabaseIntegrationTests(ITestOutputHelper output) : base(output) { }
protected override ScenarioChain<object>? ConfigureFeatureSetup()
{
return Given("a test database container", () =>
{
// This runs ONCE for all tests in this class
var container = new SqlServerTestcontainer();
container.StartAsync().Wait();
return (object)container;
});
}
protected override ScenarioChain<object>? ConfigureFeatureTeardown()
{
return Given("cleanup database", () =>
{
if (FeatureState is SqlServerTestcontainer container)
container.DisposeAsync().AsTask().Wait();
return new object();
});
}
[Scenario("First test uses database")]
[Fact]
public async Task FirstTest()
{
await GivenFeature<SqlServerTestcontainer>("the database")
.When("inserting data", async db => await InsertDataAsync(db))
.Then("data exists", success => success)
.AssertPassed();
}
[Scenario("Second test reuses same database")]
[Fact]
public async Task SecondTest()
{
// Reuses same container from feature setup
await GivenFeature<SqlServerTestcontainer>("the database")
.When("querying data", async db => await QueryDataAsync(db))
.Then("data found", result => result != null)
.AssertPassed();
}
}Benefits:
- Faster test execution (setup once, not per test)
- Shared expensive resources
- Automatic cleanup via
ConfigureFeatureTeardown()
[Theory]
[InlineData("net8.0", "8.0")]
[InlineData("net9.0", "9.0")]
[InlineData("net10.0", "10.0")]
[Scenario("Parse target framework")]
[Tag("parser")]
public async Task ParseTargetFramework(string input, string expectedVersion)
{
await Given("a target framework string", () => input)
.When("parsing", ParseTfm)
.Then("version matches expected", version =>
{
version.Should().Be(expectedVersion);
return true;
})
.AssertPassed();
}await Given("a package definition", CreatePackage)
.When("validating", Validate)
.Then("validation succeeds", result => result.IsValid)
.And("no errors reported", result => !result.Errors.Any())
.But("warnings may exist", result => result.Warnings.Any() || !result.Warnings.Any())
.AssertPassed();await Given("a package builder", () => Package.Define("Test"))
.When("adding property", b => b.Props(p => p.Property("Key", "Value")))
.And("adding target", b => b.Targets(t => t.Target<TBuild>()))
.And("building", b => b.Build())
.Then("package is complete", def => def.Props != null && def.Targets != null)
.AssertPassed();[Fact]
[Tag("windows-only")]
public async Task WindowsSpecificTest()
{
if (!OperatingSystem.IsWindows())
{
Output.WriteLine("Skipping: Test requires Windows");
return;
}
await Given("Windows environment", () => Environment.OSVersion)
.When("checking platform", os => os.Platform)
.Then("is Windows", platform => platform == PlatformID.Win32NT)
.AssertPassed();
}Or use xUnit's Skip:
[Fact]
[Tag("windows-only")]
public async Task WindowsSpecificTest()
{
Skip.IfNot(OperatingSystem.IsWindows(), "Test requires Windows");
// ...
}BddFluentApiTests.cs- 110 lines, straightforwardPackagingValidationTests.cs- 88 lines, simple validations
BddPackagingTests.cs- 131 linesBddTargetOrchestrationTests.cs- 162 linesValidationTests.cs- 55 lines, needs full conversion
BddEndToEndTests.cs- 234 lines, has cleanupBddTaskInvocationTests.cs- 191 linesBddRealWorldPatternsTests.cs- 247 lines
GoldenGenerationTests.cs- 150 linesGeneratorSpecTests.cs- 172 linesEfcptParityGenerationTests.cs- 275 linesEfcptCanonicalParityTests.cs- 401 linesCoverageTests.cs- 614 lines (largest!)
After refactoring each file:
- All tests still pass:
dotnet test - Test output shows feature/scenario names
- Tags appear in test output
- No compilation warnings
- Code coverage unchanged or improved
- Test execution time similar or faster
Run this command to verify tags work:
dotnet test --filter "Tag=smoke" --logger "console;verbosity=detailed"Update .github/workflows/ci.yml:
- name: Run smoke tests
run: dotnet test --filter "Tag=smoke" --logger "trx;LogFileName=smoke-results.trx"
- name: Run all tests except slow
run: dotnet test --filter "Tag!=slow" --logger "trx;LogFileName=all-results.trx"
- name: Run integration tests (only on main)
if: github.ref == 'refs/heads/main'
run: dotnet test --filter "Tag=integration"- TinyBDD GitHub
- TinyBDD README
- Example Refactored Test
- xUnit Trait Filtering
Solution: Ensure TinyBDD.Xunit package is up to date:
dotnet list package | grep TinyBDD
dotnet add package TinyBDD.Xunit --version <latest>Solution: Verify ITestOutputHelper is passed to base class:
public MyTests(ITestOutputHelper output) : base(output) { }Solution: Ensure .AssertPassed() is called (triggers Finally handlers):
await Given(...)
.Finally(...)
.Then(...)
.AssertPassed(); // Required!Solution: Check if optimization is disabled. Most tests should NOT have [DisableOptimization].
Happy Testing! 🎉