diff --git a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs
index a4591d65b4..f904d2200d 100644
--- a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs
+++ b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs
@@ -442,6 +442,17 @@ private static void TraverseInitializerProperties(
foreach (var property in properties)
{
cancellationToken.ThrowIfCancellationRequested();
+
+ // Only access properties whose declared type could be IAsyncInitializer.
+ // This prevents triggering side effects from unrelated property getters
+ // (e.g., WebApplicationFactory.Server which starts the test host when accessed).
+ // Properties typed as object/interfaces not extending IAsyncInitializer won't be
+ // checked - users should properly type their properties or use data source attributes.
+ if (!typeof(IAsyncInitializer).IsAssignableFrom(property.PropertyType))
+ {
+ continue;
+ }
+
try
{
var value = property.GetValue(obj);
diff --git a/TUnit.TestProject/Bugs/4049/PropertyGetterSideEffectTests.cs b/TUnit.TestProject/Bugs/4049/PropertyGetterSideEffectTests.cs
new file mode 100644
index 0000000000..35c206fee4
--- /dev/null
+++ b/TUnit.TestProject/Bugs/4049/PropertyGetterSideEffectTests.cs
@@ -0,0 +1,132 @@
+using TUnit.Core.Interfaces;
+using TUnit.TestProject.Attributes;
+
+namespace TUnit.TestProject.Bugs._4049;
+
+///
+/// Regression test for issue #4049: Property getters should not be called during discovery
+/// for properties that don't return IAsyncInitializer.
+///
+/// This simulates the WebApplicationFactory.Server scenario where accessing a property
+/// causes side effects (like starting a test server) before the object is configured.
+///
+[EngineTest(ExpectedResult.Pass)]
+public class PropertyGetterSideEffectTests
+{
+ private static int _sideEffectCount = 0;
+
+ ///
+ /// Simulates a class like WebApplicationFactory where accessing certain properties
+ /// triggers initialization (like building the test host).
+ ///
+ public class FixtureWithSideEffects : IAsyncInitializer
+ {
+ private object? _server;
+
+ ///
+ /// Simulates WebApplicationFactory.Server - accessing this property has side effects.
+ /// This property type is 'object', NOT IAsyncInitializer, so TUnit should NOT access it.
+ ///
+ public object Server
+ {
+ get
+ {
+ Interlocked.Increment(ref _sideEffectCount);
+ Console.WriteLine($"Server getter called! Side effect count: {_sideEffectCount}");
+ _server ??= new object(); // Side effect: lazy initialization
+ return _server;
+ }
+ }
+
+ public bool IsInitialized { get; private set; }
+
+ public Task InitializeAsync()
+ {
+ IsInitialized = true;
+ Console.WriteLine("FixtureWithSideEffects.InitializeAsync called");
+ return Task.CompletedTask;
+ }
+ }
+
+ [Test]
+ [ClassDataSource]
+ public async Task PropertyGetters_WithSideEffects_ShouldNotBeCalledDuringDiscovery(FixtureWithSideEffects fixture)
+ {
+ // Verify the fixture was properly initialized
+ await Assert.That(fixture.IsInitialized).IsTrue();
+
+ // The Server property getter should NOT have been called during discovery.
+ // It should only be called if the test explicitly accesses it.
+ // At this point, we haven't accessed Server, so count should be 0.
+ await Assert.That(_sideEffectCount).IsEqualTo(0)
+ .Because("TUnit should not access non-IAsyncInitializer property getters during discovery");
+
+ // Now let's explicitly access the Server to verify it works
+ var server = fixture.Server;
+ await Assert.That(server).IsNotNull();
+ await Assert.That(_sideEffectCount).IsEqualTo(1);
+
+ Console.WriteLine($"Test passed: Server getter was only called when explicitly accessed (count: {_sideEffectCount})");
+ }
+}
+
+///
+/// Test to verify that nested IAsyncInitializer properties ARE still discovered
+/// when they are properly typed.
+///
+[EngineTest(ExpectedResult.Pass)]
+public class NestedAsyncInitializerDiscoveryTests
+{
+ private static int _parentInitCount = 0;
+ private static int _childInitCount = 0;
+
+ public class ChildInitializer : IAsyncInitializer
+ {
+ public bool IsInitialized { get; private set; }
+
+ public Task InitializeAsync()
+ {
+ Interlocked.Increment(ref _childInitCount);
+ IsInitialized = true;
+ Console.WriteLine($"ChildInitializer.InitializeAsync called (count: {_childInitCount})");
+ return Task.CompletedTask;
+ }
+ }
+
+ public class ParentWithNestedInitializer : IAsyncInitializer
+ {
+ ///
+ /// This property IS typed as IAsyncInitializer, so TUnit SHOULD access it
+ /// and discover the nested initializer for proper lifecycle management.
+ ///
+ public IAsyncInitializer NestedInitializer { get; } = new ChildInitializer();
+
+ public bool IsInitialized { get; private set; }
+
+ public Task InitializeAsync()
+ {
+ Interlocked.Increment(ref _parentInitCount);
+ IsInitialized = true;
+ Console.WriteLine($"ParentWithNestedInitializer.InitializeAsync called (count: {_parentInitCount})");
+ return Task.CompletedTask;
+ }
+ }
+
+ [Test]
+ [ClassDataSource]
+ public async Task NestedInitializers_WhenProperlyTyped_ShouldBeDiscoveredAndInitialized(ParentWithNestedInitializer parent)
+ {
+ // Verify parent was initialized
+ await Assert.That(parent.IsInitialized).IsTrue();
+ await Assert.That(_parentInitCount).IsGreaterThan(0);
+
+ // Verify child was discovered and initialized
+ var child = parent.NestedInitializer as ChildInitializer;
+ await Assert.That(child).IsNotNull();
+ await Assert.That(child!.IsInitialized).IsTrue()
+ .Because("TUnit should discover and initialize properly-typed IAsyncInitializer properties");
+ await Assert.That(_childInitCount).IsGreaterThan(0);
+
+ Console.WriteLine($"Test passed: Both parent ({_parentInitCount}) and child ({_childInitCount}) were initialized");
+ }
+}