Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
135 changes: 135 additions & 0 deletions src/ExperimentFramework.Testing/ExperimentTestHost.cs
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&lt;IMyDatabase, MyDatabase&gt;();
/// services.AddScoped&lt;CloudDatabase&gt;();
/// })
/// .WithExperiments(experiments => experiments
/// .Trial&lt;IMyDatabase&gt;(trial => trial
/// .UsingTest()
/// .AddControl&lt;MyDatabase&gt;()
/// .AddCondition&lt;CloudDatabase&gt;("true")))
/// .Build();
///
/// using var scope = ExperimentTestScope.Begin()
/// .ForceCondition&lt;IMyDatabase&gt;("true");
///
/// var db = host.Services.GetRequiredService&lt;IMyDatabase&gt;();
/// await db.PingAsync();
///
/// Assert.True(host.Trace.ExpectRouted&lt;IMyDatabase&gt;("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!;
Comment thread
JerrettDavis marked this conversation as resolved.

/// <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 src/ExperimentFramework.Testing/ExperimentTestMatrix.cs
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&lt;IMyService&gt;(t => t
/// .UsingTest()
/// .AddControl&lt;DefaultImpl&gt;()
/// .AddCondition&lt;TestImpl&gt;("test")),
/// sp =>
/// {
/// using var scope = ExperimentTestScope.Begin().ForceCondition&lt;IMyService&gt;("test");
/// var svc = sp.GetRequiredService&lt;IMyService&gt;();
/// 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);
}
}
Loading
Loading