feat: add opt-in idempotency contract test base classes (#15)#176
Merged
Conversation
Adds three opt-in abstract xUnit contract test base classes that verify an ETL component produces identical results when run twice on the same instance: - IdempotentExtractorContractTests<TSut, TItem, TProgress> - IdempotentLoaderContractTests<TSut, TItem> - IdempotentTransformerContractTests<TSut, TItem> Each base carries 2 tests (6 total) and is wired into the consumer test project via concrete subclasses over the TestKit doubles so the contract runs in CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This was referenced Jun 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements #15 — opt-in idempotency contract tests for the
Wolfgang.Etl.TestKit.Xunitpackage. Three abstract base classes verify that an ETL component produces identical results when run twice on the same instance.Base classes (3)
IdempotentExtractorContractTests<TSut, TItem, TProgress>TSut : IExtractAsync<TItem>, IExtractWithProgressAsync<TItem, TProgress>CreateSut(int),CreateExpectedItems()IdempotentLoaderContractTests<TSut, TItem>TSut : ILoadAsync<TItem>CreateSut(int),CreateSourceItems(),TryGetLoadedItems(TSut)IdempotentTransformerContractTests<TSut, TItem>TSut : ITransformAsync<TItem, TItem>CreateSut(int),CreateExpectedItems()All carry
TItem : notnull/TProgress : notnull, matching the existing contract bases.Tests (6 — 2 per base)
ExtractAsync_when_called_twice_yields_identical_items_AsyncExtractAsync_when_called_twice_with_progress_both_runs_report_AsyncLoadAsync_when_called_twice_processes_all_items_both_times_AsyncLoadAsync_when_called_twice_no_state_leaks_AsyncTransformAsync_when_called_twice_yields_identical_items_AsyncTransformAsync_when_called_twice_no_accumulated_side_effects_AsyncEach calls
CreateSut(...)once and exercises the SUT twice. Consumer concrete subclasses (TestExtractorIdempotencyTests,TestLoaderIdempotencyTests,TestTransformerIdempotencyTests) wire the real TestKit doubles so the contracts execute in CI — the loader subclass uses a collectingTestLoader<int>so test #4 is meaningful.Opt-in rationale
Each base's class-level
<remarks>documents that it is opt-in: inherit only if the component supports being run more than once on the same instance. Single-use components (e.g. a stream-backed extractor with a forward-only source) must not inherit it.Deferred (not added)
The three
*_CurrentItemCount_resets_*tests from the issue draft are intentionally deferred. TodayCurrentItemCountis cumulative across runs; the reset-vs-cumulative contract is under decision in ETL-Abstractions#246. A// NOTE:comment in each base records this. Because the count is cumulative, the tests deliberately do not setMaximumItemCount(a second run would immediately hit a cumulative limit and yield nothing) and rely on default limits.Design notes / deviation from the issue draft
The repo's real contract bases are interface-constrained (
IExtractAsync,ILoadAsync,ITransformAsync), notExtractorBase/TProgress-based. These new bases mirror that reality:IExtractAsync<TItem>andIExtractWithProgressAsync<TItem, TProgress>.TProgress(its two tests don't use progress) to avoid an unused public type parameter, matching the shape ofLoadAsyncContractTests<TSut, TItem>.TryGetLoadedItems(TSut)hook (returnsnullfor non-collecting loaders, in which case the carryover check is skipped) so the base does not depend on the concreteTestLoader.PublicAPI.Unshipped.txt entries added
Build / test results
dotnet build src/Wolfgang.Etl.TestKit.Xunit -c Release— succeeded, 0 warnings / 0 errors across all TFMs (net462; netstandard2.0; net481; net8.0; net10.0). PublicAPI analyzer (RS0016) clean.dotnet test tests/Wolfgang.Etl.TestKit.Xunit.Tests.Unit -c Release -f net8.0— 233 passed, 0 failed (227 → 233; the 6 new idempotency tests all green).Closes #15
🤖 Generated with Claude Code