Testing Strategies¶
Overview¶
This section covers comprehensive testing strategies for applications using the Flaggy feature flag library, including unit tests, integration tests, and testing patterns for feature flag-driven code.
Test Setup¶
Using InMemory Provider for Tests¶
The InMemory provider is ideal for testing as it provides fast, isolated flag storage without external dependencies.
using Flaggy.Abstractions;
using Flaggy.Extensions;
using Flaggy.Models;
using Flaggy.Providers;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
public class FeatureFlagTestBase : IDisposable
{
protected readonly IServiceProvider ServiceProvider;
protected readonly IFeatureFlagService FlagService;
public FeatureFlagTestBase()
{
var services = new ServiceCollection();
// Use InMemory provider for testing
services.AddFlaggy(new InMemoryFeatureFlagProvider());
ServiceProvider = services.BuildServiceProvider();
FlagService = ServiceProvider.GetRequiredService<IFeatureFlagService>();
}
public void Dispose()
{
(ServiceProvider as IDisposable)?.Dispose();
}
}
Test Fixtures with Pre-seeded Flags¶
public class FeatureFlagFixture : IAsyncLifetime
{
public IFeatureFlagService FlagService { get; private set; }
public async Task InitializeAsync()
{
var services = new ServiceCollection();
services.AddFlaggy(new InMemoryFeatureFlagProvider());
var provider = services.BuildServiceProvider();
FlagService = provider.GetRequiredService<IFeatureFlagService>();
// Pre-seed common test flags
await FlagService.CreateFlagAsync(new FeatureFlag
{
Key = "test-feature",
is_enabled = true,
description = "Test feature flag"
});
await FlagService.CreateFlagAsync(new FeatureFlag
{
Key = "disabled-feature",
is_enabled = false,
description = "Disabled test feature"
});
}
public Task DisposeAsync() => Task.CompletedTask;
}
public class MyTests : IClassFixture<FeatureFlagFixture>
{
private readonly IFeatureFlagService _flagService;
public MyTests(FeatureFlagFixture fixture)
{
_flagService = fixture.FlagService;
}
[Fact]
public async Task TestFeature_is_enabled()
{
var isEnabled = await _flagService.is_enabledAsync("test-feature");
Assert.True(isEnabled);
}
}
Unit Testing Patterns¶
Testing Flag Evaluation¶
public class FlagEvaluationTests : FeatureFlagTestBase
{
[Fact]
public async Task is_enabledAsync_WhenFlagExists_ReturnsCorrectState()
{
// Arrange
await FlagService.CreateFlagAsync(new FeatureFlag
{
Key = "my-feature",
is_enabled = true
});
// Act
var isEnabled = await FlagService.is_enabledAsync("my-feature");
// Assert
Assert.True(isEnabled);
}
[Fact]
public async Task is_enabledAsync_WhenFlagDoesNotExist_ReturnsFalse()
{
// Act
var isEnabled = await FlagService.is_enabledAsync("non-existent-flag");
// Assert
Assert.False(isEnabled);
}
[Fact]
public async Task is_enabledAsync_WhenFlagDisabled_ReturnsFalse()
{
// Arrange
await FlagService.CreateFlagAsync(new FeatureFlag
{
Key = "disabled-feature",
is_enabled = false
});
// Act
var isEnabled = await FlagService.is_enabledAsync("disabled-feature");
// Assert
Assert.False(isEnabled);
}
}
Testing Flag Values¶
public class FlagValueTests : FeatureFlagTestBase
{
[Fact]
public async Task GetValueAsync_WhenFlagHasValue_ReturnsValue()
{
// Arrange
await FlagService.CreateFlagAsync(new FeatureFlag
{
Key = "api-endpoint",
is_enabled = true,
Value = "https://api.example.com"
});
// Act
var value = await FlagService.GetValueAsync("api-endpoint");
// Assert
Assert.Equal("https://api.example.com", value);
}
[Fact]
public async Task GetValueAsync_WhenFlagDisabled_ReturnsDefault()
{
// Arrange
await FlagService.CreateFlagAsync(new FeatureFlag
{
Key = "api-endpoint",
is_enabled = false,
Value = "https://api.example.com"
});
// Act
var value = await FlagService.GetValueAsync("api-endpoint", defaultValue: "https://default.com");
// Assert
Assert.Equal("https://default.com", value);
}
[Theory]
[InlineData("100", 100)]
[InlineData("0", 0)]
[InlineData("-50", -50)]
public async Task GetValueAsync_Int_ParsesCorrectly(string flagValue, int expected)
{
// Arrange
await FlagService.CreateFlagAsync(new FeatureFlag
{
Key = "max-retries",
is_enabled = true,
Value = flagValue
});
// Act
var value = await FlagService.GetValueAsync<int>("max-retries", defaultValue: 0);
// Assert
Assert.Equal(expected, value.Value);
}
[Theory]
[InlineData("0.5", 0.5)]
[InlineData("1.25", 1.25)]
[InlineData("99.99", 99.99)]
public async Task GetValueAsync_Double_ParsesCorrectly(string flagValue, double expected)
{
// Arrange
await FlagService.CreateFlagAsync(new FeatureFlag
{
Key = "discount-rate",
is_enabled = true,
Value = flagValue
});
// Act
var value = await FlagService.GetValueAsync<double>("discount-rate", defaultValue: 0.0);
// Assert
Assert.Equal(expected, value.Value, precision: 2);
}
[Fact]
public async Task GetValueAsync_InvalidFormat_ReturnsDefault()
{
// Arrange
await FlagService.CreateFlagAsync(new FeatureFlag
{
Key = "invalid-number",
is_enabled = true,
Value = "not-a-number"
});
// Act
var value = await FlagService.GetValueAsync<int>("invalid-number", defaultValue: 42);
// Assert
Assert.Equal(42, value.Value);
}
}
Testing CRUD Operations¶
public class FlagCrudTests : FeatureFlagTestBase
{
[Fact]
public async Task CreateFlagAsync_WhenValid_CreatesFlag()
{
// Arrange
var flag = new FeatureFlag
{
Key = "new-feature",
is_enabled = true,
Value = "test-value",
description = "Test description"
};
// Act
var result = await FlagService.CreateFlagAsync(flag);
// Assert
Assert.True(result);
var retrieved = await FlagService.GetFlagAsync("new-feature");
Assert.NotNull(retrieved);
Assert.Equal("test-value", retrieved.Value);
Assert.Equal("Test description", retrieved.description);
}
[Fact]
public async Task UpdateFlagAsync_WhenExists_UpdatesFlag()
{
// Arrange
await FlagService.CreateFlagAsync(new FeatureFlag
{
Key = "existing-flag",
is_enabled = false,
Value = "old-value"
});
var updatedFlag = new FeatureFlag
{
Key = "existing-flag",
is_enabled = true,
Value = "new-value"
};
// Act
var result = await FlagService.UpdateFlagAsync(updatedFlag);
// Assert
Assert.True(result);
var retrieved = await FlagService.GetFlagAsync("existing-flag");
Assert.True(retrieved.is_enabled);
Assert.Equal("new-value", retrieved.Value);
}
[Fact]
public async Task DeleteFlagAsync_WhenExists_DeletesFlag()
{
// Arrange
await FlagService.CreateFlagAsync(new FeatureFlag
{
Key = "to-delete",
is_enabled = true
});
// Act
var result = await FlagService.DeleteFlagAsync("to-delete");
// Assert
Assert.True(result);
var retrieved = await FlagService.GetFlagAsync("to-delete");
Assert.Null(retrieved);
}
}
Testing Extension Methods¶
public class FlagExtensionTests : FeatureFlagTestBase
{
[Fact]
public async Task CreateFlagIfNotExistsAsync_WhenNotExists_CreatesFlag()
{
// Act
var result = await FlagService.CreateFlagIfNotExistsAsync(
"new-flag",
isEnabled: true,
value: "test"
);
// Assert
Assert.True(result);
var flag = await FlagService.GetFlagAsync("new-flag");
Assert.NotNull(flag);
}
[Fact]
public async Task CreateFlagIfNotExistsAsync_WhenExists_DoesNotCreate()
{
// Arrange
await FlagService.CreateFlagAsync(new FeatureFlag
{
Key = "existing",
is_enabled = false,
Value = "original"
});
// Act
var result = await FlagService.CreateFlagIfNotExistsAsync(
"existing",
isEnabled: true,
value: "new"
);
// Assert
Assert.False(result);
var flag = await FlagService.GetFlagAsync("existing");
Assert.Equal("original", flag.Value); // Original value unchanged
}
[Fact]
public async Task ToggleFlagAsync_TogglesState()
{
// Arrange
await FlagService.CreateFlagAsync(new FeatureFlag
{
Key = "toggle-test",
is_enabled = false
});
// Act
await FlagService.ToggleFlagAsync("toggle-test");
// Assert
var isEnabled = await FlagService.is_enabledAsync("toggle-test");
Assert.True(isEnabled);
// Toggle again
await FlagService.ToggleFlagAsync("toggle-test");
isEnabled = await FlagService.is_enabledAsync("toggle-test");
Assert.False(isEnabled);
}
[Fact]
public async Task GetEnabledFlagsAsync_ReturnsOnlyEnabledFlags()
{
// Arrange
await FlagService.CreateFlagAsync(new FeatureFlag { Key = "flag1", is_enabled = true });
await FlagService.CreateFlagAsync(new FeatureFlag { Key = "flag2", is_enabled = false });
await FlagService.CreateFlagAsync(new FeatureFlag { Key = "flag3", is_enabled = true });
// Act
var enabledFlags = await FlagService.GetEnabledFlagsAsync();
// Assert
Assert.Equal(2, enabledFlags.Count());
Assert.Contains(enabledFlags, f => f.Key == "flag1");
Assert.Contains(enabledFlags, f => f.Key == "flag3");
}
}
Testing Feature Flag-Driven Code¶
Testing Both Code Paths¶
public class PaymentService
{
private readonly IFeatureFlagService _flagService;
private readonly IPaymentProcessor _newProcessor;
private readonly IPaymentProcessor _legacyProcessor;
public PaymentService(
IFeatureFlagService flagService,
IPaymentProcessor newProcessor,
IPaymentProcessor legacyProcessor)
{
_flagService = flagService;
_newProcessor = newProcessor;
_legacyProcessor = legacyProcessor;
}
public async Task<PaymentResult> ProcessPaymentAsync(Payment payment)
{
var useNewProcessor = await _flagService.is_enabledAsync("new-payment-processor");
return useNewProcessor
? await _newProcessor.ProcessAsync(payment)
: await _legacyProcessor.ProcessAsync(payment);
}
}
public class PaymentServiceTests
{
private readonly Mock<IFeatureFlagService> _mockFlagService;
private readonly Mock<IPaymentProcessor> _mockNewProcessor;
private readonly Mock<IPaymentProcessor> _mockLegacyProcessor;
private readonly PaymentService _service;
public PaymentServiceTests()
{
_mockFlagService = new Mock<IFeatureFlagService>();
_mockNewProcessor = new Mock<IPaymentProcessor>();
_mockLegacyProcessor = new Mock<IPaymentProcessor>();
_service = new PaymentService(
_mockFlagService.Object,
_mockNewProcessor.Object,
_mockLegacyProcessor.Object
);
}
[Fact]
public async Task ProcessPayment_WhenFlagEnabled_UsesNewProcessor()
{
// Arrange
_mockFlagService
.Setup(x => x.is_enabledAsync("new-payment-processor", default))
.ReturnsAsync(true);
_mockNewProcessor
.Setup(x => x.ProcessAsync(It.IsAny<Payment>()))
.ReturnsAsync(new PaymentResult { Success = true });
var payment = new Payment { Amount = 100 };
// Act
var result = await _service.ProcessPaymentAsync(payment);
// Assert
Assert.True(result.Success);
_mockNewProcessor.Verify(x => x.ProcessAsync(payment), Times.Once);
_mockLegacyProcessor.Verify(x => x.ProcessAsync(It.IsAny<Payment>()), Times.Never);
}
[Fact]
public async Task ProcessPayment_WhenFlagDisabled_UsesLegacyProcessor()
{
// Arrange
_mockFlagService
.Setup(x => x.is_enabledAsync("new-payment-processor", default))
.ReturnsAsync(false);
_mockLegacyProcessor
.Setup(x => x.ProcessAsync(It.IsAny<Payment>()))
.ReturnsAsync(new PaymentResult { Success = true });
var payment = new Payment { Amount = 100 };
// Act
var result = await _service.ProcessPaymentAsync(payment);
// Assert
Assert.True(result.Success);
_mockLegacyProcessor.Verify(x => x.ProcessAsync(payment), Times.Once);
_mockNewProcessor.Verify(x => x.ProcessAsync(It.IsAny<Payment>()), Times.Never);
}
}
Theory-Based Testing for Multiple Flag States¶
public class FeatureServiceTests
{
[Theory]
[InlineData(true, true, "Both features enabled")]
[InlineData(true, false, "Only feature A enabled")]
[InlineData(false, true, "Only feature B enabled")]
[InlineData(false, false, "No features enabled")]
public async Task ProcessData_WithVariousFlagCombinations_BehavesCorrectly(
bool featureAEnabled,
bool featureBEnabled,
string expectedBehavior)
{
// Arrange
var services = new ServiceCollection();
services.AddFlaggy(new InMemoryFeatureFlagProvider());
var provider = services.BuildServiceProvider();
var flagService = provider.GetRequiredService<IFeatureFlagService>();
await flagService.UpsertFlagAsync("feature-a", featureAEnabled);
await flagService.UpsertFlagAsync("feature-b", featureBEnabled);
var service = new FeatureService(flagService);
// Act
var result = await service.ProcessDataAsync();
// Assert
Assert.Contains(expectedBehavior, result.description);
}
}
Testing Flag Fallback Behavior¶
public class ConfigurationServiceTests
{
[Fact]
public async Task GetMaxRetries_WhenFlagExists_ReturnsConfiguredValue()
{
// Arrange
var services = new ServiceCollection();
services.AddFlaggy(new InMemoryFeatureFlagProvider());
var provider = services.BuildServiceProvider();
var flagService = provider.GetRequiredService<IFeatureFlagService>();
await flagService.CreateFlagAsync(new FeatureFlag
{
Key = "max-retries",
is_enabled = true,
Value = "5"
});
var configService = new ConfigurationService(flagService);
// Act
var maxRetries = await configService.GetMaxRetriesAsync();
// Assert
Assert.Equal(5, maxRetries);
}
[Fact]
public async Task GetMaxRetries_WhenFlagMissing_ReturnsDefaultValue()
{
// Arrange
var services = new ServiceCollection();
services.AddFlaggy(new InMemoryFeatureFlagProvider());
var provider = services.BuildServiceProvider();
var flagService = provider.GetRequiredService<IFeatureFlagService>();
var configService = new ConfigurationService(flagService);
// Act
var maxRetries = await configService.GetMaxRetriesAsync();
// Assert - Should return default value (e.g., 3)
Assert.Equal(3, maxRetries);
}
}
Integration Testing¶
Testing with Real Database Providers¶
public class DatabaseIntegrationTests : IAsyncLifetime
{
private readonly string _connectionString;
private IFeatureFlagService _flagService;
public DatabaseIntegrationTests()
{
// Use test database
_connectionString = "Server=localhost;Database=flaggy_test;User=root;Password=test;";
}
public async Task InitializeAsync()
{
var services = new ServiceCollection();
services.AddFlaggy(options =>
{
options.UseMySQL(
connectionString: _connectionString,
autoMigrate: true
);
});
var provider = services.BuildServiceProvider();
_flagService = provider.GetRequiredService<IFeatureFlagService>();
// Clean database before tests
var allFlags = await _flagService.GetAllFlagsAsync();
foreach (var flag in allFlags)
{
await _flagService.DeleteFlagAsync(flag.Key);
}
}
public async Task DisposeAsync()
{
// Cleanup
var allFlags = await _flagService.GetAllFlagsAsync();
foreach (var flag in allFlags)
{
await _flagService.DeleteFlagAsync(flag.Key);
}
}
[Fact]
public async Task CreateAndRetrieveFlag_WithDatabase_WorksCorrectly()
{
// Arrange
var flag = new FeatureFlag
{
Key = "db-test-flag",
is_enabled = true,
Value = "test-value",
description = "Integration test flag"
};
// Act
await _flagService.CreateFlagAsync(flag);
var retrieved = await _flagService.GetFlagAsync("db-test-flag");
// Assert
Assert.NotNull(retrieved);
Assert.Equal("test-value", retrieved.Value);
Assert.Equal("Integration test flag", retrieved.description);
Assert.NotNull(retrieved.created_at);
Assert.NotNull(retrieved.updated_at);
}
}
Testing Cache Behavior¶
public class CacheBehaviorTests : IAsyncLifetime
{
private IFeatureFlagService _flagService;
private IFeatureFlagProvider _provider;
public async Task InitializeAsync()
{
var services = new ServiceCollection();
_provider = new InMemoryFeatureFlagProvider();
services.AddFlaggy(
provider: _provider,
cachingProvider: CachingProvider.Memory,
cacheExpiration: TimeSpan.FromSeconds(2)
);
var serviceProvider = services.BuildServiceProvider();
_flagService = serviceProvider.GetRequiredService<IFeatureFlagService>();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task GetFlag_AfterUpdate_ReflectsChanges()
{
// Arrange - Create initial flag
await _flagService.CreateFlagAsync(new FeatureFlag
{
Key = "test-flag",
is_enabled = false,
Value = "initial"
});
// Verify initial state
var initial = await _flagService.GetFlagAsync("test-flag");
Assert.False(initial.is_enabled);
Assert.Equal("initial", initial.Value);
// Act - Update flag
await _flagService.UpdateFlagAsync(new FeatureFlag
{
Key = "test-flag",
is_enabled = true,
Value = "updated"
});
// Assert - Should reflect updated state immediately (cache invalidation)
var updated = await _flagService.GetFlagAsync("test-flag");
Assert.True(updated.is_enabled);
Assert.Equal("updated", updated.Value);
}
[Fact]
public async Task GetAllFlags_PopulatesCache()
{
// Arrange
await _flagService.CreateFlagAsync(new FeatureFlag { Key = "flag1", is_enabled = true });
await _flagService.CreateFlagAsync(new FeatureFlag { Key = "flag2", is_enabled = false });
// Act - First call populates cache
var flags1 = await _flagService.GetAllFlagsAsync();
// Second call should use cache (verify by checking it's the same reference or fast)
var sw = Stopwatch.StartNew();
var flags2 = await _flagService.GetAllFlagsAsync();
sw.Stop();
// Assert - Should be very fast (< 5ms) from cache
Assert.True(sw.ElapsedMilliseconds < 5);
Assert.Equal(flags1.Count(), flags2.Count());
}
}
Testing API Endpoints¶
Testing Feature-Flagged Endpoints¶
public class FeatureFlaggedEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public FeatureFlaggedEndpointTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task GetProducts_WhenNewApiEnabled_UsesNewImplementation()
{
// Arrange
var client = _factory
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace flag service with test implementation
services.AddScoped<IFeatureFlagService>(sp =>
{
var testServices = new ServiceCollection();
testServices.AddFlaggy(new InMemoryFeatureFlagProvider());
var provider = testServices.BuildServiceProvider();
var flagService = provider.GetRequiredService<IFeatureFlagService>();
// Enable the flag for this test
flagService.CreateFlagAsync(new FeatureFlag
{
Key = "use-new-product-api",
is_enabled = true
}).Wait();
return flagService;
});
});
})
.CreateClient();
// Act
var response = await client.GetAsync("/api/products");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("new-api", content.ToLower());
}
}
Test Helpers and Utilities¶
Flag Builder for Tests¶
public class FeatureFlagBuilder
{
private string _key = "test-flag";
private bool _isEnabled = false;
private string? _value = null;
private string? _description = null;
public FeatureFlagBuilder WithKey(string key)
{
_key = key;
return this;
}
public FeatureFlagBuilder Enabled()
{
_isEnabled = true;
return this;
}
public FeatureFlagBuilder Disabled()
{
_isEnabled = false;
return this;
}
public FeatureFlagBuilder WithValue(string value)
{
_value = value;
return this;
}
public FeatureFlagBuilder Withdescription(string description)
{
_description = description;
return this;
}
public FeatureFlag Build()
{
return new FeatureFlag
{
Key = _key,
is_enabled = _isEnabled,
Value = _value,
description = _description
};
}
public async Task<FeatureFlag> CreateAsync(IFeatureFlagService service)
{
var flag = Build();
await service.CreateFlagAsync(flag);
return flag;
}
}
// Usage
var flag = await new FeatureFlagBuilder()
.WithKey("premium-feature")
.Enabled()
.WithValue("tier-2")
.Withdescription("Premium tier 2 feature")
.CreateAsync(flagService);
Mock Flag Service¶
public class MockFeatureFlagService : IFeatureFlagService
{
private readonly Dictionary<string, FeatureFlag> _flags = new();
public Task<bool> is_enabledAsync(string key, CancellationToken ct = default)
{
return Task.FromResult(_flags.TryGetValue(key, out var flag) && flag.is_enabled);
}
public Task<string?> GetValueAsync(string key, string? defaultValue = null, CancellationToken ct = default)
{
if (_flags.TryGetValue(key, out var flag) && flag.is_enabled)
return Task.FromResult(flag.Value ?? defaultValue);
return Task.FromResult(defaultValue);
}
public Task<T?> GetValueAsync<T>(string key, T? defaultValue = null, CancellationToken ct = default) where T : struct
{
var value = GetValueAsync(key, null, ct).Result;
if (string.IsNullOrEmpty(value))
return Task.FromResult(defaultValue);
try
{
var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(T));
return Task.FromResult((T?)converter.ConvertFromString(value));
}
catch
{
return Task.FromResult(defaultValue);
}
}
public void SetFlag(string key, bool enabled, string? value = null)
{
_flags[key] = new FeatureFlag { Key = key, is_enabled = enabled, Value = value };
}
// Implement other interface methods as needed...
}
Performance Testing¶
Benchmark Tests¶
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class FeatureFlagPerformanceBenchmarks
{
private IFeatureFlagService _flagService;
[GlobalSetup]
public void Setup()
{
var services = new ServiceCollection();
services.AddFlaggy(new InMemoryFeatureFlagProvider());
var provider = services.BuildServiceProvider();
_flagService = provider.GetRequiredService<IFeatureFlagService>();
// Pre-create flags
_flagService.CreateFlagAsync(new FeatureFlag
{
Key = "benchmark-flag",
is_enabled = true,
Value = "test"
}).Wait();
}
[Benchmark]
public async Task<bool> CheckFlagEnabled()
{
return await _flagService.is_enabledAsync("benchmark-flag");
}
[Benchmark]
public async Task<string?> GetFlagValue()
{
return await _flagService.GetValueAsync("benchmark-flag");
}
[Benchmark]
public async Task<int?> GetFlagValueInt()
{
return await _flagService.GetValueAsync<int>("benchmark-flag", defaultValue: 0);
}
}
Best Practices¶
Always Test Both Flag States¶
// Good - Tests both enabled and disabled states
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Feature_BothStates_WorkCorrectly(bool flagEnabled)
{
await FlagService.UpsertFlagAsync("my-feature", flagEnabled);
var service = new MyService(FlagService);
var result = await service.DoWorkAsync();
if (flagEnabled)
Assert.True(result.UsedNewFeature);
else
Assert.False(result.UsedNewFeature);
}
Test Default Values¶
[Fact]
public async Task GetConfiguration_WhenFlagMissing_UsesDefaultValue()
{
// Don't create the flag - test the default behavior
var timeout = await FlagService.GetValueAsync<int>("request-timeout", defaultValue: 30);
Assert.Equal(30, timeout.Value);
}
Use Descriptive Test Names¶
// Good - Clear what is being tested
[Fact]
public async Task CreateFlag_WhenKeyAlreadyExists_ReturnsFalse()
// Bad - Unclear test purpose
[Fact]
public async Task TestCreateFlag()