Writing Scenarios
This guide covers the essential techniques for writing clear, maintainable BDD scenarios in TinyBDD, including background steps, scenario outlines for data-driven tests, and tagging for organization and filtering.
Anatomy of a Well-Written Scenario
A well-crafted scenario tells a story that anyone on your team can understand. Each step should be:
- Single-purpose: One action or assertion per step
- Business-focused: Use domain language, not implementation details
- Self-contained: The scenario should be understandable without reading code
Example: A Clear Scenario
[Feature("Shopping Cart")]
public class ShoppingCartTests : TinyBddXunitBase
{
[Scenario("Adding items updates total"), Fact]
public async Task AddingItemsUpdatesTotal()
{
await Given("an empty cart", () => new ShoppingCart())
.When("adding a $10 item", cart => cart.AddItem("Widget", 10m))
.And("adding a $5 item", cart => cart.AddItem("Gadget", 5m))
.Then("total is $15", cart => cart.Total == 15m)
.AssertPassed();
}
}
Background Steps
Background steps represent setup that's common across multiple scenarios in a feature. While Gherkin has a dedicated Background: section, TinyBDD handles this through standard test framework features.
Using Test Fixtures
For shared setup, use your test framework's initialization mechanisms:
xUnit: Constructor and IDisposable
[Feature("User Authentication")]
public class AuthenticationTests : TinyBddXunitBase, IDisposable
{
private readonly TestDatabase _db;
private readonly AuthService _authService;
public AuthenticationTests(ITestOutputHelper output) : base(output)
{
// Background: Initialize test database and services
_db = new TestDatabase();
_db.Seed();
_authService = new AuthService(_db);
}
[Scenario("Valid credentials allow login"), Fact]
public async Task ValidCredentialsAllowLogin()
{
await Given("valid credentials", () => new Credentials("user@example.com", "correct"))
.When("attempting login", creds => _authService.LoginAsync(creds))
.Then("login succeeds", result => result.IsSuccess)
.AssertPassed();
}
[Scenario("Invalid credentials deny login"), Fact]
public async Task InvalidCredentialsDenyLogin()
{
await Given("invalid credentials", () => new Credentials("user@example.com", "wrong"))
.When("attempting login", creds => _authService.LoginAsync(creds))
.Then("login fails", result => !result.IsSuccess)
.AssertPassed();
}
public void Dispose()
{
// Cleanup after each scenario
_db?.Dispose();
}
}
NUnit: SetUp and TearDown
[Feature("User Authentication")]
public class AuthenticationTests : TinyBddNUnitBase
{
private TestDatabase _db;
private AuthService _authService;
[SetUp]
public void BackgroundSetup()
{
// Background: Initialize test database and services
_db = new TestDatabase();
_db.Seed();
_authService = new AuthService(_db);
}
[Scenario("Valid credentials allow login"), Test]
public async Task ValidCredentialsAllowLogin()
{
await Given("valid credentials", () => new Credentials("user@example.com", "correct"))
.When("attempting login", creds => _authService.LoginAsync(creds))
.Then("login succeeds", result => result.IsSuccess)
.AssertPassed();
}
[TearDown]
public void BackgroundCleanup()
{
_db?.Dispose();
}
}
MSTest: TestInitialize and TestCleanup
[TestClass]
[Feature("User Authentication")]
public class AuthenticationTests : TinyBddMsTestBase
{
private TestDatabase _db;
private AuthService _authService;
[TestInitialize]
public void BackgroundSetup()
{
// Background: Initialize test database and services
_db = new TestDatabase();
_db.Seed();
_authService = new AuthService(_db);
}
[Scenario("Valid credentials allow login"), TestMethod]
public async Task ValidCredentialsAllowLogin()
{
await Given("valid credentials", () => new Credentials("user@example.com", "correct"))
.When("attempting login", creds => _authService.LoginAsync(creds))
.Then("login succeeds", result => result.IsSuccess)
.AssertPassed();
}
[TestCleanup]
public void BackgroundCleanup()
{
_db?.Dispose();
}
}
Explicit Background Steps in Scenarios
For scenarios requiring visible setup steps, include them in your Given chain:
[Scenario("Transfer between accounts"), Fact]
public async Task TransferBetweenAccounts()
{
await Given("account A with $100", () => new Account("A", 100m))
.And("account B with $50", (accountA, ct) =>
{
var accountB = new Account("B", 50m);
return Task.FromResult((accountA, accountB));
})
.When("transferring $30 from A to B", accounts =>
{
accounts.accountA.Transfer(accounts.accountB, 30m);
return accounts;
})
.Then("account A has $70", accounts => accounts.accountA.Balance == 70m)
.And("account B has $80", accounts => accounts.accountB.Balance == 80m)
.AssertPassed();
}
Scenario Outlines: Data-Driven Tests
Scenario outlines let you run the same test logic with multiple sets of data. This is ideal for testing boundary conditions, equivalence classes, or multiple valid/invalid inputs.
Basic Scenario Outline
[Feature("Calculator")]
public class CalculatorTests : TinyBddXunitBase
{
[Scenario("Adding two numbers"), Fact]
public async Task AddingTwoNumbers()
{
var ctx = Bdd.CreateContext(this);
await Bdd.ScenarioOutline<(int a, int b, int expected)>(ctx, "Addition examples")
.Given("first number", ex => ex.a)
.And("second number", (_, ex) => ex.b)
.When("added together", (a, b) => a + b)
.Then("result equals expected", (sum, ex) => sum == ex.expected)
.Examples(
(a: 1, b: 2, expected: 3),
(a: 5, b: 5, expected: 10),
(a: -1, b: 1, expected: 0),
(a: 0, b: 0, expected: 0))
.AssertAllPassedAsync();
}
}
Complex Data Examples
For more complex scenarios with multiple parameters:
[Scenario("Password validation"), Fact]
public async Task PasswordValidation()
{
var ctx = Bdd.CreateContext(this);
await Bdd.ScenarioOutline<(string password, bool shouldBeValid, string reason)>(ctx, "Password rules")
.Given("a password", ex => ex.password)
.When("validating", pwd => _validator.Validate(pwd))
.Then("validation result matches expected", (result, ex) =>
result.IsValid == ex.shouldBeValid)
.Examples(
("Str0ng!Pass", true, "valid strong password"),
("weak", false, "too short"),
("NoNumbers!", false, "missing numbers"),
("nonumber123", false, "missing special character"),
("NOLOWERCASE1!", false, "missing lowercase"))
.AssertAllPassedAsync();
}
Accessing Example Data in Steps
Each step can access the example data through the ex parameter:
await Bdd.ScenarioOutline<(decimal price, decimal discount, decimal expectedTotal)>(ctx, "Discount calculation")
.Given("item with price", ex => new CartItem { Price = ex.price })
.When("applying discount", (item, ex) =>
{
item.ApplyDiscount(ex.discount);
return item;
})
.Then("final price matches", (item, ex) =>
Math.Abs(item.FinalPrice - ex.expectedTotal) < 0.01m)
.Examples(
(price: 100m, discount: 0.10m, expectedTotal: 90m),
(price: 50m, discount: 0.20m, expectedTotal: 40m),
(price: 25m, discount: 0m, expectedTotal: 25m))
.AssertAllPassedAsync();
Handling Example Failures
When an example fails, the exception indicates which example row failed:
// If the second example fails:
// Example 1 (a: 1, b: 2, expected: 3) [OK]
// Example 2 (a: 5, b: 5, expected: 10) [FAIL]
// Expected: 10, Actual: 9
Use RunAsync() instead of AssertAllPassedAsync() to capture results without throwing:
var results = await outline.Examples(...).RunAsync();
// Inspect individual results
foreach (var result in results.Items)
{
Console.WriteLine($"Example {result.Index}: {(result.Passed ? "PASS" : "FAIL")}");
if (!result.Passed)
{
Console.WriteLine($" Error: {result.Exception?.Message}");
}
}
// Or assert all passed
results.AssertAllPassed(); // Throws aggregate exception with all failures
Tags: Organization and Filtering
Tags help categorize scenarios for selective execution, reporting, and organization. Tags can be applied at both the feature (class) and scenario (method) levels.
Applying Tags
Single Tag per Attribute
[Feature("Payment Processing")]
[Tag("integration")]
public class PaymentTests : TinyBddXunitBase
{
[Scenario("Credit card payment"), Fact]
[Tag("smoke")]
public async Task CreditCardPayment() { /* ... */ }
[Scenario("PayPal payment"), Fact]
[Tag("slow")]
public async Task PayPalPayment() { /* ... */ }
}
Multiple Tags via Scenario Attribute
The [Scenario] attribute accepts tags as additional parameters:
[Feature("User Registration")]
public class RegistrationTests : TinyBddXunitBase
{
[Scenario("Email registration", "smoke", "fast")]
[Fact]
public async Task EmailRegistration() { /* ... */ }
[Scenario("Social login", "integration", "slow")]
[Fact]
public async Task SocialLogin() { /* ... */ }
}
Programmatic Tags
Add tags dynamically within scenarios:
[Scenario("Dynamic tagging example"), Fact]
public async Task DynamicTagging()
{
var ctx = Ambient.Current.Value;
ctx.AddTag("runtime-tag");
await Given(() => 1)
.When("double", x => x * 2)
.Then("equals 2", x => x == 2)
.AssertPassed();
}
Common Tag Conventions
| Tag | Purpose | Example Use |
|---|---|---|
smoke |
Critical path tests | Quick validation of core functionality |
regression |
Full regression suite | Comprehensive testing before release |
integration |
External dependencies | Tests requiring databases, APIs, etc. |
unit |
Pure unit tests | Fast, isolated tests |
slow |
Long-running tests | Tests taking more than a few seconds |
wip |
Work in progress | Features under development |
bug-{id} |
Bug verification | Tests for specific bug fixes |
Filtering Tests by Tag
Each test framework provides mechanisms for filtering by tags:
xUnit Trait Filtering
TinyBDD bridges tags to xUnit traits:
# Run only smoke tests
dotnet test --filter "Category=smoke"
# Exclude slow tests
dotnet test --filter "Category!=slow"
# Multiple conditions
dotnet test --filter "(Category=smoke)|(Category=regression)"
NUnit Category Filtering
# Run specific category
dotnet test --filter "TestCategory=smoke"
# Exclude category
dotnet test --filter "TestCategory!=integration"
MSTest TestCategory Filtering
# Run specific category
dotnet test --filter "TestCategory=smoke"
# Complex filters
dotnet test --filter "(TestCategory=smoke)&(TestCategory!=slow)"
Tag Reporting
Tags appear in test output for documentation and traceability:
Feature: Payment Processing
Scenario: Credit card payment
Tags: smoke, integration
Given a valid credit card [OK] 2 ms
When processing payment [OK] 45 ms
Then payment succeeds [OK] 1 ms
Best Practices for Tags
- Use consistent naming: Establish tag conventions across your team
- Keep tags simple: Short, lowercase, hyphen-separated
- Apply liberally: Better to over-tag than under-tag
- Document your tags: Maintain a list of standard tags and their meanings
- Use tag hierarchies: Consider prefixes like
api-,ui-,db-for clarity
Scenario Organization Tips
One Scenario Per Test Method
Each test method should contain exactly one scenario. This provides:
- Clear test isolation
- Better failure reporting
- Easier debugging
// Good: One scenario per method
[Scenario("Adding items"), Fact]
public async Task AddingItems() { /* one scenario */ }
[Scenario("Removing items"), Fact]
public async Task RemovingItems() { /* another scenario */ }
// Avoid: Multiple scenarios in one method
[Scenario("Cart operations"), Fact]
public async Task CartOperations()
{
// Multiple unrelated scenarios here - harder to understand and debug
}
Group Related Scenarios in a Feature Class
Use feature classes to group related scenarios:
[Feature("Shopping Cart")]
public class ShoppingCartTests : TinyBddXunitBase
{
[Scenario("Adding items"), Fact]
public async Task AddingItems() { /* ... */ }
[Scenario("Removing items"), Fact]
public async Task RemovingItems() { /* ... */ }
[Scenario("Calculating totals"), Fact]
public async Task CalculatingTotals() { /* ... */ }
}
Use Descriptive Names
Scenario names should describe the behavior being tested:
// Good: Describes the behavior
[Scenario("Empty cart shows zero total")]
// Avoid: Implementation-focused
[Scenario("GetTotal returns 0")]
// Good: Business-focused
[Scenario("Discount applies to eligible items")]
// Avoid: Technical details
[Scenario("DiscountCalculator.Calculate() works")]
Next Steps
- Learn about Data and Tables for working with complex data structures
- Explore Hooks and Lifecycle for setup and teardown patterns
- See Running with Test Frameworks for framework-specific guidance