Skip to content

feat: add opt-in idempotency contract test base classes (#15)#176

Merged
Chris-Wolfgang merged 1 commit into
vNextfrom
feature/idempotency-contract-tests-15
Jun 24, 2026
Merged

feat: add opt-in idempotency contract test base classes (#15)#176
Chris-Wolfgang merged 1 commit into
vNextfrom
feature/idempotency-contract-tests-15

Conversation

@Chris-Wolfgang

Copy link
Copy Markdown
Owner

Summary

Implements #15 — opt-in idempotency contract tests for the Wolfgang.Etl.TestKit.Xunit package. Three abstract base classes verify that an ETL component produces identical results when run twice on the same instance.

Base classes (3)

Class Constraint Abstract members
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)

  1. ExtractAsync_when_called_twice_yields_identical_items_Async
  2. ExtractAsync_when_called_twice_with_progress_both_runs_report_Async
  3. LoadAsync_when_called_twice_processes_all_items_both_times_Async
  4. LoadAsync_when_called_twice_no_state_leaks_Async
  5. TransformAsync_when_called_twice_yields_identical_items_Async
  6. TransformAsync_when_called_twice_no_accumulated_side_effects_Async

Each 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 collecting TestLoader<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. Today CurrentItemCount is 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 set MaximumItemCount (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), not ExtractorBase/TProgress-based. These new bases mirror that reality:

  • Extractor needs both the parameterless and progress-aware extract methods (the interfaces are segregated in Abstractions 0.13.1), so it constrains to both IExtractAsync<TItem> and IExtractWithProgressAsync<TItem, TProgress>.
  • The loader base omits TProgress (its two tests don't use progress) to avoid an unused public type parameter, matching the shape of LoadAsyncContractTests<TSut, TItem>.
  • Loader test Dev #4 reads the collected snapshot via a new abstract TryGetLoadedItems(TSut) hook (returns null for non-collecting loaders, in which case the carryover check is skipped) so the base does not depend on the concrete TestLoader.

PublicAPI.Unshipped.txt entries added

Wolfgang.Etl.TestKit.Xunit.IdempotentExtractorContractTests<TSut, TItem, TProgress>
Wolfgang.Etl.TestKit.Xunit.IdempotentExtractorContractTests<TSut, TItem, TProgress>.IdempotentExtractorContractTests() -> void
Wolfgang.Etl.TestKit.Xunit.IdempotentExtractorContractTests<TSut, TItem, TProgress>.ExtractAsync_when_called_twice_yields_identical_items_Async() -> System.Threading.Tasks.Task!
Wolfgang.Etl.TestKit.Xunit.IdempotentExtractorContractTests<TSut, TItem, TProgress>.ExtractAsync_when_called_twice_with_progress_both_runs_report_Async() -> System.Threading.Tasks.Task!
abstract Wolfgang.Etl.TestKit.Xunit.IdempotentExtractorContractTests<TSut, TItem, TProgress>.CreateSut(int itemCount) -> TSut
abstract Wolfgang.Etl.TestKit.Xunit.IdempotentExtractorContractTests<TSut, TItem, TProgress>.CreateExpectedItems() -> System.Collections.Generic.IReadOnlyList<TItem>!
Wolfgang.Etl.TestKit.Xunit.IdempotentLoaderContractTests<TSut, TItem>
Wolfgang.Etl.TestKit.Xunit.IdempotentLoaderContractTests<TSut, TItem>.IdempotentLoaderContractTests() -> void
Wolfgang.Etl.TestKit.Xunit.IdempotentLoaderContractTests<TSut, TItem>.LoadAsync_when_called_twice_processes_all_items_both_times_Async() -> System.Threading.Tasks.Task!
Wolfgang.Etl.TestKit.Xunit.IdempotentLoaderContractTests<TSut, TItem>.LoadAsync_when_called_twice_no_state_leaks_Async() -> System.Threading.Tasks.Task!
abstract Wolfgang.Etl.TestKit.Xunit.IdempotentLoaderContractTests<TSut, TItem>.CreateSut(int itemCount) -> TSut
abstract Wolfgang.Etl.TestKit.Xunit.IdempotentLoaderContractTests<TSut, TItem>.CreateSourceItems() -> System.Collections.Generic.IReadOnlyList<TItem>!
abstract Wolfgang.Etl.TestKit.Xunit.IdempotentLoaderContractTests<TSut, TItem>.TryGetLoadedItems(TSut sut) -> System.Collections.Generic.IReadOnlyList<TItem>?
Wolfgang.Etl.TestKit.Xunit.IdempotentTransformerContractTests<TSut, TItem>
Wolfgang.Etl.TestKit.Xunit.IdempotentTransformerContractTests<TSut, TItem>.IdempotentTransformerContractTests() -> void
Wolfgang.Etl.TestKit.Xunit.IdempotentTransformerContractTests<TSut, TItem>.TransformAsync_when_called_twice_yields_identical_items_Async() -> System.Threading.Tasks.Task!
Wolfgang.Etl.TestKit.Xunit.IdempotentTransformerContractTests<TSut, TItem>.TransformAsync_when_called_twice_no_accumulated_side_effects_Async() -> System.Threading.Tasks.Task!
abstract Wolfgang.Etl.TestKit.Xunit.IdempotentTransformerContractTests<TSut, TItem>.CreateSut(int itemCount) -> TSut
abstract Wolfgang.Etl.TestKit.Xunit.IdempotentTransformerContractTests<TSut, TItem>.CreateExpectedItems() -> System.Collections.Generic.IReadOnlyList<TItem>!

Build / test results

  • dotnet build src/Wolfgang.Etl.TestKit.Xunit -c Releasesucceeded, 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.0233 passed, 0 failed (227 → 233; the 6 new idempotency tests all green).

Note: Closes #15 won't auto-fire on a merge into vNext; the issue stays open until vNext merges to main.

Closes #15

🤖 Generated with Claude Code

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant