C# Testing Patterns
Original skill by ECC. Keep this credit when you copy or change this skill.
Use these patterns to write clear and safe tests for C# and .NET apps.
When to Use This Skill
Use this skill when you:
- Write new C# tests.
- Review test quality or coverage.
- Set up tests for a .NET project.
- Fix slow or flaky tests.
- Add unit, API, database, or cancel tests.
Main Tools
| Tool | Use |
|---|---|
| xUnit | Run tests |
| FluentAssertions | Write clear checks |
| NSubstitute or Moq | Replace app parts in unit tests |
| WebApplicationFactory | Test ASP.NET Core APIs |
| Testcontainers | Test with a real database or service |
| Bogus | Make test data |
Use one mock tool per test project. Do not mix NSubstitute and Moq without a clear need.
Core Rules
- Test what the user can see.
- Keep each test about one case.
- Use Arrange, Act, and Assert.
- Give each test its own data.
- Do not depend on test order.
- Do not use a real clock, random value, web call, or shared file in a unit test.
- Pass a
CancellationTokento async code. - Use
async Task, notasync void. - Check both good and bad paths.
- Add a test when you fix a bug.
Name tests like this:
Method_ExpectedResult_WhenConditionUnit Test Structure
public sealed class OrderServiceTests
{
private readonly IOrderRepository _repository =
Substitute.For<IOrderRepository>();
private readonly ILogger<OrderService> _logger =
Substitute.For<ILogger<OrderService>>();
private readonly OrderService _sut;
public OrderServiceTests()
{
_sut = new OrderService(_repository, _logger);
}
[Fact]
public async Task PlaceOrderAsync_ReturnsSuccess_WhenRequestIsValid()
{
// Arrange
var request = new CreateOrderRequest
{
CustomerId = "cust-123",
Items = [new OrderItem("SKU-001", 2, 29.99m)]
};
// Act
var result = await _sut.PlaceOrderAsync(
request,
CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeNull();
result.Value!.CustomerId.Should().Be("cust-123");
}
[Fact]
public async Task PlaceOrderAsync_ReturnsFailure_WhenItemsAreEmpty()
{
// Arrange
var request = new CreateOrderRequest
{
CustomerId = "cust-123",
Items = []
};
// Act
var result = await _sut.PlaceOrderAsync(
request,
CancellationToken.None);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("at least one item");
}
}Only remove Arrange comments when the test is still easy to scan.
Tests with Many Inputs
Use [Theory] when the same rule needs many values.
[Theory]
[InlineData("", false)]
[InlineData("a", false)]
[InlineData("ab@c.d", false)]
[InlineData("user@example.com", true)]
[InlineData("user+tag@example.co.uk", true)]
public void IsValidEmail_ReturnsExpectedResult(
string email,
bool expected)
{
EmailValidator.IsValid(email).Should().Be(expected);
}Use MemberData for larger objects.
[Theory]
[MemberData(nameof(InvalidOrderCases))]
public async Task PlaceOrderAsync_ReturnsFailure_WhenOrderIsInvalid(
CreateOrderRequest request,
string expectedError)
{
var result = await _sut.PlaceOrderAsync(
request,
CancellationToken.None);
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain(expectedError);
}
public static TheoryData<CreateOrderRequest, string> InvalidOrderCases => new()
{
{
new CreateOrderRequest
{
CustomerId = "",
Items = [ValidItem()]
},
"CustomerId"
},
{
new CreateOrderRequest
{
CustomerId = "c1",
Items = []
},
"at least one item"
},
{
new CreateOrderRequest
{
CustomerId = "c1",
Items = [new OrderItem("", 1, 10m)]
},
"SKU"
}
};Add cases for empty text, spaces, zero, negative values, limits, and null when the type allows it.
Mocks with NSubstitute
Mock code outside the part being tested. Do not mock simple data objects.
[Fact]
public async Task GetOrderAsync_ReturnsNull_WhenOrderDoesNotExist()
{
// Arrange
var orderId = Guid.NewGuid();
_repository
.FindByIdAsync(orderId, Arg.Any<CancellationToken>())
.Returns((Order?)null);
// Act
var result = await _sut.GetOrderAsync(
orderId,
CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task PlaceOrderAsync_SavesOrder_WhenRequestIsValid()
{
// Arrange
var request = ValidOrderRequest();
// Act
await _sut.PlaceOrderAsync(request, CancellationToken.None);
// Assert
await _repository.Received(1).AddAsync(
Arg.Is<Order>(order =>
order.CustomerId == request.CustomerId),
Arg.Any<CancellationToken>());
}Only check calls when the call itself matters. Prefer checking the result or saved state.
Error and Cancel Tests
Check the exact error type when it is part of the app contract.
[Fact]
public async Task GetOrderAsync_Throws_WhenRepositoryFails()
{
var orderId = Guid.NewGuid();
_repository
.FindByIdAsync(orderId, Arg.Any<CancellationToken>())
.Returns<Task<Order?>>(_ =>
throw new InvalidOperationException("Database failed"));
var act = () => _sut.GetOrderAsync(
orderId,
CancellationToken.None);
await act.Should()
.ThrowAsync<InvalidOperationException>()
.WithMessage("Database failed");
}
[Fact]
public async Task PlaceOrderAsync_Stops_WhenCanceled()
{
using var source = new CancellationTokenSource();
source.Cancel();
var act = () => _sut.PlaceOrderAsync(
ValidOrderRequest(),
source.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
}Do not check full error text if the text is not part of the contract. Text may change often.
ASP.NET Core API Tests
Use WebApplicationFactory<Program> to test routes, status codes, headers, and JSON.
Use a unique database name for each test class. Shared names can leak data across tests.
public sealed class OrderApiTests :
IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrderApiTests(WebApplicationFactory<Program> factory)
{
var databaseName = $"OrderApiTests-{Guid.NewGuid()}";
_client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase(databaseName));
});
}).CreateClient();
}
[Fact]
public async Task GetOrder_Returns404_WhenOrderDoesNotExist()
{
var response = await _client.GetAsync(
$"/api/orders/{Guid.NewGuid()}");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task CreateOrder_Returns201_WhenRequestIsValid()
{
var request = new CreateOrderRequest
{
CustomerId = "cust-1",
Items = [new OrderItem("SKU-001", 1, 19.99m)]
};
var response = await _client.PostAsJsonAsync(
"/api/orders",
request);
response.StatusCode.Should().Be(HttpStatusCode.Created);
response.Headers.Location.Should().NotBeNull();
}
[Fact]
public async Task CreateOrder_Returns400_WhenItemsAreEmpty()
{
var request = new CreateOrderRequest
{
CustomerId = "cust-1",
Items = []
};
var response = await _client.PostAsJsonAsync(
"/api/orders",
request);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
}The EF Core in-memory store does not act like a real SQL database. It may hide SQL, key, and transaction bugs. Use it for simple API tests. Use Testcontainers when database rules matter.
Database Tests with Testcontainers
These tests need Docker. Pin the image version so test runs stay stable.
public sealed class PostgresOrderRepositoryTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres =
new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
private AppDbContext _db = null!;
public async Task InitializeAsync()
{
await _postgres.StartAsync();
var options =
new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(_postgres.GetConnectionString())
.Options;
_db = new AppDbContext(options);
await _db.Database.MigrateAsync();
}
public async Task DisposeAsync()
{
await _db.DisposeAsync();
await _postgres.DisposeAsync();
}
[Fact]
public async Task AddAsync_SavesOrder()
{
var repository = new SqlOrderRepository(_db);
var order = Order.Create(
"cust-1",
[new OrderItem("SKU-001", 2, 10m)]);
await repository.AddAsync(
order,
CancellationToken.None);
_db.ChangeTracker.Clear();
var found = await repository.FindByIdAsync(
order.Id,
CancellationToken.None);
found.Should().NotBeNull();
found!.Items.Should().HaveCount(1);
}
}Clear the EF Core change tracker before reading data back. This helps prove that the data came from the database.
If Docker is not ready, fail with a clear message. Do not hide the test by catching all errors.
Test Data Builders
Builders keep setup short. Their default data must be valid.
public sealed class OrderBuilder
{
private string _customerId = "cust-default";
private readonly List<OrderItem> _items =
[new OrderItem("SKU-001", 1, 10m)];
public OrderBuilder WithCustomer(string customerId)
{
_customerId = customerId;
return this;
}
public OrderBuilder WithItem(
string sku,
int quantity,
decimal price)
{
_items.Add(new OrderItem(sku, quantity, price));
return this;
}
public OrderBuilder WithoutItems()
{
_items.Clear();
return this;
}
public Order Build()
{
return Order.Create(_customerId, [.. _items]);
}
}Concrete use:
[Fact]
public void Build_CreatesVipOrder_WithPremiumItem()
{
var order = new OrderBuilder()
.WithCustomer("cust-vip")
.WithItem("SKU-PREMIUM", 3, 99.99m)
.Build();
order.CustomerId.Should().Be("cust-vip");
order.Items.Should().Contain(item =>
item.Sku == "SKU-PREMIUM" &&
item.Quantity == 3 &&
item.Price == 99.99m);
}Return a copy of lists when needed. This stops one test from changing data used by another test.
Time, Random Data, and Culture
Do not use DateTime.Now in code that must be tested. Pass in TimeProvider or a clock type.
Set a fixed random seed when random data is needed:
Randomizer.Seed = new Random(12345);
var faker = new Faker();Add culture tests when code reads or writes dates, money, or numbers. Do not assume every system uses the same date or decimal format.
Slow or Flaky Tests
When a test fails only at times:
- Check for shared state.
- Check for fixed ports and file names.
- Check for real clock use.
- Check for random data with no fixed seed.
- Check for tests that depend on run order.
- Check for tasks that were not awaited.
- Check for database data left by an old test.
- Check for strict time limits.
- Check for real network calls.
Do not use Thread.Sleep.
For code that changes later, poll until a short time limit ends:
private static async Task WaitUntilAsync(
Func<bool> condition,
TimeSpan timeout)
{
var stopAt = DateTime.UtcNow + timeout;
while (!condition())
{
if (DateTime.UtcNow >= stopAt)
{
throw new TimeoutException(
"The test condition was not met in time.");
}
await Task.Delay(20);
}
}Use this only when the app work is truly done in the background. Prefer a task or event that can be awaited.
Test Layout
tests/
MyApp.UnitTests/
Services/
OrderServiceTests.cs
PaymentServiceTests.cs
Validators/
EmailValidatorTests.cs
MyApp.IntegrationTests/
Api/
OrderApiTests.cs
Repositories/
OrderRepositoryTests.cs
MyApp.TestHelpers/
Builders/
OrderBuilder.cs
Fixtures/
DatabaseFixture.csKeep unit and integration tests in separate projects when they need different tools or run times.
Common Problems
| Problem | Fix |
|---|---|
| Tests check private code | Check public results and behavior |
| Tests share data they can change | Make fresh data for each test |
| Tests need a set run order | Remove the link between tests |
Async tests use Thread.Sleep |
Await work or poll with a short limit |
Tests use async void |
Return Task |
Tests check ToString() |
Check typed fields |
| One test checks many cases | Split it into focused tests |
| Names tell how code works | Name the case and result |
| Code drops the cancel token | Pass it and test cancel behavior |
| Unit tests call real services | Use a fake or mock |
| Database tests pass only in memory | Test key SQL rules with a real database |
| Tests use the real clock | Pass in a clock |
| Random tests fail at times | Use a fixed seed |
| Parallel tests use one file or port | Give each test a unique name or port |
| Cleanup hides the first error | Keep cleanup safe and simple |
Run Tests
# Run all tests
dotnet test
# Run tests and collect coverage
dotnet test --collect:"XPlat Code Coverage"
# Run one project
dotnet test tests/MyApp.UnitTests/
# Run tests with a matching name
dotnet test --filter "FullyQualifiedName~OrderService"
# Keep running tests while files change
dotnet watch test --project tests/MyApp.UnitTests/A high coverage number does not prove good tests. Cover key rules, errors, limits, and user paths first.
Final Check
Before you finish, make sure:
- The test name states the case and result.
- The test can run by itself.
- The result does not depend on time or run order.
- All async work is awaited.
- Good, bad, empty, limit, and cancel cases are covered when they matter.
- Unit tests do not call real outside services.
- Integration tests clean up their own data.
- The full test suite passes.