@@ -16,7 +16,8 @@ namespace EssentialCSharp.Web.Tests;
1616public sealed class WebApplicationFactory : TestWebApplicationFactory < Program >
1717{
1818 private static readonly SemaphoreSlim SchemaInitializationGate = new ( 1 , 1 ) ;
19- // One GUID per factory instance → each factory gets its own isolated in-memory database.
19+ // One GUID per factory instance (field, not computed property) ensures each factory
20+ // gets its own isolated in-memory database and keeps a stable connection string.
2021 private readonly string _sqlConnectionString =
2122 $ "DataSource=file:{ Guid . NewGuid ( ) : N} ?mode=memory&cache=shared";
2223
@@ -28,34 +29,24 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
2829 {
2930 builder . ConfigureServices ( services =>
3031 {
31- for ( int i = services . Count - 1 ; i >= 0 ; i -- )
32- {
33- if ( services [ i ] . ServiceType ==
34- typeof ( IDbContextOptionsConfiguration < EssentialCSharpWebContext > ) )
35- {
36- services . RemoveAt ( i ) ;
37- }
38- }
32+ RemoveSingleOrNone (
33+ services ,
34+ descriptor => descriptor . ServiceType ==
35+ typeof ( IDbContextOptionsConfiguration < EssentialCSharpWebContext > ) ,
36+ "IDbContextOptionsConfiguration<EssentialCSharpWebContext>" ) ;
3937
40- for ( int i = services . Count - 1 ; i >= 0 ; i -- )
41- {
42- if ( services [ i ] . ServiceType == typeof ( DbConnection ) )
43- {
44- services . RemoveAt ( i ) ;
45- }
46- }
38+ RemoveSingleOrNone (
39+ services ,
40+ descriptor => descriptor . ServiceType == typeof ( DbConnection ) ,
41+ nameof ( DbConnection ) ) ;
4742
4843 // Remove DatabaseMigrationService: it calls MigrateAsync which conflicts
4944 // with EnsureCreated() used below for the in-memory SQLite test database.
50- for ( int i = services . Count - 1 ; i >= 0 ; i -- )
51- {
52- ServiceDescriptor descriptor = services [ i ] ;
53- if ( descriptor . ServiceType == typeof ( IHostedService ) &&
54- descriptor . ImplementationType == typeof ( DatabaseMigrationService ) )
55- {
56- services . RemoveAt ( i ) ;
57- }
58- }
45+ RemoveSingleOrNone (
46+ services ,
47+ descriptor => descriptor . ServiceType == typeof ( IHostedService ) &&
48+ descriptor . ImplementationType == typeof ( DatabaseMigrationService ) ,
49+ nameof ( DatabaseMigrationService ) ) ;
5950
6051 // Open a keep-alive connection to prevent the shared-cache in-memory database from
6152 // being dropped when per-scope connections are disposed between requests.
@@ -81,15 +72,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
8172 } ) ;
8273
8374 // Ensure schema exists before any hosted service that reads from the database.
84- for ( int i = services . Count - 1 ; i >= 0 ; i -- )
85- {
86- ServiceDescriptor descriptor = services [ i ] ;
87- if ( descriptor . ServiceType == typeof ( IHostedService ) &&
88- descriptor . ImplementationType == typeof ( EnsureCreatedHostedService ) )
89- {
90- services . RemoveAt ( i ) ;
91- }
92- }
75+ RemoveSingleOrNone (
76+ services ,
77+ descriptor => descriptor . ServiceType == typeof ( IHostedService ) &&
78+ descriptor . ImplementationType == typeof ( EnsureCreatedHostedService ) ,
79+ nameof ( EnsureCreatedHostedService ) ) ;
9380
9481 ServiceDescriptor ensureCreatedDescriptor =
9582 ServiceDescriptor . Singleton < IHostedService , EnsureCreatedHostedService > ( ) ;
@@ -127,6 +114,32 @@ protected override void Dispose(bool disposing)
127114 base . Dispose ( disposing ) ;
128115 }
129116
117+ private static void RemoveSingleOrNone (
118+ IServiceCollection services ,
119+ Func < ServiceDescriptor , bool > predicate ,
120+ string descriptorName )
121+ {
122+ List < int > matchIndexes = [ ] ;
123+ for ( int i = 0 ; i < services . Count ; i ++ )
124+ {
125+ if ( predicate ( services [ i ] ) )
126+ {
127+ matchIndexes . Add ( i ) ;
128+ }
129+ }
130+
131+ if ( matchIndexes . Count > 1 )
132+ {
133+ throw new InvalidOperationException (
134+ $ "Expected at most one '{ descriptorName } ' registration but found { matchIndexes . Count } .") ;
135+ }
136+
137+ if ( matchIndexes . Count == 1 )
138+ {
139+ services . RemoveAt ( matchIndexes [ 0 ] ) ;
140+ }
141+ }
142+
130143 private sealed class EnsureCreatedHostedService ( IServiceProvider serviceProvider ) : IHostedService
131144 {
132145 public async Task StartAsync ( CancellationToken cancellationToken )
0 commit comments