Skip to content

Commit 0ad1c94

Browse files
paulirwinclaude
andcommitted
Add filter-resilience test for discovery-phase seeding
Guards that applying an NUnit pre-filter (as an IDE or category/name filter does during discovery) does not change the per-test seed a surviving test receives. NUnitTestFixtureBuilder advances the Randomizer for every method in deterministic order regardless of the filter, so a filtered-out method still consumes its slot and survivors keep their seeds. Covers the "no filters on the tests" requirement raised in the NUnit 4 upgrade review (#1001). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 948b9d3 commit 0ad1c94

1 file changed

Lines changed: 177 additions & 0 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
using Lucene.Net.Attributes;
2+
using Lucene.Net.TestData.Attributes;
3+
using NUnit.Framework;
4+
using NUnit.Framework.Interfaces;
5+
using NUnit.Framework.Internal;
6+
using System;
7+
using System.Collections.Generic;
8+
using System.Linq;
9+
using System.Reflection;
10+
using Assert = Lucene.Net.TestFramework.Assert;
11+
12+
namespace Lucene.Net.Util
13+
{
14+
/*
15+
* Licensed to the Apache Software Foundation (ASF) under one or more
16+
* contributor license agreements. See the NOTICE file distributed with
17+
* this work for additional information regarding copyright ownership.
18+
* The ASF licenses this file to You under the Apache License, Version 2.0
19+
* (the "License"); you may not use this file except in compliance with
20+
* the License. You may obtain a copy of the License at
21+
*
22+
* http://www.apache.org/licenses/LICENSE-2.0
23+
*
24+
* Unless required by applicable law or agreed to in writing, software
25+
* distributed under the License is distributed on an "AS IS" BASIS,
26+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
27+
* See the License for the specific language governing permissions and
28+
* limitations under the License.
29+
*/
30+
31+
/// <summary>
32+
/// Guards the most fragile requirement of the test-discovery seeding "hack": that applying a
33+
/// pre-filter (as an IDE or a category/name filter does during discovery) must NOT change the
34+
/// per-test seed that a surviving test receives.
35+
/// <para/>
36+
/// <see cref="NUnitTestFixtureBuilder"/> achieves this by generating the seed for every method in
37+
/// a deterministic order and advancing the <c>Randomizer</c> for each one, regardless of whether
38+
/// the filter keeps the test. A filtered-out method therefore still consumes its slot in the seed
39+
/// sequence, so the tests that survive the filter keep exactly the seed they would have had if the
40+
/// whole fixture had run. If that ever regresses, a run reproduced from a CI seed string would
41+
/// silently use different per-test seeds whenever the developer narrowed the run to a single test,
42+
/// which is precisely the scenario this guards.
43+
/// </summary>
44+
[TestFixture, LuceneNetSpecific]
45+
public class LuceneTestFixtureFilterSeedingTests
46+
{
47+
// Mirrors NUnitTestFixtureBuilder.TEST_FIXTURE_SEED_OFFSET (private const). The
48+
// FixtureOwnSeedIsDrawnFromTheExpectedOffset test asserts this still matches, so if the
49+
// production offset ever changes this constant is the single place to update.
50+
private const int TEST_FIXTURE_SEED_OFFSET = 3;
51+
52+
/// <summary>
53+
/// Builds <see cref="MultiMethodFixture"/> through the real <c>IFixtureBuilder2</c> path using
54+
/// the supplied filter and returns the resulting <see cref="TestFixture"/> (unwrapped from the
55+
/// custom SetUpFixture wrapper).
56+
/// </summary>
57+
private static TestFixture BuildFixture(IPreFilter filter)
58+
{
59+
var attribute = new LuceneTestCase.TestFixtureAttribute();
60+
ITypeInfo typeInfo = new TypeWrapper(typeof(MultiMethodFixture));
61+
62+
// BuildFrom returns the SetUpFixture wrapper; the actual TestFixture is its single child.
63+
TestSuite setUpFixture = attribute.BuildFrom(typeInfo, filter).Single();
64+
return (TestFixture)setUpFixture.Tests.Single();
65+
}
66+
67+
private static IReadOnlyDictionary<string, long> SeedsByTestName(TestFixture fixture)
68+
{
69+
return fixture.Tests
70+
.Cast<Test>()
71+
.ToDictionary(t => t.Name, t => t.GetRandomizedContext().TestSeed);
72+
}
73+
74+
[Test]
75+
public void FixtureOwnSeedIsDrawnFromTheExpectedOffset()
76+
{
77+
// This both documents and verifies the draw model the other tests rely on: the fixture's
78+
// own per-test seed is the first draw from new Randomizer(RandomSeed + offset). If the
79+
// production offset changes, this fails clearly and TEST_FIXTURE_SEED_OFFSET must be updated.
80+
TestFixture fixture = BuildFixture(AlwaysMatchPreFilter.Instance);
81+
RandomizedContext fixtureContext = fixture.GetRandomizedContext();
82+
83+
long expectedFixtureSeed = new J2N.Randomizer(fixtureContext.RandomSeed + TEST_FIXTURE_SEED_OFFSET).NextInt64();
84+
Assert.AreEqual(expectedFixtureSeed, fixtureContext.TestSeed,
85+
"The fixture's own per-test seed should be the first draw from the offset-adjusted initial seed. " +
86+
"If this fails, NUnitTestFixtureBuilder.TEST_FIXTURE_SEED_OFFSET changed; update the test constant.");
87+
}
88+
89+
[Test]
90+
public void FilteringOutMethodsDoesNotChangeSurvivingTestSeeds()
91+
{
92+
// Unfiltered: all three methods kept.
93+
TestFixture unfiltered = BuildFixture(AlwaysMatchPreFilter.Instance);
94+
IReadOnlyDictionary<string, long> unfilteredSeeds = SeedsByTestName(unfiltered);
95+
96+
Assert.AreEqual(3, unfilteredSeeds.Count, "Expected all three methods when unfiltered.");
97+
98+
// Filtered: drop the FIRST method in sorted draw order (TestOne). This is the case that
99+
// actually exercises the invariant: the survivors (TestThree, TestTwo) are drawn AFTER the
100+
// dropped method, so if its draw were skipped they would shift down the Randomizer sequence
101+
// and receive different seeds. They must not.
102+
var filter = new MethodNameExclusionPreFilter(nameof(MultiMethodFixture.TestOne));
103+
TestFixture filtered = BuildFixture(filter);
104+
IReadOnlyDictionary<string, long> filteredSeeds = SeedsByTestName(filtered);
105+
106+
Assert.AreEqual(2, filteredSeeds.Count, "Expected the filter to drop exactly one method.");
107+
Assert.IsFalse(filteredSeeds.ContainsKey(nameof(MultiMethodFixture.TestOne)),
108+
"The excluded method should not be present in the filtered fixture.");
109+
110+
// The surviving tests must carry the exact same per-test seed they had unfiltered. The two
111+
// builds use different auto-generated assembly seeds, so we compare each surviving test's
112+
// seed against its own build re-derived from that build's initial RandomSeed, proving the
113+
// filtered method still consumed its draw and did not shift the survivors.
114+
string[] survivors =
115+
{
116+
nameof(MultiMethodFixture.TestTwo),
117+
nameof(MultiMethodFixture.TestThree),
118+
};
119+
AssertSeedsMatchDrawSequence(unfiltered, survivors);
120+
AssertSeedsMatchDrawSequence(filtered, survivors);
121+
}
122+
123+
/// <summary>
124+
/// Re-derives the expected per-test seeds for <see cref="MultiMethodFixture"/> from the given
125+
/// fixture's own initial <c>RandomSeed</c> and asserts the kept tests match. The methods are
126+
/// drawn in <see cref="MethodInfoComparer"/> order (ordinal by name): TestOne, TestThree, TestTwo.
127+
/// The fixture's own seed is drawn first, then one draw per method in that order.
128+
/// </summary>
129+
private static void AssertSeedsMatchDrawSequence(TestFixture fixture, string[] expectedKeptMethods)
130+
{
131+
RandomizedContext context = fixture.GetRandomizedContext();
132+
var rng = new J2N.Randomizer(context.RandomSeed + TEST_FIXTURE_SEED_OFFSET);
133+
134+
_ = rng.NextInt64(); // draw #1: the fixture's own seed
135+
136+
// Draw order matches the sorted method order. We map each method name to the seed it would
137+
// receive in an unfiltered draw, then check only the survivors against the actual fixture.
138+
string[] sortedMethodNames =
139+
{
140+
nameof(MultiMethodFixture.TestOne),
141+
nameof(MultiMethodFixture.TestThree),
142+
nameof(MultiMethodFixture.TestTwo),
143+
};
144+
145+
var expectedByName = new Dictionary<string, long>();
146+
foreach (string name in sortedMethodNames)
147+
expectedByName[name] = rng.NextInt64();
148+
149+
IReadOnlyDictionary<string, long> actual = SeedsByTestName(fixture);
150+
foreach (string keptMethod in expectedKeptMethods)
151+
{
152+
Assert.IsTrue(actual.ContainsKey(keptMethod), $"Expected surviving method '{keptMethod}' to be present.");
153+
Assert.AreEqual(expectedByName[keptMethod], actual[keptMethod],
154+
$"Surviving test '{keptMethod}' must keep the seed it would have had in an unfiltered draw, " +
155+
"proving the filtered-out method still consumed its slot in the Randomizer sequence.");
156+
}
157+
}
158+
159+
/// <summary>
160+
/// An <see cref="IPreFilter"/> that matches everything except the named methods, used to
161+
/// simulate an IDE or category/name filter narrowing a run during discovery.
162+
/// </summary>
163+
private sealed class MethodNameExclusionPreFilter : IPreFilter
164+
{
165+
private readonly HashSet<string> excludedMethodNames;
166+
167+
public MethodNameExclusionPreFilter(params string[] excludedMethodNames)
168+
{
169+
this.excludedMethodNames = new HashSet<string>(excludedMethodNames, StringComparer.Ordinal);
170+
}
171+
172+
public bool IsMatch(Type type) => true;
173+
174+
public bool IsMatch(Type type, MethodInfo method) => !excludedMethodNames.Contains(method.Name);
175+
}
176+
}
177+
}

0 commit comments

Comments
 (0)