Backend Testing
Test Naming Standards
Follow Microsoft's unit testing best practices:
Pattern: MethodUnderTest_Scenario_ExpectedBehavior
-
MethodUnderTest — The actual method on the class being tested, not necessarily the entry point you call. For example, when testing ObjectToInferredTypesConverter , use Read (the converter's method) even though you invoke it via _serializer.Deserialize() .
-
Scenario — The input, state, or condition being tested.
-
ExpectedBehavior — What the method should do or return.
// ✅ Good: Clear method, scenario, and expected behavior [Fact] public void GetValue_JObjectWithUserInfo_ReturnsTypedUserInfo() { }
[Fact] public void GetValue_MissingKey_ThrowsKeyNotFoundException() { }
[Fact] public void Read_EmptyArray_ReturnsEmptyList() { } // Tests ObjectToInferredTypesConverter.Read()
[Fact] public void Write_NestedDictionary_SerializesCorrectly() { } // Tests ObjectToInferredTypesConverter.Write()
[Fact] public async Task PostEvent_WithValidPayload_ReturnsAccepted() { }
// ❌ Bad: Vague or missing context [Fact] public void TestGetValue() { }
[Fact] public void CanGetValue() { }
[Fact] public void Deserialize_EmptyArray_ReturnsEmptyList() { } // Wrong: Deserialize is the entry point, not the method under test
Running Tests
All tests
dotnet test
By test name
dotnet test --filter "FullyQualifiedName~PostEvent_WithValidPayload_ReturnsAccepted"
By class name
dotnet test --filter "ClassName~EventControllerTests"
Test Folder Structure
Tests mirror the source structure:
tests/Exceptionless.Tests/ ├── AppWebHostFactory.cs # WebApplicationFactory for integration tests ├── IntegrationTestsBase.cs # Base class for integration tests ├── TestWithServices.cs # Base class for unit tests with DI ├── Controllers/ # API controller tests ├── Jobs/ # Job tests ├── Repositories/ # Repository tests ├── Services/ # Service tests ├── Utility/ # Test data builders │ ├── AppSendBuilder.cs # Fluent HTTP request builder │ ├── DataBuilder.cs # Test data creation │ ├── EventData.cs │ ├── OrganizationData.cs │ ├── ProjectData.cs │ ├── ProxyTimeProvider.cs # Time manipulation │ └── ... └── Validation/ # Validator tests
Integration Test Base Pattern
Inherit from IntegrationTestsBase which uses Foundatio.Xunit's TestWithLoggingBase :
// From tests/Exceptionless.Tests/IntegrationTestsBase.cs public abstract class IntegrationTestsBase : TestWithLoggingBase, IAsyncLifetime, IClassFixture<AppWebHostFactory> { protected readonly TestServer _server; private readonly ProxyTimeProvider _timeProvider;
public IntegrationTestsBase(ITestOutputHelper output, AppWebHostFactory factory) : base(output)
{
_server = factory.Server;
_timeProvider = GetService<ProxyTimeProvider>();
}
protected TService GetService<TService>() where TService : notnull
=> ServiceProvider.GetRequiredService<TService>();
protected FluentClient CreateFluentClient()
{
var settings = GetService<JsonSerializerSettings>();
return new FluentClient(CreateHttpClient(), new NewtonsoftJsonSerializer(settings));
}
}
Real Test Example
From EventControllerTests.cs:
public class EventControllerTests : IntegrationTestsBase { private readonly IEventRepository _eventRepository; private readonly IQueue<EventPost> _eventQueue;
public EventControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory)
{
_eventRepository = GetService<IEventRepository>();
_eventQueue = GetService<IQueue<EventPost>>();
}
[Fact]
public async Task PostEvent_WithValidPayload_EnqueuesAndProcessesEvent()
{
// Arrange
/* language=json */
const string json = """{"message":"test","reference_id":"TestReferenceId"}""";
// Act
await SendRequestAsync(r => r
.Post()
.AsTestOrganizationClientUser()
.AppendPath("events")
.Content(json, "application/json")
.StatusCodeShouldBeAccepted()
);
var stats = await _eventQueue.GetQueueStatsAsync();
Assert.Equal(1, stats.Enqueued);
var processEventsJob = GetService<EventPostsJob>();
await processEventsJob.RunAsync();
await RefreshDataAsync();
// Assert
var events = await _eventRepository.GetAllAsync();
var ev = events.Documents.Single(e => e.Type == Event.KnownTypes.Log);
Assert.Equal("test", ev.Message);
}
}
Test Structure (Arrange-Act-Assert)
Use clear // Arrange , // Act , // Assert comments for readability:
[Fact] public void GetValue_DirectUserInfoType_ReturnsTypedValue() { // Arrange var userInfo = new UserInfo("test@example.com", "Test User"); var data = new DataDictionary { { "user", userInfo } };
// Act
var result = data.GetValue<UserInfo>("user", _jsonOptions);
// Assert
Assert.NotNull(result);
Assert.Equal("test@example.com", result.Identity);
Assert.Equal("Test User", result.Name);
}
JSON String Literals
Use /* language=json */ comment before JSON strings for IDE syntax highlighting and validation:
[Fact] public void GetValue_JsonStringWithError_ReturnsTypedError() { // Arrange /* language=json */ const string json = """{"message":"Test error","type":"System.Exception"}"""; var data = new DataDictionary { { "@error", json } };
// Act
var result = data.GetValue<Error>("@error", _jsonOptions);
// Assert
Assert.NotNull(result);
Assert.Equal("Test error", result.Message);
}
FluentClient Pattern
Use SendRequestAsync with AppSendBuilder for HTTP testing:
await SendRequestAsync(r => r .Post() .AsTestOrganizationUser() // Basic auth with test user .AppendPath("organizations") .Content(new NewOrganization { Name = "Test" }) .StatusCodeShouldBeCreated() );
// Available auth helpers r.AsGlobalAdminUser() // TEST_USER_EMAIL r.AsTestOrganizationUser() // TEST_ORG_USER_EMAIL r.AsFreeOrganizationUser() // FREE_USER_EMAIL r.AsTestOrganizationClientUser() // API key bearer token
Test Data Builders
Create test data with CreateDataAsync :
var (stacks, events) = await CreateDataAsync(b => b .Event() .TestProject() .Type(Event.KnownTypes.Error) .Message("Test error"));
Assert.Single(stacks); Assert.Single(events);
Time Manipulation with ProxyTimeProvider
NOT ISystemClock — use .NET 8+ TimeProvider with ProxyTimeProvider :
// Access via protected property protected ProxyTimeProvider TimeProvider => _timeProvider;
// Advance time TimeProvider.Advance(TimeSpan.FromHours(1));
// Set specific time TimeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 15, 12, 0, 0, TimeSpan.Zero));
// Restore to system time TimeProvider.Restore();
Registered in test services:
services.ReplaceSingleton<TimeProvider>(_ => new ProxyTimeProvider());
Test Principles
-
TDD workflow — When fixing bugs or adding features, write a failing test first
-
Use real serializer — Tests use the same JSON serializer as production
-
Use real time provider — Manipulate via ProxyTimeProvider when needed
-
Refresh after writes — Call RefreshDataAsync() after database changes
-
Clean state — ResetDataAsync() clears data between tests
Foundatio.Xunit
Base class provides logging integration:
using Foundatio.Xunit;
public class MyTests : TestWithLoggingBase { public MyTests(ITestOutputHelper output) : base(output) { Log.DefaultLogLevel = LogLevel.Information; } }