BDD Testing in ConnectSoft Microservice Template¶
Purpose & Overview¶
Behavior-Driven Development (BDD) testing in the ConnectSoft Microservice Template provides executable specifications written in natural language that describe how the microservice should behave from a user's perspective. BDD tests bridge the gap between business requirements and technical implementation, enabling collaboration between developers, QA, and business stakeholders.
The template uses Reqnroll (the .NET port of SpecFlow) for BDD testing, providing a robust framework for writing and executing acceptance tests that validate end-to-end scenarios.
Why BDD Testing?¶
BDD testing offers several key benefits:
- Living Documentation: Feature files serve as executable documentation of system behavior
- Business Readability: Tests written in natural language (Gherkin) are understandable by non-technical stakeholders
- Collaboration: Shared language between developers, QA, and product owners
- Test Automation: Automated acceptance tests that validate business requirements
- Regression Prevention: Catch regressions early in the development cycle
- Integration Testing: Test complete user journeys end-to-end
- Test-Driven Development: Write tests before implementation (acceptance test-driven development)
BDD Philosophy
BDD combines the general techniques and principles of TDD with ideas from domain-driven design and object-oriented analysis and design to provide software development and management teams with shared tools and a shared process to collaborate on software development. The goal is to write tests that describe the behavior of the system from the user's perspective.
Architecture Overview¶
BDD Testing Structure¶
The AcceptanceTests project follows a structured organization pattern that mirrors the domain and feature organization:
ConnectSoft.MicroserviceTemplate.AcceptanceTests/
├── AIExtensionsFeatures/
│ ├── AI Chat Completions Feature.feature
│ ├── AI Tool Invocation Feature.feature
│ └── Steps/
│ ├── AIChatCompletionsFeatureStepDefinitions.cs
│ └── AIToolInvocationFeatureStepDefinitions.cs
├── FeatureA/
│ └── FeatureAUseCaseA/
│ ├── Execute FeatureAUseCaseA Using Rest API.feature
│ ├── Execute FeatureAUseCaseA Using Grpc API.feature
│ └── Steps/
│ ├── ExecuteFeatureAUseCaseAUsingRestAPIStepDefinition.cs
│ └── ExecuteFeatureAUseCaseAUsingGrpcAPIStepDefinition.cs
├── MicroserviceAggregateRoots/
│ ├── Features/
│ │ ├── Create MicroserviceAggregateRoot Using Rest API.feature
│ │ ├── Create MicroserviceAggregateRoot Using Grpc API.feature
│ │ └── ...
│ ├── Steps/
│ │ ├── CreateMicroserviceAggregateRootUsingRestAPIStepDefinition.cs
│ │ └── ...
│ └── MicroserviceAggregateRootsBackground.cs
├── HealthChecks/
│ ├── Test Health Check Endpoint.feature
│ └── TestHealthCheckEndpointStepBinding.cs
├── Hangfire/
│ ├── Run Hangfire Scheduler.feature
│ └── Steps/
│ └── RunHangfireSchedulerStepDefinitions.cs
├── SignalRHubs/
│ ├── WebChatHub broadcast over SignalR.feature
│ └── Steps/
│ └── WebChatHubBroadcastOverSignalRStepDefinition.cs
├── BeforeAfterTestRunHooks.cs
├── TestStartup.cs
├── TestConstants.cs
├── TestServerForwardingHandler.cs
├── appsettings.Development.json
├── appsettings.Development.Docker.json
└── appsettings.RateLimitTests.json
Project Organization Patterns¶
Feature-Based Organization:
- Each feature area (e.g., MicroserviceAggregateRoots, FeatureA) has its own folder
- Feature files are organized by functionality (REST API, gRPC API, etc.)
- Step definitions are co-located with feature files in Steps/ subdirectories
- Background steps (setup/teardown) are in dedicated files (e.g., MicroserviceAggregateRootsBackground.cs)
Naming Conventions:
- Feature files: {FeatureName} Feature.feature or {Action} {Entity} Using {Technology} API.feature
- Step definition files: {Action}{Entity}Using{Technology}APIStepDefinition.cs
- Background files: {Entity}Background.cs
BDD Testing Flow¶
Feature File (.feature)
↓ (Gherkin syntax)
Reqnroll Code Generation
↓ (.feature.cs)
Step Definitions (.cs)
↓ (C# code)
BeforeFeature Hook
↓ (TestStartup)
TestServer Host
↓ (In-Memory Application)
Microservice Application
↓ (Test Execution)
AfterFeature Hook (Cleanup)
Reqnroll Framework¶
What is Reqnroll?¶
Reqnroll is the .NET port of SpecFlow, a BDD framework for .NET that bridges the gap between business and technical stakeholders. It uses Gherkin syntax to write executable specifications.
Installation¶
Key Features¶
- Gherkin Syntax: Natural language feature files
- Step Definitions: Bind Gherkin steps to C# code
- Test Hooks: Setup and teardown logic
- TestServer Integration: In-memory testing of ASP.NET Core applications
- MSTest Integration: Runs as MSTest unit tests
- Data Tables: Support for tabular test data
- Scenario Outlines: Parameterized scenarios
Gherkin Syntax¶
Feature Files¶
Feature files use Gherkin syntax to describe system behavior:
Feature: AI Chat Completions Feature
End-to-end acceptance tests for AI's chat API with the selected model
Scenario: Simple OpenAI Chat Completion Returns A Response
Given AI provider configured to use model chat completions "openAI"
When I send a chat request with:
| role | content |
| system | You are a concise assistant. |
| user | Say hello in one short sentence. |
Then the chat response should be non-empty
And the chat response should contain a greeting
Gherkin Keywords¶
Feature: Describes the feature being tested
Scenario: Describes a specific test case
Given: Establishes the initial context (preconditions)
When: Describes the action being performed
Then: Describes the expected outcome
And/But: Continuation of previous step
Background: Steps that run before every scenario
Scenario Outline: Parameterized scenarios
Scenario Outline: Create user with different roles
Given a user with role "<role>"
When I create the user
Then the user should have role "<role>"
Examples:
| role |
| Admin |
| User |
| Guest |
Data Tables: Tabular data for step parameters
When I send a chat request with:
| role | content |
| system | You are a concise assistant. |
| user | Say hello in one short sentence. |
Step Definitions¶
Creating Step Definitions¶
Step definitions bind Gherkin steps to C# code:
// AIChatCompletionsFeatureStepDefinitions.cs
namespace ConnectSoft.MicroserviceTemplate.AcceptanceTests.AIExtensionsFeatures.Steps
{
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Reqnroll;
[Binding]
public sealed class AIChatCompletionsFeatureStepDefinitions
{
private IChatClient? chatClient;
private string assistantResponse = string.Empty;
[Given("AI provider configured to use model chat completions {string}")]
[Scope(Feature = "AI Chat Completions Feature")]
public void GivenAIProviderConfiguredToUseModelChatCompletions(string aiProvider)
{
this.chatClient = BeforeAfterTestRunHooks.ServerInstance?
.Services.GetRequiredKeyedService<IChatClient>(aiProvider);
Assert.IsNotNull(this.chatClient, "IChatClient is not resolved.");
}
[When("I send a chat request with:")]
[Scope(Feature = "AI Chat Completions Feature")]
public async Task WhenISendAChatRequestWith(DataTable table)
{
var chatHistory = new List<ChatMessage>();
foreach (var row in table.Rows)
{
var role = row["role"].Trim().ToLowerInvariant();
var content = row["content"];
chatHistory.Add(role switch
{
"system" => new ChatMessage(ChatRole.System, content),
"user" => new ChatMessage(ChatRole.User, content),
"assistant" => new ChatMessage(ChatRole.Assistant, content),
_ => new ChatMessage(ChatRole.User, content)
});
}
this.assistantResponse = string.Empty;
await foreach (var update in this.chatClient!.GetStreamingResponseAsync(chatHistory))
{
this.assistantResponse += update.Text;
}
}
[Then("the chat response should be non-empty")]
[Scope(Feature = "AI Chat Completions Feature")]
public void ThenTheChatResponseShouldBeNonEmpty()
{
Assert.IsNotNull(this.assistantResponse);
Assert.IsNotEmpty(this.assistantResponse);
}
}
}
Step Definition Attributes¶
Binding: Marks a class as containing step definitions
Given/When/Then: Binds methods to Gherkin steps
[Given("AI provider configured to use model chat completions {string}")]
public void GivenAIProviderConfigured(string aiProvider)
{
// Step implementation
}
Scope: Limits step definitions to specific features or scenarios
[Scope(Feature = "AI Chat Completions Feature")]
[Given("AI provider configured to use model chat completions {string}")]
public void GivenAIProviderConfigured(string aiProvider)
{
// Only applies to "AI Chat Completions Feature"
}
Step Parameter Matching¶
String Parameters: Use {string} in step text
[Given("AI provider configured to use model chat completions {string}")]
public void GivenAIProviderConfigured(string aiProvider)
{
// aiProvider = "openAI"
}
Integer Parameters: Use {int} in step text
[When("I ask AI to roll a random number between {int} and {int}")]
public async Task WhenIAskAIRollNumber(int min, int max)
{
// min = 1, max = 100
}
Data Tables: Use DataTable parameter
[When("I send a chat request with:")]
public async Task WhenISendAChatRequestWith(DataTable table)
{
foreach (var row in table.Rows)
{
var role = row["role"];
var content = row["content"];
// Process row
}
}
Table Rows: Use Table parameter for row-by-row processing
[When("I create users with:")]
public void WhenICreateUsersWith(Table table)
{
foreach (var row in table.Rows)
{
var name = row["Name"];
var email = row["Email"];
// Process row
}
}
Test Infrastructure¶
BeforeAfterTestRunHooks¶
The BeforeAfterTestRunHooks class provides centralized test infrastructure management for all acceptance tests. It handles host creation, TestServer initialization, and cleanup.
Key Responsibilities: - Host creation and configuration - TestServer initialization - HttpClient creation for HTTP-based tests - Startup warmup coordination - Resource cleanup
// BeforeAfterTestRunHooks.cs
[Binding]
public static class BeforeAfterTestRunHooks
{
internal const string BaseUrlConstant = "https://localhost:7279";
public static string? BaseUrl { get; private set; }
public static TestServer? ServerInstance { get; set; }
public static IHost? HostInstance { get; set; }
public static HttpClient? TestServerClient { get; private set; }
[BeforeFeature]
public static void BeforeFeature()
{
HostInstance = CreateHostBuilder().Build();
HostInstance.Start();
ServerInstance = HostInstance.GetTestServer();
TestServerClient = ServerInstance.CreateClient();
BaseUrl = TestServerClient.BaseAddress?.ToString();
HostInstance!.Services.GetRequiredService<StartupWarmupGate>().MarkReady();
}
[AfterFeature]
public static void AfterFeature()
{
TestServerClient?.Dispose();
TestServerClient = null;
if (ServerInstance != null)
{
ServerInstance.Dispose();
ServerInstance = null;
}
if (HostInstance != null)
{
_ = HostInstance.StopAsync().ConfigureAwait(false);
HostInstance = null;
}
}
}
Key Properties:
- ServerInstance: Provides access to the TestServer for creating HTTP clients and accessing services
- HostInstance: The application host instance for accessing DI container and services
- TestServerClient: Pre-configured HttpClient for HTTP-based tests
- BaseUrl: The base URL of the test server (useful for relative URL construction)
TestStartup¶
The TestStartup class extends the main Startup class and customizes it for testing scenarios:
Key Features: - Database cleanup before tests (drops and recreates databases) - Test-specific configuration overrides - SignalR health check reconfiguration for TestServer - gRPC client factory setup for in-memory testing
// TestStartup.cs
public class TestStartup(IConfiguration configuration, IWebHostEnvironment webHostEnvironment)
: Startup(configuration, webHostEnvironment)
{
public override void ConfigureServices(IServiceCollection services)
{
#if UseNHibernate
this.DropSqlDatabase(TestConstants.DbName);
#endif
#if UseMongoDb
this.DropMongoDbDatabase(TestConstants.DbName);
#endif
base.ConfigureServices(services);
#if UseSignalR && HealthCheck
ReconfigureSignalRHealthChecks(services);
#endif
#if UseGrpc
ClientFactory clientFactory = new (new ServiceModelGrpcClientOptions
{
MarshallerFactory = new JsonMarshallerFactory(),
});
services.AddSingleton<IClientFactory>(clientFactory);
services.ActivateSingleton<IClientFactory>();
#endif
}
}
Database Cleanup: - NHibernate: Drops SQL Server database if it exists before tests - MongoDB: Drops MongoDB database if it exists before tests - Ensures clean state for each test run
Test Constants:
// TestConstants.cs
internal static class TestConstants
{
internal const string DbName = "MICROSERVICE_DATABASE";
internal const string NHibernateConnectionStringKey = "ConnectSoft.MicroserviceTemplateSqlServer";
internal const string MongoDbConnectionStringKey = "ConnectSoft.MicroserviceTemplateMongoDb";
}
Host Builder Configuration¶
The host builder in BeforeAfterTestRunHooks configures:
Configuration Loading:
private static void DefineConfiguration(HostBuilderContext hostBuilderContext, IConfigurationBuilder configurationBuilder)
{
string? environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
string appSettingsFileName = "appsettings.Development.json";
if (string.Equals(Environment.GetEnvironmentVariable("TF_BUILD"), "True", StringComparison.Ordinal))
{
appSettingsFileName = "appsettings.Development.Docker.json";
}
if (string.Equals(environment, "RateLimitTests", StringComparison.Ordinal))
{
appSettingsFileName = "appsettings.RateLimitTests.json";
}
configurationBuilder.Sources.Clear();
configurationBuilder.SetBasePath(hostBuilderContext.HostingEnvironment.ContentRootPath)
.AddJsonFile(appSettingsFileName, optional: true, reloadOnChange: true);
// Azure App Configuration (if enabled)
// Environment variables
configurationBuilder.AddEnvironmentVariables();
}
Environment-Specific Configuration:
- appsettings.Development.json: Default for local development
- appsettings.Development.Docker.json: For Docker/CI environments (detected via TF_BUILD)
- appsettings.RateLimitTests.json: For rate limiting specific tests
Web Host Configuration:
private static void ConfigureWebHostDefaultsInternal(IWebHostBuilder webBuilder)
{
webBuilder.UseStartup<TestStartup>();
webBuilder.UseTestServer();
// Set content root to test assembly location
Directory.SetCurrentDirectory(AppDomain.CurrentDomain.BaseDirectory);
webBuilder.UseContentRoot(/* assembly directory */);
// Configure Kestrel for HTTP/1.1 and HTTP/2
webBuilder.ConfigureKestrel(options =>
{
options.ConfigureEndpointDefaults(endpoints =>
endpoints.Protocols = HttpProtocols.Http1AndHttp2);
options.AddServerHeader = false;
});
}
TestServerForwardingHandler¶
The TestServerForwardingHandler is a custom HttpMessageHandler that forwards HTTP requests to the TestServer's HttpClient. This is useful when components require an HttpMessageHandler but tests run on TestServer.
Use Cases:
- Health checks that need an HttpMessageHandler
- SDK clients that require an HttpMessageHandler
- Components that can't directly use HttpClient
// TestServerForwardingHandler.cs
public sealed class TestServerForwardingHandler : HttpMessageHandler
{
private readonly HttpClient client;
public TestServerForwardingHandler(HttpClient client, bool disposeClient = true)
{
this.client = client ?? throw new ArgumentNullException(nameof(client));
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
// Automatically converts relative URIs to absolute using BaseAddress
if (request.RequestUri is { IsAbsoluteUri: false })
{
if (this.client.BaseAddress is null)
{
throw new InvalidOperationException(
"The underlying HttpClient has no BaseAddress and the request URI is relative.");
}
request.RequestUri = new Uri(this.client.BaseAddress, request.RequestUri);
}
return this.client.SendAsync(request, cancellationToken);
}
}
Usage Example:
var testServerClient = BeforeAfterTestRunHooks.TestServerClient!;
var handler = new TestServerForwardingHandler(testServerClient);
var httpClient = new HttpClient(handler);
Hook Execution Order¶
Hooks execute in the following order:
BeforeFeature (static)
↓
BeforeScenario (instance)
↓
BeforeStep (instance)
↓
Step Execution
↓
AfterStep (instance)
↓
AfterScenario (instance)
↓
AfterFeature (static)
Hook Attributes¶
BeforeFeature: Runs before each feature (static method)
[BeforeFeature]
public static void BeforeFeature()
{
// Feature-level setup (e.g., TestServer initialization)
}
AfterFeature: Runs after each feature (static method)
[AfterFeature]
public static void AfterFeature()
{
// Feature-level cleanup (e.g., TestServer disposal)
}
BeforeScenario: Runs before each scenario (instance method)
[BeforeScenario]
public void BeforeScenario()
{
// Scenario-level setup (e.g., test data preparation)
}
AfterScenario: Runs after each scenario (instance method)
BeforeStep: Runs before each step (instance method)
AfterStep: Runs after each step (instance method)
Parallelization¶
The template prevents parallel test execution to avoid conflicts:
This ensures: - Tests run sequentially - No resource conflicts - Predictable test execution order - Easier debugging
TestServer Integration¶
In-Memory Testing¶
TestServer provides an in-memory test host for ASP.NET Core applications, allowing tests to run against the full application stack without network overhead.
Benefits: - Fast Execution: No network overhead, in-memory communication - Isolated Tests: Each test feature runs in isolation with its own host - Full Stack: Tests complete application stack (middleware, routing, DI, etc.) - Configuration: Uses same configuration system as production - Service Resolution: Full access to DI container and registered services
Accessing TestServer in Step Definitions¶
Option 1: Use Pre-configured HttpClient (Recommended)
[When("I check the health of the application")]
public async Task WhenICheckTheHealth()
{
using HttpClient? httpClient = BeforeAfterTestRunHooks.TestServerClient;
ArgumentNullException.ThrowIfNull(httpClient);
var response = await httpClient.GetAsync("/health");
this.response = response;
}
Option 2: Create New HttpClient from TestServer
[When("I send a request")]
public async Task WhenISendARequest()
{
using HttpClient? httpClient = BeforeAfterTestRunHooks.ServerInstance?.CreateClient();
ArgumentNullException.ThrowIfNull(httpClient);
var response = await httpClient.PostAsync(url, data);
this.response = response;
}
Option 3: Access Services from DI Container
[Given("AI provider configured to use model chat completions {string}")]
public void GivenAIProviderConfigured(string aiProvider)
{
this.chatClient = BeforeAfterTestRunHooks.ServerInstance?
.Services.GetRequiredKeyedService<IChatClient>(aiProvider);
Assert.IsNotNull(this.chatClient, "IChatClient is not resolved.");
}
Feature File Organization¶
Organization Patterns¶
The template organizes feature files by domain and functionality:
Domain-Based Organization:
MicroserviceAggregateRoots/
├── Features/
│ ├── Create MicroserviceAggregateRoot Using Rest API.feature
│ ├── Create MicroserviceAggregateRoot Using Grpc API.feature
│ ├── Get MicroserviceAggregateRoot Details Using Rest API.feature
│ └── ...
└── Steps/
├── CreateMicroserviceAggregateRootUsingRestAPIStepDefinition.cs
└── ...
Technology-Based Organization:
FeatureA/
└── FeatureAUseCaseA/
├── Execute FeatureAUseCaseA Using Rest API.feature
├── Execute FeatureAUseCaseA Using Grpc API.feature
└── Steps/
├── ExecuteFeatureAUseCaseAUsingRestAPIStepDefinition.cs
└── ExecuteFeatureAUseCaseAUsingGrpcAPIStepDefinition.cs
Feature-Based Organization:
AIExtensionsFeatures/
├── AI Chat Completions Feature.feature
├── AI Tool Invocation Feature.feature
└── Steps/
├── AIChatCompletionsFeatureStepDefinitions.cs
└── AIToolInvocationFeatureStepDefinitions.cs
Feature File Structure¶
Basic Feature File:
Feature: Create MicroserviceAggregateRoot Using Rest API
In order to allow users to manage and store MicroserviceAggregateRoot
I want to be able to create MicroserviceAggregateRoot using Rest API
@CreateMicroserviceAggregateRootUsingRestAPI
Scenario: Create MicroserviceAggregateRoot Using Rest API
Given I have entered new object identifier
When i create MicroserviceAggregateRoot using rest API
Then i should receive valid create MicroserviceAggregateRoot response
Multiple Scenarios in One Feature:
Feature: Create MicroserviceAggregateRoot Using Rest API
In order to allow users to manage and store MicroserviceAggregateRoot
I want to be able to create MicroserviceAggregateRoot using Rest API
@CreateMicroserviceAggregateRootUsingRestAPI
Scenario: Create MicroserviceAggregateRoot Using Rest API
Given I have entered new object identifier
When i create MicroserviceAggregateRoot using rest API
Then i should receive valid create MicroserviceAggregateRoot response
@CreateMicroserviceAggregateRootUsingRestAPIForInvalidInputShouldReturn400
Scenario: Create MicroserviceAggregateRoot Using Rest API For Invalid Input Should Return 400
Given I have entered object identifier : 00000000-0000-0000-0000-000000000000
When i create MicroserviceAggregateRoot using rest API
Then i should receive create MicroserviceAggregateRoot bad request response
@CreateMicroserviceAggregateRootUsingRestAPIForInvalidInputShouldReturnAlreadyExists
Scenario: Create MicroserviceAggregateRoot Using Rest API For Existing Resource Should Return Already Exists
Given I have entered new object identifier
When i create MicroserviceAggregateRoot using rest API
Then i should receive valid create MicroserviceAggregateRoot response
When i create MicroserviceAggregateRoot using rest API with same identifier again
Then i should receive create MicroserviceAggregateRoot already exists response
Conditional Feature Files¶
Feature files can use conditional compilation directives for technology-specific scenarios:
Feature: AI Chat Completions Feature
End-to-end acceptance tests for AI's chat API with the selected model
#if (UseOpenAI)
@SimpleOllamaChatCompletionReturnsAResponse
Scenario: Simple OpenAI Chat Completion Returns A Response
Given AI provider configured to use model chat completions "openAI"
When I send a chat request with:
| role | content |
| system | You are a concise assistant. |
| user | Say hello in one short sentence. |
Then the chat response should be non-empty
And the chat response should contain a greeting
#endif
Writing Feature Files¶
Basic Feature Structure¶
Feature: Feature Name
As a [role],
I want to [capability],
So that [benefit]
Scenario: Scenario Name
Given [precondition]
When [action]
Then [expected outcome]
Example: Health Check Feature¶
Feature: Test Health Check Endpoint
As a developer,
I want to ensure that the health check endpoint is working correctly,
So that I can be confident that the application is running correctly.
@TestHealthCheckEndpoint
Scenario: Check the health of the application
When I check the health of the application
Then I should receive a 200 OK response
And the response should be json content
Example: AI Chat Completions Feature¶
Feature: AI Chat Completions Feature
End-to-end acceptance tests for AI's chat API with the selected model
#if (UseOpenAI)
@SimpleOllamaChatCompletionReturnsAResponse
Scenario: Simple OpenAI Chat Completion Returns A Response
Given AI provider configured to use model chat completions "openAI"
When I send a chat request with:
| role | content |
| system | You are a concise assistant. |
| user | Say hello in one short sentence. |
Then the chat response should be non-empty
And the chat response should contain a greeting
#endif
Conditional Compilation¶
Use #if directives for conditional scenarios:
#if (UseOpenAI)
Scenario: OpenAI Chat Completion
Given AI provider configured to use model chat completions "openAI"
When I send a chat request
Then the response should be received
#endif
#if (UseAzureOpenAI)
Scenario: Azure OpenAI Chat Completion
Given AI provider configured to use model chat completions "azureOpenAI"
When I send a chat request
Then the response should be received
#endif
Tags¶
Use tags to organize and filter scenarios:
@AI @ChatCompletion
Scenario: Simple Chat Completion
Given AI provider configured
When I send a chat request
Then the response should be received
@AI @ToolInvocation
Scenario: Tool Invocation
Given AI tools provider is available
When I ask AI to use a tool
Then the tool should be invoked
Running Tagged Tests:
Step Definition Patterns¶
Step Definition Class Structure¶
Step definition classes follow consistent patterns in the template:
Naming Convention:
- Class name: {Action}{Entity}Using{Technology}APIStepDefinition or {FeatureName}StepDefinitions
- File name matches class name
- Located in Steps/ subdirectory within feature area
Basic Structure:
[Binding]
public sealed class CreateMicroserviceAggregateRootUsingRestApiStepDefinition
{
// State management fields
private readonly CreateMicroserviceAggregateRootRequest createMicroserviceAggregateRootRequest = new()
{
ObjectId = Guid.NewGuid(),
};
private HttpResponseMessage? createMicroserviceAggregateRootHttpResponseMessage;
private CreateMicroserviceAggregateRootResponse? createMicroserviceAggregateRootResponse;
// Step definitions with [Scope] attribute
[Given(@"I have entered new object identifier")]
[Scope(Feature = "Create MicroserviceAggregateRoot Using Rest API")]
public void GivenIHaveEnteredNewObjectIdentifier()
{
this.createMicroserviceAggregateRootRequest.ObjectId = Guid.NewGuid();
}
[When(@"i create MicroserviceAggregateRoot using rest API")]
[Scope(Feature = "Create MicroserviceAggregateRoot Using Rest API")]
public async Task WhenICreateMicroserviceAggregateRootUsingRestAPI()
{
// Implementation
}
[Then(@"i should receive valid create MicroserviceAggregateRoot response")]
[Scope(Feature = "Create MicroserviceAggregateRoot Using Rest API")]
public async Task ThenIShouldReceiveValidCreateMicroserviceAggregateRootResponse()
{
// Assertions
}
}
Scope Attribute Pattern¶
The [Scope] attribute limits step definitions to specific features, preventing ambiguous matches:
[Scope(Feature = "Create MicroserviceAggregateRoot Using Rest API")]
[Given(@"I have entered new object identifier")]
public void GivenIHaveEnteredNewObjectIdentifier()
{
// Only matches steps in "Create MicroserviceAggregateRoot Using Rest API" feature
}
Benefits: - Prevents step definition conflicts across features - Makes step definitions more maintainable - Improves test readability (clear feature association)
State Management Patterns¶
Instance Fields for State:
[Binding]
public sealed class CreateMicroserviceAggregateRootUsingRestApiStepDefinition
{
// Request state
private readonly CreateMicroserviceAggregateRootRequest request = new();
// Response state
private HttpResponseMessage? httpResponse;
private CreateMicroserviceAggregateRootResponse? response;
// Shared state across steps in the same scenario
private Guid objectId;
}
Guidelines:
- Use instance fields (not static) to share state between steps in the same scenario
- Initialize request objects in field initializers where possible
- Use nullable types for response objects until they're populated
- Clear/reset state in [BeforeScenario] if needed
HTTP Client Usage Patterns¶
Pattern 1: Using Pre-configured HttpClient (Recommended for simple requests)
[When(@"i create MicroserviceAggregateRoot using rest API")]
public async Task WhenICreateMicroserviceAggregateRootUsingRestAPI()
{
using HttpClient? httpClient = BeforeAfterTestRunHooks.TestServerClient;
ArgumentNullException.ThrowIfNull(httpClient);
string url = "api/MicroserviceAggregateRootsService/MicroserviceAggregateRoots";
var json = JsonConvert.SerializeObject(this.request);
var data = new StringContent(json, Encoding.UTF8, "application/json");
this.httpResponse = await httpClient.PostAsync(url, data).ConfigureAwait(false);
}
Pattern 2: Creating New HttpClient (For scenarios requiring custom configuration)
[When(@"I send a request with custom headers")]
public async Task WhenISendRequestWithCustomHeaders()
{
using HttpClient? httpClient = BeforeAfterTestRunHooks.ServerInstance?.CreateClient();
ArgumentNullException.ThrowIfNull(httpClient);
httpClient.DefaultRequestHeaders.Add("Custom-Header", "value");
this.httpResponse = await httpClient.GetAsync("/api/endpoint").ConfigureAwait(false);
}
Pattern 3: Using TestServerForwardingHandler (For components requiring HttpMessageHandler)
[When(@"I check health using handler")]
public async Task WhenICheckHealthUsingHandler()
{
var testServerClient = BeforeAfterTestRunHooks.TestServerClient!;
var handler = new TestServerForwardingHandler(testServerClient);
using var httpClient = new HttpClient(handler);
this.httpResponse = await httpClient.GetAsync("/health").ConfigureAwait(false);
}
Service Resolution Patterns¶
Resolving Services from DI Container:
[Given("AI provider configured to use model chat completions {string}")]
public void GivenAIProviderConfigured(string aiProvider)
{
this.chatClient = BeforeAfterTestRunHooks.ServerInstance?
.Services.GetRequiredKeyedService<IChatClient>(aiProvider);
Assert.IsNotNull(this.chatClient, "IChatClient is not resolved.");
}
Resolving Configuration:
[Given("database is configured")]
public void GivenDatabaseIsConfigured()
{
IConfiguration? configuration = BeforeAfterTestRunHooks.HostInstance?
.Services.GetService<IConfiguration>();
ArgumentNullException.ThrowIfNull(configuration);
var connectionString = configuration.GetConnectionString(TestConstants.NHibernateConnectionStringKey);
// Use connection string
}
Assertion Patterns¶
HTTP Response Assertions:
[Then(@"i should receive valid create MicroserviceAggregateRoot response")]
public async Task ThenIShouldReceiveValidCreateMicroserviceAggregateRootResponse()
{
Assert.IsNotNull(this.httpResponse);
Assert.IsTrue(this.httpResponse.IsSuccessStatusCode);
using HttpContent httpContent = this.httpResponse.Content;
var content = await httpContent.ReadAsStringAsync().ConfigureAwait(false);
var response = JsonConvert.DeserializeObject<CreateMicroserviceAggregateRootResponse>(content);
Assert.IsNotNull(response);
Assert.IsNotNull(response.CreatedMicroserviceAggregateRoot);
Assert.AreEqual(this.request.ObjectId, response.CreatedMicroserviceAggregateRoot.ObjectId);
}
Error Response Assertions:
[Then(@"i should receive create MicroserviceAggregateRoot bad request response")]
public async Task ThenIShouldReceiveBadRequestResponse()
{
Assert.IsNotNull(this.httpResponse);
Assert.IsFalse(this.httpResponse.IsSuccessStatusCode);
Assert.AreEqual(HttpStatusCode.BadRequest, this.httpResponse.StatusCode);
var content = await this.httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
var problemDetails = JsonConvert.DeserializeObject<ValidationProblemDetails>(content);
Assert.IsNotNull(problemDetails);
Assert.AreEqual(400, problemDetails.Status);
Assert.AreEqual("https://tools.ietf.org/html/rfc9110#section-15.5.1", problemDetails.Type);
}
Health Check Assertions (with polling):
[When(@"I check the health of the application")]
public async Task WhenICheckTheHealthOfTheApplication()
{
using HttpClient? httpClient = BeforeAfterTestRunHooks.ServerInstance?.CreateClient();
ArgumentNullException.ThrowIfNull(httpClient);
this.healthCheckHttpResponse = await WaitForHealthyAsync(
httpClient,
"/health",
ReadyTimeout,
PollInterval).ConfigureAwait(false);
}
private static async Task<HttpResponseMessage> WaitForHealthyAsync(
HttpClient client,
string path,
TimeSpan timeout,
TimeSpan pollInterval)
{
var deadline = DateTime.UtcNow + timeout;
HttpResponseMessage? last = null;
while (DateTime.UtcNow < deadline)
{
last?.Dispose();
last = await client.GetAsync(path).ConfigureAwait(false);
var body = await last.Content.ReadAsStringAsync().ConfigureAwait(false);
if (last.IsSuccessStatusCode && IsHealthy(body))
{
return last;
}
await Task.Delay(pollInterval).ConfigureAwait(false);
}
return last ?? new HttpResponseMessage(HttpStatusCode.RequestTimeout);
}
Background Steps Pattern¶
Background steps handle database setup and teardown:
// MicroserviceAggregateRootsBackground.cs
[Binding]
public sealed class MicroserviceAggregateRootsBackground
{
[Given(@"all MicroserviceAggregateRoots deleted for object identifier : (.*)")]
public void GivenAllMicroserviceAggregateRootsDeletedForObjectIdentifier(Guid objectId)
{
IConfiguration? configuration = BeforeAfterTestRunHooks.HostInstance?
.Services.GetService<IConfiguration>();
ArgumentNullException.ThrowIfNull(configuration);
#if UseNHibernate
using IDbConnection db = new SqlConnection(
configuration.GetConnectionString(TestConstants.NHibernateConnectionStringKey));
db.Execute(
"DELETE FROM [ConnectSoft.MicroserviceTemplate].[MicroserviceAggregateRoots] WHERE ObjectId = @ObjectId",
new { ObjectId = objectId });
#endif
#if UseMongoDb
var clientSettings = MongoClientSettings.FromConnectionString(
configuration.GetConnectionString(TestConstants.MongoDbConnectionStringKey));
MongoClient mongoClient = new(clientSettings);
var database = mongoClient.GetDatabase(TestConstants.DbName);
database.GetCollection<IMicroserviceAggregateRoot>(typeof(IMicroserviceAggregateRoot).AsCollectionName())
.DeleteMany(Builders<IMicroserviceAggregateRoot>.Filter.Eq(e => e.Id, objectId));
#endif
}
}
Usage in Feature Files:
Background:
Given all MicroserviceAggregateRoots deleted for object identifier : 00000000-0000-0000-0000-000000000000
Scenario: Create new aggregate root
Given I have entered new object identifier
When i create MicroserviceAggregateRoot using rest API
Then i should receive valid create MicroserviceAggregateRoot response
State Management¶
Use instance fields to share state between steps:
[Binding]
public sealed class AIChatCompletionsFeatureStepDefinitions
{
private IChatClient? chatClient;
private string assistantResponse = string.Empty;
[Given("AI provider configured to use model chat completions {string}")]
public void GivenAIProviderConfigured(string aiProvider)
{
this.chatClient = BeforeAfterTestRunHooks.ServerInstance?
.Services.GetRequiredKeyedService<IChatClient>(aiProvider);
}
[When("I send a chat request")]
public async Task WhenISendAChatRequest()
{
// Use this.chatClient
}
[Then("the chat response should be non-empty")]
public void ThenTheChatResponseShouldBeNonEmpty()
{
// Use this.assistantResponse
Assert.IsNotEmpty(this.assistantResponse);
}
}
Dependency Injection¶
Access services from TestServer:
[Given("AI provider configured to use model chat completions {string}")]
public void GivenAIProviderConfigured(string aiProvider)
{
this.chatClient = BeforeAfterTestRunHooks.ServerInstance?
.Services.GetRequiredKeyedService<IChatClient>(aiProvider);
Assert.IsNotNull(this.chatClient, "IChatClient is not resolved.");
}
HTTP Client Usage¶
[When("I check the health of the application")]
public async Task WhenICheckTheHealth()
{
var response = await BeforeAfterTestRunHooks.TestServerClient!
.GetAsync("/health");
this.response = response;
this.responseContent = await response.Content.ReadAsStringAsync();
}
[Then("I should receive a 200 OK response")]
public void ThenIShouldReceive200OK()
{
Assert.AreEqual(HttpStatusCode.OK, this.response.StatusCode);
}
Data Table Processing¶
[When("I send a chat request with:")]
public async Task WhenISendAChatRequestWith(DataTable table)
{
var chatHistory = new List<ChatMessage>();
foreach (var row in table.Rows)
{
var role = row["role"].Trim().ToLowerInvariant();
var content = row["content"];
chatHistory.Add(role switch
{
"system" => new ChatMessage(ChatRole.System, content),
"user" => new ChatMessage(ChatRole.User, content),
"assistant" => new ChatMessage(ChatRole.Assistant, content),
_ => new ChatMessage(ChatRole.User, content)
});
}
// Use chatHistory
}
Async Steps¶
Use async Task for asynchronous operations:
[When("I send a chat request")]
public async Task WhenISendAChatRequest()
{
await foreach (var update in this.chatClient!.GetStreamingResponseAsync(chatHistory))
{
this.assistantResponse += update.Text;
}
}
Integration with Application Host¶
Startup Warmup Coordination¶
The template uses StartupWarmupGate to coordinate startup warmup in acceptance tests:
[BeforeFeature]
public static void BeforeFeature()
{
// ... host setup ...
HostInstance!.Services.GetRequiredService<StartupWarmupGate>().MarkReady();
}
This ensures: - Health checks wait for application to be ready
See Startup and Warmup for detailed startup warmup patterns and mechanisms. - Background services are initialized - Database connections are established - Application is fully warmed up before tests run
Configuration Override Patterns¶
Environment-Specific Configuration:
private static void DefineConfiguration(HostBuilderContext hostBuilderContext, IConfigurationBuilder configurationBuilder)
{
string appSettingsFileName = "appsettings.Development.json";
// CI/CD environment detection
if (string.Equals(Environment.GetEnvironmentVariable("TF_BUILD"), "True", StringComparison.Ordinal))
{
appSettingsFileName = "appsettings.Development.Docker.json";
}
// Test-specific environment
if (string.Equals(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), "RateLimitTests", StringComparison.Ordinal))
{
appSettingsFileName = "appsettings.RateLimitTests.json";
}
configurationBuilder.Sources.Clear();
configurationBuilder.SetBasePath(hostBuilderContext.HostingEnvironment.ContentRootPath)
.AddJsonFile(appSettingsFileName, optional: true, reloadOnChange: true);
}
Configuration Files:
- appsettings.Development.json: Default test configuration
- appsettings.Development.Docker.json: Docker/CI-specific settings
- appsettings.RateLimitTests.json: Rate limiting test configuration
Service Model Testing¶
REST API Testing:
[When(@"i create MicroserviceAggregateRoot using rest API")]
public async Task WhenICreateMicroserviceAggregateRootUsingRestAPI()
{
using HttpClient? httpClient = BeforeAfterTestRunHooks.ServerInstance?.CreateClient();
ArgumentNullException.ThrowIfNull(httpClient);
string url = "api/MicroserviceAggregateRootsService/MicroserviceAggregateRoots";
var json = JsonConvert.SerializeObject(this.request);
var data = new StringContent(json, Encoding.UTF8, "application/json");
this.httpResponse = await httpClient.PostAsync(url, data).ConfigureAwait(false);
}
gRPC API Testing:
[Given("gRPC client is configured")]
public void GivenGrpcClientIsConfigured()
{
var clientFactory = BeforeAfterTestRunHooks.HostInstance?
.Services.GetRequiredService<IClientFactory>();
ArgumentNullException.ThrowIfNull(clientFactory);
this.grpcClient = clientFactory.CreateClient<IMicroserviceAggregateRootsService>();
}
SignalR Testing:
[Given("SignalR connection is established")]
public async Task GivenSignalRConnectionIsEstablished()
{
var server = BeforeAfterTestRunHooks.ServerInstance;
var baseAddress = BeforeAfterTestRunHooks.BaseUrl;
this.hubConnection = new HubConnectionBuilder()
.WithUrl($"{baseAddress}/webChatHub", options =>
{
options.HttpMessageHandlerFactory = _ => server!.CreateHandler();
options.Transports = HttpTransportType.LongPolling;
})
.Build();
await this.hubConnection.StartAsync();
}
Actor Model Testing¶
Orleans Actor Testing:
Feature: Bank account actor feature using Orleans
Scenario: Withdraw from bank account
Given a bank account actor with account id "12345"
When I withdraw 100 from the account
Then the account balance should be 0
[Given("a bank account actor with account id {string}")]
public void GivenBankAccountActor(string accountId)
{
var clusterClient = BeforeAfterTestRunHooks.HostInstance?
.Services.GetRequiredService<IClusterClient>();
ArgumentNullException.ThrowIfNull(clusterClient);
this.bankAccountActor = clusterClient.GetGrain<IBankAccountActor>(accountId);
}
Messaging Model Testing¶
MassTransit Testing:
[When("I publish a message")]
public async Task WhenIPublishMessage()
{
var publishEndpoint = BeforeAfterTestRunHooks.HostInstance?
.Services.GetRequiredService<IPublishEndpoint>();
ArgumentNullException.ThrowIfNull(publishEndpoint);
await publishEndpoint.Publish(new MyEvent { Data = "test" });
}
Best Practices¶
Do's¶
-
Use Descriptive Step Names
-
Keep Steps Atomic
-
Use Data Tables for Complex Data
-
Share State Between Steps
-
Use Scope to Limit Step Definitions
-
Organize Features by Domain
-
Use Background Steps for Setup
-
Validate HTTP Responses Thoroughly
-
Use TestServerClient for HTTP Requests
-
Clean Up Resources in AfterFeature
Don'ts¶
-
Don't Use Technical Jargon in Features
-
Don't Repeat Step Logic
// ❌ BAD - Duplicate code [Given("user is logged in")] public void GivenUserIsLoggedIn() { /* login code */ } [Given("admin is logged in")] public void GivenAdminIsLoggedIn() { /* login code */ } // ✅ GOOD - Reusable step [Given("user {string} is logged in")] public void GivenUserIsLoggedIn(string username) { /* login code */ } -
Don't Mix Concerns in Steps
-
Don't Use Hardcoded Values
-
Don't Forget to Dispose HttpClient
// ❌ BAD - Resource leak var httpClient = BeforeAfterTestRunHooks.ServerInstance?.CreateClient(); var response = await httpClient.GetAsync("/api/endpoint"); // ✅ GOOD - Proper disposal using HttpClient? httpClient = BeforeAfterTestRunHooks.ServerInstance?.CreateClient(); var response = await httpClient.GetAsync("/api/endpoint"); -
Don't Mix Test Concerns
// ❌ BAD - Multiple concerns in one step [When("I create and verify aggregate root")] public async Task WhenICreateAndVerify() { // Creates and verifies in one step } // ✅ GOOD - Separate steps [When("I create MicroserviceAggregateRoot using rest API")] public async Task WhenICreate() { /* ... */ } [Then("i should receive valid create MicroserviceAggregateRoot response")] public async Task ThenIShouldReceiveValid() { /* ... */ } -
Don't Skip Error Response Validation
// ❌ BAD - Only checks status code Assert.AreEqual(HttpStatusCode.BadRequest, this.httpResponse.StatusCode); // ✅ GOOD - Validates full error response Assert.AreEqual(HttpStatusCode.BadRequest, this.httpResponse.StatusCode); var content = await this.httpResponse.Content.ReadAsStringAsync(); var problemDetails = JsonConvert.DeserializeObject<ValidationProblemDetails>(content); Assert.IsNotNull(problemDetails); Assert.AreEqual(400, problemDetails.Status);
Running BDD Tests¶
Local Execution¶
Run All Tests:
Run Specific Feature:
Run Tagged Tests:
Verbose Output:
CI/CD Integration¶
Azure DevOps Pipeline:
- task: DotNetCoreCLI@2
displayName: 'Run BDD Tests'
inputs:
command: 'test'
projects: '**/*AcceptanceTests.csproj'
arguments: '--logger trx --results-directory $(Agent.TempDirectory)'
GitHub Actions:
Advanced Patterns¶
Scenario Outlines¶
Use scenario outlines for parameterized tests:
Scenario Outline: Test AI providers
Given AI provider configured to use model chat completions "<provider>"
When I send a chat request
Then the response should be received
Examples:
| provider |
| openAI |
| azureOpenAI |
| ollama |
Background Steps¶
Use background for common setup:
Feature: User Management
Background:
Given the application is running
And the database is initialized
Scenario: Create user
When I create a user with name "John"
Then the user should be created
Multiple Step Matchers¶
Match multiple step patterns:
[Then("the chat response should be non-empty")]
[Then("the response should not be empty")]
[Then("I should receive a response")]
public void ThenResponseShouldBeNonEmpty()
{
Assert.IsNotEmpty(this.assistantResponse);
}
Step Argument Transformations¶
Transform step arguments:
[StepArgumentTransformation(@"(\d+) days? ago")]
public DateTime DaysAgoTransform(int days)
{
return DateTime.Now.AddDays(-days);
}
[Given("user created account (.* days ago)")]
public void GivenUserCreatedAccount(DateTime creationDate)
{
// creationDate is already transformed
}
Troubleshooting¶
Issue: Step Definitions Not Found¶
Symptom: "Step definition not found" error.
Solutions:
1. Ensure [Binding] attribute is on the class
2. Check step text matches exactly (case-sensitive)
3. Verify feature file is included in project
4. Rebuild project to regenerate code-behind files
Issue: TestServer Not Available¶
Symptom: ServerInstance is null in step definitions.
Solutions:
1. Ensure [BeforeFeature] hook is running
2. Check BeforeAfterTestRunHooks is properly initialized
3. Verify TestStartup is configured correctly
Issue: Tests Running in Parallel¶
Symptom: Tests interfere with each other.
Solutions:
1. Add [assembly: DoNotParallelize] to prevent parallel execution
2. Use [Scope] to isolate step definitions
3. Ensure proper cleanup in [AfterFeature] hooks
Summary¶
BDD Testing in the ConnectSoft Microservice Template provides:
- ✅ Living Documentation: Feature files document system behavior
- ✅ Business Readability: Tests written in natural language
- ✅ Collaboration: Shared language between stakeholders
- ✅ Test Automation: Automated acceptance tests
- ✅ Integration Testing: End-to-end scenario validation
- ✅ Reqnroll Integration: Robust BDD framework for .NET
- ✅ TestServer Integration: In-memory testing of ASP.NET Core applications
By following these patterns, teams can:
- Write Executable Specifications: Feature files serve as living documentation
- Collaborate Effectively: Business stakeholders can read and understand tests
- Catch Regressions Early: Automated acceptance tests prevent bugs
- Validate End-to-End: Test complete user journeys
- Maintain Quality: Continuous validation of business requirements
The BDD Testing ensures that acceptance tests are written in a way that both technical and non-technical stakeholders can understand, bridging the gap between requirements and implementation while providing automated validation of system behavior.
Related Documentation¶
- Startup and Warmup: Startup warmup patterns and coordination in acceptance tests
- Integration Testing: Integration testing patterns and best practices
- Unit Testing: Unit testing patterns and best practices
- Application Model: Application model and service registration