7 questions on testing .NET code: NUnit lifecycle and parameterized tests, Moq doubles and verification, Reqnroll BDD, and integration tests with WebApplicationFactory and Testcontainers.
01TestingFoundation
NUnit Parameterized Tests and Constraint-Based Assertions
Answer
Parameterized Testing: The [TestCase] attribute passes inline test arguments directly into test method parameters. Each [TestCase] generates a distinct, reportable test entry in the test runner, enabling concise coverage of boundary values and edge cases.
TestCaseSource: For complex data sets, non-primitive objects, or dynamically generated values, [TestCaseSource] references a static property, method, or class implementing IEnumerable. Each yielded TestCaseData instance can specify method arguments, test display names, and explicit categories.
Constraint-Based Assertions: The Assert.That syntax evaluates an actual value against an IResolveConstraint expression. Instead of relying on static method overloads (such as Assert.AreEqual), constraint expressions (e.g. Is.EqualTo, Is.Not.Null, Has.Count) construct an evaluation tree.
Failure Output: Constraint assertions format failure messages by displaying the expected constraint specification, the actual received value, and specific collection diffs or string mismatch positions.
Flow diagram: NUnit constraint assertion evaluation and reporting
C# (NUnit 4 Parameterized & Assertions)
usingSystem;
usingSystem.Collections;
usingSystem.Threading.Tasks;
usingNUnit.Framework;
[TestFixture]
publicclassPricingCalculatorTests
{
[TestCase(100.0, 0.10, 90.0)]
[TestCase(50.0, 0.0, 50.0)]
[TestCase(200.0, 0.25, 150.0)]
publicvoidCalculateDiscount_WithInlineCases_ReturnsExpectedTotal(
decimal basePrice, decimal discountRatio, decimal expectedTotal)
{
var result = PricingCalculator.ApplyDiscount(basePrice, discountRatio);
Assert.That(result, Is.EqualTo(expectedTotal));
}
[TestCaseSource(typeof(PricingTestData), nameof(PricingTestData.InvalidDiscountCases))]
publicvoidCalculateDiscount_WithInvalidInputs_ThrowsArgumentOutOfRangeException(
decimal basePrice, decimal discountRatio)
{
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
PricingCalculator.ApplyDiscount(basePrice, discountRatio));
Assert.That(ex.ParamName, Is.EqualTo("discountRatio"));
}
[Test]
publicasyncTaskProcessOrderAsync_WhenValid_ValidatesAllPropertiesTogether()
{
var order = awaitOrderService.CreateOrderAsync("SKU-1024", quantity: 3);
Assert.Multiple(() =>
{
Assert.That(order, Is.Not.Null);
Assert.That(order.ItemCount, Is.EqualTo(3));
Assert.That(order.Status, Is.EqualTo(OrderStatus.Created));
Assert.That(order.CreatedAtUtc, Is.LessThanOrEqualTo(DateTime.UtcNow));
});
}
}
publicstaticclassPricingTestData
{
publicstaticIEnumerableInvalidDiscountCases
{
get
{
yieldreturnnewTestCaseData(100m, -0.05m).SetName("Negative discount ratio rejected");
yieldreturnnewTestCaseData(100m, 1.05m).SetName("Discount ratio over 100% rejected");
}
}
}
Multiple Assertions with Assert.Multiple
By default, an assertion failure immediately halts execution of the test method. Wrapping multiple assertions in Assert.Multiple(() => { ... }) allows NUnit to record every failed assertion within the block and report all failures together at the end of the test run.
This avoids having to fix one assertion failure, rerun the suite, and encounter another assertion failure on the immediately following line.
Asynchronous Exception Assertions
Testing asynchronous exceptions requires passing an async delegate to Assert.ThrowsAsync<T>() or using Assert.That with Throws.TypeOf<T>().
NUnit awaits the delegate, catches the thrown exception, unwraps AggregateException where applicable, and verifies the exception type and error message.
02TestingFoundation
Moq Verification, Strict vs Loose Mocks, and Callbacks
Answer
Behavior Verification vs State Verification: State verification checks the return value or side-effect state of the subject under test. Behavior verification uses mock.Verify() to confirm that specific methods on collaborators were invoked with expected arguments. Verifying every internal call tightly couples tests to implementation details, causing tests to break during internal refactorings that preserve observable behavior.
Loose Mocking (Default): MockBehavior.Loose never throws if an unconfigured member is invoked. It returns default(T) (0, false, null, or an empty array/enumerable). This keeps test setups concise by ignoring auxiliary method calls irrelevant to the current test.
Strict Mocking: new Mock<T>(MockBehavior.Strict) throws a MockException immediately if any member is called that does not have an explicit Setup. Strict mocks detect unintended side-effects, but require comprehensive setups for every dependency interaction.
Invocation Cardinality: Times.Once(), Times.Never(), Times.Exactly(n), and Times.AtLeastOnce() assert the exact number of times a method was called. mock.VerifyNoOtherCalls() verifies that no interactions occurred on the mock other than those explicitly verified.
Flow diagram: Mock verification decision tree: State vs Behavior
The .Callback<T>(arg => ...) method captures arguments passed to the mock during execution. This allows assertions on complex objects, such as verifying generated IDs, timestamps, or state changes before a repository save.
Tracking Properties with SetupProperty
By default, mocks do not retain property state. Calling mock.SetupProperty(x => x.Count) gives the property standard getter/setter storage behavior. mock.SetupAllProperties() initializes tracking across all properties on the mocked interface.
03TestingFoundation
Reqnroll BDD Architecture, Gherkin Syntax, and Step Bindings
Answer
Reqnroll Overview: Reqnroll is an open-source Behaviour-Driven Development (BDD) framework for .NET, created by the community as the official successor to SpecFlow following the discontinuation of SpecFlow in 2024. It parses Gherkin feature files and bridges business specifications to automated .NET tests.
Gherkin Specification Structure: Specifications are written in .feature files using standard Gherkin keywords: Feature (high-level capability), Scenario (concrete business acceptance example), Given (preconditions), When (actions/events), and Then (expected observable outcomes), with And/But for conjunctions.
Build-Time Code Generation: During MSBuild compilation, Reqnroll translates each .feature file into a C# test class targeting the configured test framework (NUnit, xUnit, or MSTest). Each scenario generates a test method decorated with the test framework's attributes (e.g. [Test] for NUnit).
Step Definition Bindings: Methods matching Gherkin steps are marked with [Binding] on the enclosing class and [Given], [When], or [Then] attributes on methods. Expressions use regular expressions or Cucumber Expressions with parameter capturing groups (e.g. @"user deposits \$(\d+)") to extract arguments directly into method parameters.
Flow diagram: Reqnroll build and execution pipeline
Gherkin & C# (Reqnroll Feature and Step Definition)
// BankAccount.feature// Feature: Bank Account Withdrawals// Scenario: Successful withdrawal with sufficient funds// Given an account with an opening balance of $500// When the customer requests a withdrawal of $200// Then the withdrawal should be approved// And the new account balance should be $300usingNUnit.Framework;
usingReqnroll;
[Binding]
publicclassBankAccountStepDefinitions
{
privateBankAccount _account = null!;
privateWithdrawalResult _result = null!;
[Given(@"an account with an opening balance of \$(\d+)")]
publicvoidGivenAnAccountWithAnOpeningBalanceOf(decimal openingBalance)
{
_account = newBankAccount(openingBalance);
}
[When(@"the customer requests a withdrawal of \$(\d+)")]
publicvoidWhenTheCustomerRequestsAWithdrawalOf(decimal withdrawalAmount)
{
_result = _account.Withdraw(withdrawalAmount);
}
[Then(@"the withdrawal should be approved")]
publicvoidThenTheWithdrawalShouldBeApproved()
{
Assert.That(_result.IsSuccess, Is.True);
}
[Then(@"the new account balance should be \$(\d+)")]
publicvoidThenTheNewAccountBalanceShouldBe(decimal expectedBalance)
{
Assert.That(_account.Balance, Is.EqualTo(expectedBalance));
}
}
Gherkin Semantics: Given, When, Then
Given steps establish the known system state prior to the action (e.g., creating accounts, seeding balances, configuring user permissions).
When steps represent the specific stimulus or transaction being exercised by the user or external actor.
Then steps assert the observable business outcome, verifying output values, state changes, or emitted domain events.
Scenario Outlines and Examples
Scenario Outlines use placeholders in angle brackets (e.g. <input>, <expected>) mapped to a tabular Examples block. Reqnroll generates an independent test execution for every row in the Examples table.
04TestingIntermediate
Reqnroll Context Injection, Tables, and Step Transformations
Answer
State Sharing Problem: BDD scenarios frequently span multiple step definition classes (e.g. AuthenticationSteps, OrderSteps, PaymentSteps). Using static fields to share scenario state causes race conditions when running scenarios in parallel and creates hidden dependencies.
Context Injection (BoDi Container): Reqnroll solves state sharing via constructor dependency injection using BoDi (a lightweight built-in container) or Microsoft.Extensions.DependencyInjection. Every scenario has an isolated container scope. When multiple step definition classes request the same context class in their constructors, Reqnroll injects the same instance, then disposes it when the scenario finishes.
Table Data Mapping: Gherkin data tables are passed to step definitions as Reqnroll.Table parameters. Using the Reqnroll.Assist extension methods (e.g. table.CreateInstance<OrderModel>() and table.CreateSet<OrderItemModel>()), tables automatically deserialize into strongly-typed DTOs by matching header names to property names.
Step Argument Transformations: The [StepArgumentTransformation] attribute converts raw string inputs or tables into custom domain types (such as Currency, DateTimeOffset, or Entity identifiers) before passing them to the step method, keeping step signatures strongly typed.
usingSystem.Collections.Generic;
usingNUnit.Framework;
usingReqnroll;
// Shared POCO context state scoped per scenariopublicclassOrderScenarioContext
{
publicCustomer? CurrentCustomer { get; set; }
publicOrder? PlacedOrder { get; set; }
}
[Binding]
publicclassCustomerStepDefinitions
{
privatereadonlyOrderScenarioContext _context;
// Injected automatically by Reqnroll's DI containerpublicCustomerStepDefinitions(OrderScenarioContext context)
{
_context = context;
}
[Given(@"a registered customer with the following details:")]
publicvoidGivenARegisteredCustomerWithDetails(Table table)
{
// Reqnroll.Assist deserializes table columns into Customer DTOvar customer = table.CreateInstance<Customer>();
_context.CurrentCustomer = customer;
}
}
[Binding]
publicclassOrderStepDefinitions
{
privatereadonlyOrderScenarioContext _context;
publicOrderStepDefinitions(OrderScenarioContext context)
{
_context = context;
}
[When(@"the customer places an order for items:")]
publicvoidWhenTheCustomerPlacesAnOrder(Table table)
{
var items = table.CreateSet<OrderItem>();
var order = newOrder(_context.CurrentCustomer!.Id);
foreach (var item in items)
{
order.AddItem(item.Sku, item.Quantity, item.UnitPrice);
}
_context.PlacedOrder = order;
}
[Then(@"the total order price should be \$(\d+)")]
publicvoidThenTheTotalOrderPriceShouldBe(decimal expectedTotal)
{
Assert.That(_context.PlacedOrder!.CalculateTotal(), Is.EqualTo(expectedTotal));
}
}
Lifecycle Hooks in Reqnroll
Hooks allow executing setup and teardown logic around test executions: [BeforeTestRun], [AfterTestRun], [BeforeFeature], [AfterFeature], [BeforeScenario], and [AfterScenario].
Tag filtering can restrict hook execution to specific scenarios, such as [BeforeScenario("@database")] to reset test databases only for scenarios that interact with storage.
ScenarioContext and FeatureContext
Reqnroll provides built-in ScenarioContext for accessing scenario metadata, tags, and error information. While ScenarioContext can store key-value pairs, strongly typed POCO context classes injected via constructor injection are preferred for type safety and refactoring.
05TestingAdvanced
NUnit Test Lifecycle, Fixtures, and State Isolation
Answer
Test Fixture Declarations: Test fixtures are marked with the [TestFixture] attribute. By default, NUnit instantiates the fixture class once and reuses that instance across all [Test] methods within the class. This contrasts with xUnit, which instantiates a fresh class instance for every individual test method.
Hook Execution Lifecycle: [OneTimeSetUp] runs once before any test in the fixture executes. Before each individual [Test] method runs, [SetUp] executes. After each test method finishes, [TearDown] runs. Once all tests in the fixture complete, [OneTimeTearDown] executes.
State Leakage Risk: Because fixture instance fields are retained across tests by default, any mutation of instance state in one test can bleed into subsequent tests. State isolation requires re-initializing mutable fields inside [SetUp] or applying the [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] attribute.
Parallel Test Execution: NUnit supports concurrent test runs using the [Parallelizable] attribute. Applying [Parallelizable(ParallelScope.All)] on a fixture allows test methods within that fixture to run concurrently on separate worker threads, requiring complete thread-safety or isolation of instance and static state.
Flow diagram: NUnit test fixture execution lifecycle
C# (NUnit 4 Lifecycle Example)
usingSystem;
usingSystem.IO;
usingNUnit.Framework;
[TestFixture]
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
publicclassFileProcessorTests : IDisposable
{
privatestring _tempFilePath = null!;
privateFileStream _fileStream = null!;
[OneTimeSetUp]
publicvoidGlobalSetup()
{
// Executes once before any test in this fixture runsTestContext.Progress.WriteLine("Starting FileProcessor test suite execution.");
}
[SetUp]
publicvoidSetUp()
{
// Executes before each individual test method
_tempFilePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.tmp");
_fileStream = File.Create(_tempFilePath);
}
[Test]
publicvoidWriteData_WhenValid_WritesBytesToDisk()
{
// Arrange
byte[] payload = [0x41, 0x42, 0x43];
// Act
_fileStream.Write(payload, 0, payload.Length);
_fileStream.Flush();
// AssertAssert.That(newFileInfo(_tempFilePath).Length, Is.EqualTo(3));
}
[TearDown]
publicvoidTearDown()
{
// Guaranteed cleanup after each test, even on failure
_fileStream?.Dispose();
if (File.Exists(_tempFilePath))
{
File.Delete(_tempFilePath);
}
}
[OneTimeTearDown]
publicvoidGlobalTeardown()
{
// Executes once after all tests in this fixture completeTestContext.Progress.WriteLine("Completed FileProcessor test suite execution.");
}
publicvoidDispose()
{
_fileStream?.Dispose();
GC.SuppressFinalize(this);
}
}
Cleanup Guarantees and IDisposable
The [TearDown] method is guaranteed to execute even if the test method throws an assertion failure or unhandled exception. This ensures unmanaged resources, database connections, and temporary files are cleaned up.
If a test fixture implements IDisposable, NUnit calls Dispose() after [OneTimeTearDown] completes, providing compatibility with standard .NET disposal patterns.
NUnit vs xUnit Lifecycle Differences
xUnit does not use [SetUp] or [TearDown] attributes. Instead, xUnit executes the class constructor before each test and IDisposable.Dispose() after each test, enforcing per-test instance isolation by default.
NUnit requires explicit configuration via [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] to match xUnit's instance-per-test isolation model.
06TestingAdvanced
Moq Test Doubles, Stubs, and Castle DynamicProxy Internals
Answer
Test Double Taxonomy: Gerard Meszaros defines five test double types: Dummy (passed around but never used), Stub (provides canned answers to calls during the test), Spy (records calls and parameters for later inspection), Mock (pre-programmed with expectations to verify interaction), and Fake (lightweight working implementation, like an in-memory database).
Castle DynamicProxy Generation: Moq relies on Castle.Core DynamicProxy to generate an in-memory proxy type implementing the requested interface or inheriting from an abstract or non-sealed class using System.Reflection.Emit.
Virtual Member Interception Constraint: Because DynamicProxy uses subclassing and interface implementation at runtime, Moq can only intercept interface members or virtual/abstract members on classes. Non-virtual methods, static methods, and sealed classes cannot be intercepted by Moq.
Setup and Invocation Pipeline: Calling mock.Setup(x => x.Method(args)) parses the expression tree and registers an IMethodCall in the mock's interceptor. When the test subject calls the proxy, the interceptor matches invocation arguments against registered setups and executes the configured Return or Throws delegate.
Sequence diagram: Castle DynamicProxy dispatch flow in Moq
It.IsAny<T>() matches any value of type T, including null for nullable reference types or nullables.
It.Is<T>(predicate) evaluates a boolean predicate against the runtime argument. If the predicate returns false, Moq does not match the setup, falling back to default behavior or failing in strict mode.
Asynchronous Method Setups
For methods returning Task<T> or ValueTask<T>, use ReturnsAsync(value) or ReturnsAsync(() => calculatedValue). This wraps the return value into a completed Task without needing Task.FromResult boilerplate.
07TestingAdvanced
Integration Testing with WebApplicationFactory and Testcontainers
Answer
Integration Testing Purpose: Unit tests with mocks verify logic in isolation, but cannot verify EF Core queries, model validation, routing attributes, authentication handlers, or middleware order. Integration testing boots the ASP.NET Core host in memory to verify cross-layer communication.
WebApplicationFactory<TProgram>: Microsoft.AspNetCore.Mvc.Testing provides WebApplicationFactory to instantiate the application's Program class within a TestServer. TestServer intercepts HTTP requests in memory via an in-process HttpMessageHandler without binding to external network TCP sockets.
Overriding Services: WithWebHostBuilder(builder => builder.ConfigureTestServices(services => ...)) allows swapping specific dependencies in the DI container (such as replacing an external payment gateway or message publisher with a test double) while keeping real EF Core DbContexts, validators, and controllers intact.
Database Isolation with Testcontainers: Using the EF Core In-Memory provider has limitations because it does not enforce foreign keys, unique constraints, or transaction isolation semantics. Modern .NET integration testing uses Testcontainers for .NET to spin up real Docker containers for PostgreSQL, SQL Server, or Redis on demand for the test run.
Flow diagram: WebApplicationFactory in-memory test pipeline
To prevent data created in one test from leaking into another, integration test suites can execute tests inside an ambient transaction scope that is rolled back during [TearDown], or execute database truncation scripts via Respawn before each test.
HttpClient In-Memory Dispatch
factory.CreateClient() returns an HttpClient configured to dispatch requests directly into TestServer. Requests pass through all registered ASP.NET Core middleware (authentication, routing, exception handling) and return standard HttpResponseMessage objects.