diff --git a/src/LinqTests/Acceptance/child_collection_queries.cs b/src/LinqTests/Acceptance/child_collection_queries.cs index b934c3a285..544b4d791f 100644 --- a/src/LinqTests/Acceptance/child_collection_queries.cs +++ b/src/LinqTests/Acceptance/child_collection_queries.cs @@ -64,7 +64,14 @@ static child_collection_queries() @where(x => x.StringArray != null && x.String.Equals("Orange") && x.StringArray.Contains("Red") && x.AnotherString.Equals("one")); // GH-2975 - @where(x => x.Children.Any(c => c.NullableDateOffset <= DateTimeOffset.UtcNow)); + // Capture a fixed timestamp far enough in the future to dominate the + // ±60-second jitter in Target.Random's NullableDateOffset values. Using + // DateTimeOffset.UtcNow inline here would be evaluated independently by + // the LINQ-to-objects "expected" path and by the LINQ-to-SQL "actual" + // path; values close to "now" could land on opposite sides of <= and + // disagree, producing a flaky test on slow CI runners. + var asOf = DateTimeOffset.UtcNow.AddDays(1); + @where(x => x.Children.Any(c => c.NullableDateOffset <= asOf)); } [Theory] diff --git a/src/LinqTests/Bugs/Bug_3337_select_page.cs b/src/LinqTests/Bugs/Bug_3337_select_page.cs index b712a26ae6..15cf36bef9 100644 --- a/src/LinqTests/Bugs/Bug_3337_select_page.cs +++ b/src/LinqTests/Bugs/Bug_3337_select_page.cs @@ -13,6 +13,10 @@ public class Bug_3337_select_page : BugIntegrationContext [Fact] public async Task try_it_out() { + // Reset the shared Target random sequence so the data we generate is + // deterministic regardless of sibling-test order. Without this, a + // surprise zero in `Number` or null in `String` would silently flake. + Target.ResetRandomSeed(); await theStore.BulkInsertAsync(Target.GenerateRandomData(1000).ToArray()); var results = await theSession.Query().Where(x => x.Inner != null) diff --git a/src/LinqTests/Bugs/Bug_4282_is_one_of_against_string_list.cs b/src/LinqTests/Bugs/Bug_4282_is_one_of_against_string_list.cs index 82309eb9f8..9095bac0c3 100644 --- a/src/LinqTests/Bugs/Bug_4282_is_one_of_against_string_list.cs +++ b/src/LinqTests/Bugs/Bug_4282_is_one_of_against_string_list.cs @@ -25,18 +25,24 @@ public async Task can_query_string_list_with_is_one_of_against_runtime_list() var ids = await session.Query() .Where(x => x.RelatedIds.IsOneOf(relatedIds)) - .OrderBy(x => x.Id) .Select(x => x.Id) .ToListAsync(); - ids.ShouldHaveTheSameElementsAs(doc1.Id, doc3.Id); + // doc1 (matches "related-2") and doc3 (matches "related-4") are + // expected; doc2 should not match. Use set-membership rather than a + // sequential ShouldHaveTheSameElementsAs because the Ids are + // server-generated Guids and don't sort in declaration order. + ids.Count.ShouldBe(2); + ids.ShouldContain(doc1.Id); + ids.ShouldContain(doc3.Id); var notIds = await session.Query() .Where(x => !x.RelatedIds.IsOneOf(relatedIds)) .Select(x => x.Id) .ToListAsync(); - notIds.ShouldHaveTheSameElementsAs(doc2.Id); + notIds.Count.ShouldBe(1); + notIds.ShouldContain(doc2.Id); } public class Issue4282Target diff --git a/src/LinqTests/Bugs/Bug_605_unary_expressions_in_where_clause_of_compiled_query.cs b/src/LinqTests/Bugs/Bug_605_unary_expressions_in_where_clause_of_compiled_query.cs index 1509ecf099..1f3c017ae2 100644 --- a/src/LinqTests/Bugs/Bug_605_unary_expressions_in_where_clause_of_compiled_query.cs +++ b/src/LinqTests/Bugs/Bug_605_unary_expressions_in_where_clause_of_compiled_query.cs @@ -16,11 +16,15 @@ public class Bug_605_unary_expressions_in_where_clause_of_compiled_query: BugInt [Fact] public async Task with_flag_as_true() { + // Test asserts the compiled-query result equals the equivalent inline LINQ + // result. Reset the shared Target random sequence so the test data we + // operate on is deterministic regardless of sibling-test order on CI. + Target.ResetRandomSeed(); var targets = Target.GenerateRandomData(1000).ToArray(); await theStore.BulkInsertAsync(targets); await using var query = theStore.QuerySession(); - var results = await query.QueryAsync(new FlaggedTrueTargets()); + var results = (await query.QueryAsync(new FlaggedTrueTargets())).ToList(); var expected = query.Query() .SelectMany(x => x.Children) @@ -31,7 +35,11 @@ public async Task with_flag_as_true() .Take(15) .ToList(); - results.Count().ShouldBe(15); + // Compare against the inline LINQ query rather than a hardcoded count. + // The point of the test is "compiled query == inline LINQ for the same + // expression"; the page size of 15 is incidental and was a fragile + // assertion against shared-random test-data variance. + results.Count.ShouldBe(expected.Count); results.Select(x => x.Id) .ShouldHaveTheSameElementsAs(expected.Select(x => x.Id)); @@ -42,11 +50,12 @@ public async Task with_flag_as_true_with_enum_as_string() { StoreOptions(_ => _.UseSystemTextJsonForSerialization(EnumStorage.AsString)); + Target.ResetRandomSeed(); var targets = Target.GenerateRandomData(1000).ToArray(); await theStore.BulkInsertAsync(targets); await using var query = theStore.QuerySession(); - var results = await query.QueryAsync(new FlaggedTrueTargets()); + var results = (await query.QueryAsync(new FlaggedTrueTargets())).ToList(); var expected = query.Query() .SelectMany(x => x.Children) @@ -57,7 +66,7 @@ public async Task with_flag_as_true_with_enum_as_string() .Take(15) .ToList(); - results.Count().ShouldBe(15); + results.Count.ShouldBe(expected.Count); results.Select(x => x.Id) .ShouldHaveTheSameElementsAs(expected.Select(x => x.Id)); @@ -66,11 +75,12 @@ public async Task with_flag_as_true_with_enum_as_string() [Fact] public async Task with_flag_as_false() { + Target.ResetRandomSeed(); var targets = Target.GenerateRandomData(1000).ToArray(); await theStore.BulkInsertAsync(targets); await using var query = theStore.QuerySession(); - var results = await query.QueryAsync(new FlaggedFalseTargets()); + var results = (await query.QueryAsync(new FlaggedFalseTargets())).ToList(); var expected = query.Query() .SelectMany(x => x.Children) @@ -81,7 +91,7 @@ public async Task with_flag_as_false() .Take(15) .ToList(); - results.Count().ShouldBe(15); + results.Count.ShouldBe(expected.Count); results.Select(x => x.Id) .ShouldHaveTheSameElementsAs(expected.Select(x => x.Id)); diff --git a/src/LinqTests/ChildCollections/query_against_child_collections.cs b/src/LinqTests/ChildCollections/query_against_child_collections.cs index edf3af5aa4..4393d4ff43 100644 --- a/src/LinqTests/ChildCollections/query_against_child_collections.cs +++ b/src/LinqTests/ChildCollections/query_against_child_collections.cs @@ -24,6 +24,12 @@ public query_against_child_collections() private async Task buildUpTargetData() { + // Pin the shared random sequence so the per-test data is deterministic + // regardless of which sibling tests consumed Target's static random + // before us. Without this, tests that assert on the random distribution + // (e.g. "at least one Green child") could occasionally see a slice of + // the sequence that doesn't produce a match. + Target.ResetRandomSeed(); targets = Target.GenerateRandomData(20).ToArray(); targets.SelectMany(x => x.Children).Each(x => x.Number = 5); diff --git a/src/Marten.Testing/Documents/Target.cs b/src/Marten.Testing/Documents/Target.cs index ef7afcd282..d82b3d2a21 100644 --- a/src/Marten.Testing/Documents/Target.cs +++ b/src/Marten.Testing/Documents/Target.cs @@ -22,7 +22,33 @@ public class Target public record Nested(Target[] Targets); public Nested NestedObject { get; set; } - private static readonly Random _random = new Random(67); + + // Seeded random shared across all tests in the assembly. The seed is fixed + // (67), but consumption is shared: every Target.Random() call advances the + // sequence, so a test's effective random data depends on which other tests + // (and how many Random() calls each made) ran before it. xUnit test + // discovery / ordering is mostly stable but not perfectly so — flakes that + // surface only on CI typically come from a small order shift consuming a + // different slice of the sequence and producing different test data. + // + // ResetRandomSeed below lets a test that genuinely depends on specific + // random data pin the sequence at the start. Keep the static so unaffected + // tests are unchanged. + private static Random _random = new Random(67); + + /// + /// Reset the shared random sequence to a known seed. Call from a test that + /// needs deterministic test data (assertions on exact counts, IDs of the + /// first N records, etc.) to remove its dependency on whatever sibling + /// tests happened to consume from the static random before it. + /// + /// Seed for the new random sequence. Defaults to 67 to + /// match the historical default; pass a different value if a test wants its + /// own deterministic-but-distinct stream. + public static void ResetRandomSeed(int seed = 67) + { + _random = new Random(seed); + } private static readonly string[] _strings = { diff --git a/src/Marten.Testing/Harness/SpecificationExtensions.cs b/src/Marten.Testing/Harness/SpecificationExtensions.cs index e851dcc444..bdda92e008 100644 --- a/src/Marten.Testing/Harness/SpecificationExtensions.cs +++ b/src/Marten.Testing/Harness/SpecificationExtensions.cs @@ -67,9 +67,18 @@ public static void ShouldHaveTheSameElementsAs(this IList actual, IList expected public static void ShouldBeEqualWithDbPrecision(this DateTimeOffset actual, DateTimeOffset expected) { - static DateTimeOffset toDbPrecision(DateTimeOffset date) => new DateTimeOffset(date.Ticks / 1000 * 1000, new TimeSpan(date.Offset.Ticks / 1000 * 1000)); - - toDbPrecision(actual).ShouldBe(toDbPrecision(expected)); + // PostgreSQL `timestamptz` is microsecond precision; .NET DateTimeOffset + // is 100ns precision. A round-trip through Postgres truncates up to 9 + // ticks. Compare with a millisecond tolerance — easily wider than the + // truncation but tight enough to catch real semantic differences. This + // is preferred over the older "round both sides to 100µs and compare" + // approach because it's more robust to clock-comparison edge cases on + // slow / loaded CI runners (see #4310). + var difference = (actual - expected).Duration(); + difference.ShouldBeLessThanOrEqualTo(TimeSpan.FromMilliseconds(1), + $"DateTimeOffset values differ by {difference.TotalMicroseconds:F1}µs; expected within 1ms.\n" + + $" expected: {expected:O}\n" + + $" actual: {actual:O}"); } public static void ShouldContain(this DbObjectName[] names, string qualifiedName)