-
Notifications
You must be signed in to change notification settings - Fork 1
Test with TestKit
Last verified against v2.0.1
IntegratoR.TestKit gives test projects Result<T>-aware assertions, FIFO HTTP and cache fakes, and pre-built entities so a handler test reads like the behaviour it pins. Add a <PackageReference Include="IntegratoR.TestKit" /> to test projects only — AddIntegratoR does not register it.
using FluentResults;
using IntegratoR.Abstractions.Common.CQRS.Commands;
using IntegratoR.Abstractions.Common.Results;
using IntegratoR.OData.FO.Domain.Entities.LedgerJournal;
using IntegratoR.TestKit.Assertions;
using NSubstitute;
using Xunit;
[Fact]
public async Task CreateHandler_ReturnsPersistedHeader()
{
var header = new LedgerJournalHeader
{
DataAreaId = "USMF",
JournalName = "GenJrnl",
Description = "Month-end accrual"
};
IService<LedgerJournalHeader> service = Substitute.For<IService<LedgerJournalHeader>>();
service.AddAsync(header, Arg.Any<CancellationToken>())
.Returns(Result.Ok(header));
var handler = new CreateCommandHandler<LedgerJournalHeader>(service);
Result<LedgerJournalHeader> result =
await handler.Handle(new CreateCommand<LedgerJournalHeader>(header), CancellationToken.None);
result.Should().BeSuccessful();
result.Should().HaveValue(header);
}The five assertions live in IntegratoR.TestKit.Assertions and chain off .Should(): BeSuccessful(), BeFailed(), HaveErrorCode(string), HaveErrorType(ErrorType), and HaveValue(T) (generic Result<T> only). HaveErrorCode and HaveErrorType read the first IntegrationError on the result, so they fail with a clear message if the result carries a plain Result.Fail("...").
Assert the rejection the same way you assert success — chain BeFailed() into the code or type. This pins the Validation error a create raises when JournalName is missing:
[Fact]
public async Task CreateHandler_SurfacesValidationError()
{
var header = new LedgerJournalHeader { DataAreaId = "USMF", JournalName = "GenJrnl", Description = "x" };
IService<LedgerJournalHeader> service = Substitute.For<IService<LedgerJournalHeader>>();
service.AddAsync(header, Arg.Any<CancellationToken>())
.Returns(Result.Fail(new IntegrationError(
"Validation.Error", "JournalName is required", ErrorType.Validation)));
var handler = new CreateCommandHandler<LedgerJournalHeader>(service);
Result<LedgerJournalHeader> result =
await handler.Handle(new CreateCommand<LedgerJournalHeader>(header), CancellationToken.None);
result.Should().BeFailed().And.HaveErrorType(ErrorType.Validation);
result.Should().HaveErrorCode("Validation.Error");
}BeSuccessful() on a failed result reports the embedded error — "Expected result to be successful, but it failed with: JournalName is required" — so a test failure names the cause without extra logging.
FakeHttpMessageHandler returns queued responses in FIFO order and records what was sent. Construct it parameterless, queue one response per expected call, then call CreateClient():
using System.Net;
using IntegratoR.TestKit.Fakes;
var handler = new FakeHttpMessageHandler();
handler.Queue(HttpStatusCode.OK, """{"value":[{"dataAreaId":"USMF"}]}"""); // (status, body)
handler.Queue(new HttpResponseMessage(HttpStatusCode.NoContent)); // pre-built message
HttpClient client = handler.CreateClient();
HttpResponseMessage first = await client.GetAsync(new Uri("https://host/data/LedgerJournalHeaders"));
first.StatusCode.Should().Be(HttpStatusCode.OK);
// Inspect what the code under test sent — bodies captured eagerly, survive request disposal.
handler.SentRequests.Should().HaveCount(1);
handler.SentRequestBodies[0].Should().BeNull(); // GET has no bodySentRequests and SentRequestBodies are index-aligned; a request with no content yields a null body. Queue a response for every request the code makes.
Caution
FakeHttpMessageHandler throws InvalidOperationException — "No more queued responses" — when the code makes more requests than you queued. Queue one response per expected call, including each item of a batch write (composite-key batch Update/Delete issue one request per item).
FakeCacheService is an in-memory ICacheService for CachingBehaviour tests. It stores entries forever — CacheDuration is ignored — and exposes Count, Contains, and Clear for inspection:
var cache = new FakeCacheService();
await cache.SetAsync("LedgerJournalHeaders-USMF", header); // no CancellationToken on ICacheService
cache.Contains("LedgerJournalHeaders-USMF").Should().BeTrue();
cache.Count.Should().Be(1);
cache.Clear(); // reset between testsTo assert duration or eviction semantics, run the production DistributedCacheService against a MemoryDistributedCache instead — FakeCacheService cannot express them.
Four entities in IntegratoR.TestKit.Doubles.Entities cover generic-handler and serialisation tests without pulling a full D365 entity into the fixture. Each inherits the non-generic BaseEntity and overrides GetCompositeKey().
| Entity | GetCompositeKey() |
Use for |
|---|---|---|
TestEntity |
[Id, PartitionKey] |
Generic command/query handler tests |
TestEntityWithODataAttributes |
[Id] |
[ODataField(IgnoreOnCreate/IgnoreOnUpdate)] payload-stripping tests |
TestEntityWithD365Attributes |
[DataAreaId, JournalBatchNumber!] |
AllowEdit/AllowEditOnCreate/IsRequired runtime-enforcement tests |
TestSingleKeyEntity |
[Id] |
Single-key handling (no array-length ≥ 2 assumption) |
TestEntityBuilder builds TestEntity from a static Default() factory with fixed values — Id "test-id", PartitionKey "test-partition", Name "Test Entity", Description null — then chain With… overrides for only what the test cares about:
using IntegratoR.TestKit.Builders;
TestEntity entity = TestEntityBuilder.Default() // static factory, not new TestEntityBuilder()
.WithName("Custom Name")
.WithPartitionKey("partition-1")
.Build();Note
Default() is a static factory returning a fresh builder with hardcoded values — call TestEntityBuilder.Default(), not new TestEntityBuilder().Default(). Values are constant across runs (no random suffix), so two builders with the same overrides produce equal entities.
Register only what the handler resolves, then send through IMediator:
ServiceCollection services = new();
services.AddSingleton<ICacheService>(new FakeCacheService());
services.AddSingleton<IService<TestEntity>>(Substitute.For<IService<TestEntity>>());
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(MyHandler).Assembly));
ServiceProvider provider = services.BuildServiceProvider();
IMediator mediator = provider.GetRequiredService<IMediator>();For a full-stack test of registration itself, build an in-memory IConfiguration and call the one public entry point, AddIntegratoR — not the internal AddODataClient/AddApplicationServices composition steps:
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ODataSettings:Url"] = "https://example.com/data",
["ODataSettings:Authentication:Mode"] = "OAuth",
["ODataSettings:Authentication:OAuth:ClientId"] = "test"
})
.Build();
services.AddIntegratoR(configuration);Pin behaviour; skip tests that mirror structure a compiler already guarantees.
- Pipeline behaviours (logging, validation, caching) — real cross-cutting logic.
- Handlers with real composition or error handling.
- Serialisation contracts (
[ODataField],[JsonPropertyName],Result<T>round-trip). - LINQ-to-OData filter translation — one test per supported shape.
- Regression pins for fixed bugs (composite-key URL construction, BaseAddress normalisation, enum qualified-type form).
Skip POCO getters/setters, DI-registration checks that every interface resolves, and pass-through delegation.
Run the suite with dotnet test; scope to one project with dotnet test tests/IntegratoR.OData.Tests or one test with dotnet test --filter "FullyQualifiedName~ClassName.MethodName".
Get Started
Use Cases
- Handle Errors
- Configure Resilience
- Add Validation
- Cache Query Results
- Work with Dimensions
- Run Smoke Tests
- Extend the Pipeline
- Test with TestKit
Reference