-
Notifications
You must be signed in to change notification settings - Fork 1
Add ExperimentFramework.Testing package for deterministic testing #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
JerrettDavis
merged 5 commits into
main
from
copilot/add-experimentframework-testing-package
Jan 15, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
d852519
Initial plan
Copilot 80c6b0f
Add ExperimentFramework.Testing package with core functionality
Copilot 9e0410a
Add README and improve trace decorator comments
Copilot 6d2fd50
Changes before error encountered
Copilot 327c3aa
Address PR review feedback: fix URLs, improve performance, add tests,…
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
39 changes: 39 additions & 0 deletions
39
src/ExperimentFramework.Testing/ExperimentFramework.Testing.csproj
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk"> | ||
|
|
||
| <PropertyGroup> | ||
| <TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks> | ||
| <Nullable>enable</Nullable> | ||
| <ImplicitUsings>enable</ImplicitUsings> | ||
| <LangVersion>preview</LangVersion> | ||
|
|
||
| <!-- NuGet Package Metadata --> | ||
| <PackageId>ExperimentFramework.Testing</PackageId> | ||
| <Version>1.0.0</Version> | ||
| <Authors>JD</Authors> | ||
| <Description>Testing utilities for ExperimentFramework including deterministic routing, test harnesses, trace capture, and assertions. Enables trivial testing of experiments without mocking.</Description> | ||
| <PackageTags>experiments;testing;ab-testing;harness;deterministic;assertions;beta</PackageTags> | ||
| <PackageLicenseExpression>MIT</PackageLicenseExpression> | ||
| <PackageProjectUrl>https://github.com/JerrettDavis/ExperimentFramework</PackageProjectUrl> | ||
| <RepositoryUrl>https://github.com/JerrettDavis/ExperimentFramework</RepositoryUrl> | ||
| <RepositoryType>git</RepositoryType> | ||
| <PackageReadmeFile>README.md</PackageReadmeFile> | ||
| <GenerateDocumentationFile>true</GenerateDocumentationFile> | ||
| <GeneratePackageOnBuild>true</GeneratePackageOnBuild> | ||
| <IncludeSymbols>true</IncludeSymbols> | ||
| <SymbolPackageFormat>snupkg</SymbolPackageFormat> | ||
| </PropertyGroup> | ||
|
|
||
| <ItemGroup> | ||
| <None Include="README.md" Pack="true" PackagePath="\" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup> | ||
| <ProjectReference Include="..\ExperimentFramework\ExperimentFramework.csproj" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup> | ||
| <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="$(MicrosoftExtensionsVersion)" /> | ||
| <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="$(MicrosoftExtensionsVersion)" /> | ||
| </ItemGroup> | ||
|
|
||
| </Project> |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| using ExperimentFramework.Selection; | ||
| using Microsoft.Extensions.DependencyInjection; | ||
|
|
||
| namespace ExperimentFramework.Testing; | ||
|
|
||
| /// <summary> | ||
| /// Test host for easily setting up and testing experiments. | ||
| /// </summary> | ||
| /// <example> | ||
| /// <code> | ||
| /// var host = ExperimentTestHost.Create(services => | ||
| /// { | ||
| /// services.AddScoped<IMyDatabase, MyDatabase>(); | ||
| /// services.AddScoped<CloudDatabase>(); | ||
| /// }) | ||
| /// .WithExperiments(experiments => experiments | ||
| /// .Trial<IMyDatabase>(trial => trial | ||
| /// .UsingTest() | ||
| /// .AddControl<MyDatabase>() | ||
| /// .AddCondition<CloudDatabase>("true"))) | ||
| /// .Build(); | ||
| /// | ||
| /// using var scope = ExperimentTestScope.Begin() | ||
| /// .ForceCondition<IMyDatabase>("true"); | ||
| /// | ||
| /// var db = host.Services.GetRequiredService<IMyDatabase>(); | ||
| /// await db.PingAsync(); | ||
| /// | ||
| /// Assert.True(host.Trace.ExpectRouted<IMyDatabase>("true")); | ||
| /// </code> | ||
| /// </example> | ||
| public sealed class ExperimentTestHost | ||
| { | ||
| private ExperimentTestHost() | ||
| { | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets the configured service provider. | ||
| /// </summary> | ||
| public IServiceProvider Services { get; private set; } = null!; | ||
|
|
||
| /// <summary> | ||
| /// Gets the trace assertions helper for verifying experiment behavior. | ||
| /// </summary> | ||
| public ExperimentTraceAssertions Trace { get; private set; } = null!; | ||
|
|
||
| /// <summary> | ||
| /// Gets the raw event sink for advanced scenarios. | ||
| /// </summary> | ||
| public InMemoryExperimentEventSink EventSink { get; private set; } = null!; | ||
|
|
||
| /// <summary> | ||
| /// Creates a new test host builder. | ||
| /// </summary> | ||
| /// <param name="configureServices">Action to configure services.</param> | ||
| /// <returns>A new test host builder.</returns> | ||
| public static ExperimentTestHostBuilder Create(Action<IServiceCollection> configureServices) | ||
| { | ||
| return new ExperimentTestHostBuilder(configureServices); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Builder for configuring the experiment test host. | ||
| /// </summary> | ||
| public sealed class ExperimentTestHostBuilder | ||
| { | ||
| private readonly Action<IServiceCollection> _configureServices; | ||
| private Action<ExperimentFrameworkBuilder>? _configureExperiments; | ||
|
|
||
| internal ExperimentTestHostBuilder(Action<IServiceCollection> configureServices) | ||
| { | ||
| _configureServices = configureServices ?? throw new ArgumentNullException(nameof(configureServices)); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Configures experiments for the test host. | ||
| /// </summary> | ||
| /// <param name="configureExperiments">Action to configure experiments.</param> | ||
| /// <returns>This builder for fluent chaining.</returns> | ||
| public ExperimentTestHostBuilder WithExperiments(Action<ExperimentFrameworkBuilder> configureExperiments) | ||
| { | ||
| _configureExperiments = configureExperiments ?? throw new ArgumentNullException(nameof(configureExperiments)); | ||
| return this; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Builds the test host. | ||
| /// </summary> | ||
| /// <returns>The configured test host.</returns> | ||
| public ExperimentTestHost Build() | ||
| { | ||
| var services = new ServiceCollection(); | ||
|
|
||
| // Configure user services | ||
| _configureServices(services); | ||
|
|
||
| // Create event sink and trace | ||
| var eventSink = new InMemoryExperimentEventSink(); | ||
| var trace = new ExperimentTraceAssertions(eventSink); | ||
|
|
||
| // Register event sink as singleton | ||
| services.AddSingleton(eventSink); | ||
| services.AddSingleton(trace); | ||
|
|
||
| // Register test selection provider factory | ||
| services.AddSingleton<ISelectionModeProviderFactory, TestSelectionProviderFactory>(); | ||
|
|
||
| // Configure experiments if provided | ||
| if (_configureExperiments != null) | ||
| { | ||
| var experimentBuilder = ExperimentFrameworkBuilder.Create(); | ||
|
|
||
| // Add trace capturing decorator | ||
| experimentBuilder.AddDecoratorFactory(new TraceCapturingDecoratorFactory(eventSink)); | ||
|
|
||
| // Apply user experiment configuration | ||
| _configureExperiments(experimentBuilder); | ||
|
|
||
| // Register with DI | ||
| services.AddExperimentFramework(experimentBuilder); | ||
| } | ||
|
|
||
| // Build service provider | ||
| var serviceProvider = services.BuildServiceProvider(); | ||
|
|
||
| return new ExperimentTestHost | ||
| { | ||
| Services = serviceProvider, | ||
| Trace = trace, | ||
| EventSink = eventSink | ||
| }; | ||
| } | ||
| } | ||
| } | ||
156 changes: 156 additions & 0 deletions
156
src/ExperimentFramework.Testing/ExperimentTestMatrix.cs
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| using Microsoft.Extensions.DependencyInjection; | ||
|
|
||
| namespace ExperimentFramework.Testing; | ||
|
|
||
| /// <summary> | ||
| /// Represents the proxy strategy to use for an experiment. | ||
| /// </summary> | ||
| public enum ProxyStrategy | ||
| { | ||
| /// <summary> | ||
| /// Use source-generated compile-time proxies (default, fastest). | ||
| /// </summary> | ||
| SourceGenerated, | ||
|
|
||
| /// <summary> | ||
| /// Use DispatchProxy-based runtime proxies (slower, more flexible). | ||
| /// </summary> | ||
| DispatchProxy | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Options for controlling test matrix execution. | ||
| /// </summary> | ||
| public sealed class ExperimentTestMatrixOptions | ||
| { | ||
| /// <summary> | ||
| /// Gets or sets the proxy strategies to test against. | ||
| /// </summary> | ||
| public ProxyStrategy[] Strategies { get; set; } = | ||
| { | ||
| ProxyStrategy.SourceGenerated, | ||
| ProxyStrategy.DispatchProxy | ||
| }; | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets whether to stop on first failure or continue testing all strategies. | ||
| /// </summary> | ||
| public bool StopOnFirstFailure { get; set; } = false; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Utilities for running experiments across multiple proxy strategies. | ||
| /// </summary> | ||
| public static class ExperimentTestMatrix | ||
| { | ||
| /// <summary> | ||
| /// Runs the same test logic across all configured proxy strategies. | ||
| /// </summary> | ||
| /// <param name="configure">Action to configure the experiment framework builder.</param> | ||
| /// <param name="test">The test action to execute with the service provider.</param> | ||
| /// <param name="options">Optional matrix test options.</param> | ||
| /// <exception cref="AggregateException"> | ||
| /// Thrown if any strategy test fails (when StopOnFirstFailure is false). | ||
| /// </exception> | ||
| /// <example> | ||
| /// <code> | ||
| /// ExperimentTestMatrix.RunInAllProxyModes( | ||
| /// builder => builder | ||
| /// .Trial<IMyService>(t => t | ||
| /// .UsingTest() | ||
| /// .AddControl<DefaultImpl>() | ||
| /// .AddCondition<TestImpl>("test")), | ||
| /// sp => | ||
| /// { | ||
| /// using var scope = ExperimentTestScope.Begin().ForceCondition<IMyService>("test"); | ||
| /// var svc = sp.GetRequiredService<IMyService>(); | ||
| /// Assert.Equal(42, svc.GetValue()); | ||
| /// }); | ||
| /// </code> | ||
| /// </example> | ||
| public static void RunInAllProxyModes( | ||
| Action<ExperimentFrameworkBuilder> configure, | ||
| Action<IServiceProvider> test, | ||
| ExperimentTestMatrixOptions? options = null) | ||
| { | ||
| RunInAllProxyModes(services => { }, configure, test, options); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Runs the same test logic across all configured proxy strategies with custom service configuration. | ||
| /// </summary> | ||
| /// <param name="configureServices">Action to configure DI services.</param> | ||
| /// <param name="configure">Action to configure the experiment framework builder.</param> | ||
| /// <param name="test">The test action to execute with the service provider.</param> | ||
| /// <param name="options">Optional matrix test options.</param> | ||
| /// <exception cref="AggregateException"> | ||
| /// Thrown if any strategy test fails (when StopOnFirstFailure is false). | ||
| /// </exception> | ||
| public static void RunInAllProxyModes( | ||
| Action<IServiceCollection> configureServices, | ||
| Action<ExperimentFrameworkBuilder> configure, | ||
| Action<IServiceProvider> test, | ||
| ExperimentTestMatrixOptions? options = null) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(configureServices); | ||
| ArgumentNullException.ThrowIfNull(configure); | ||
| ArgumentNullException.ThrowIfNull(test); | ||
|
|
||
| options ??= new ExperimentTestMatrixOptions(); | ||
| var exceptions = new List<Exception>(); | ||
|
|
||
| foreach (var strategy in options.Strategies) | ||
| { | ||
| try | ||
| { | ||
| RunTestWithStrategy(strategy, configureServices, configure, test); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| var wrappedException = new InvalidOperationException( | ||
| $"Test failed with {strategy} proxy strategy", ex); | ||
|
|
||
| if (options.StopOnFirstFailure) | ||
| { | ||
| throw wrappedException; | ||
| } | ||
|
|
||
| exceptions.Add(wrappedException); | ||
| } | ||
| } | ||
|
|
||
| if (exceptions.Count > 0) | ||
| { | ||
| throw new AggregateException( | ||
| $"Test failed for {exceptions.Count} proxy strategy/strategies", | ||
| exceptions); | ||
| } | ||
| } | ||
|
|
||
| private static void RunTestWithStrategy( | ||
| ProxyStrategy strategy, | ||
| Action<IServiceCollection> configureServices, | ||
| Action<ExperimentFrameworkBuilder> configure, | ||
| Action<IServiceProvider> test) | ||
| { | ||
| var host = ExperimentTestHost.Create(configureServices) | ||
| .WithExperiments(builder => | ||
| { | ||
| // Apply strategy | ||
| if (strategy == ProxyStrategy.DispatchProxy) | ||
| { | ||
| builder.UseDispatchProxy(); | ||
| } | ||
| else | ||
| { | ||
| builder.UseSourceGenerators(); | ||
| } | ||
|
|
||
| // Apply user configuration | ||
| configure(builder); | ||
| }) | ||
| .Build(); | ||
|
|
||
| test(host.Services); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.