Skip to content

Commit 86e79b4

Browse files
authored
fix testng class fixtures for factory-created instances (fixes #896, via #1306)
1 parent cc94c17 commit 86e79b4

4 files changed

Lines changed: 210 additions & 69 deletions

File tree

allure-testng/src/main/java/io/qameta/allure/testng/AllureTestNg.java

Lines changed: 76 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,6 @@
7676
import java.util.UUID;
7777
import java.util.concurrent.ConcurrentHashMap;
7878
import java.util.concurrent.CopyOnWriteArrayList;
79-
import java.util.concurrent.locks.ReadWriteLock;
80-
import java.util.concurrent.locks.ReentrantReadWriteLock;
8179
import java.util.function.Consumer;
8280
import java.util.stream.Collectors;
8381
import java.util.stream.Stream;
@@ -164,9 +162,10 @@ public class AllureTestNg
164162
.withInitial(() -> UUID.randomUUID().toString());
165163

166164
/**
167-
* Store uuid for per-class scopes.
165+
* Store uuid for per-class scopes: one scope per test class instance. Class fixtures run once per instance,
166+
* so factory-created instances of the same class each get their own scope holding only that instance's tests.
168167
*/
169-
private final Map<ITestClass, String> classScopeUuidStorage = new ConcurrentHashMap<>();
168+
private final Map<ClassInstanceKey, String> classScopeUuidStorage = new ConcurrentHashMap<>();
170169

171170
/**
172171
* Store uuid for data provider scopes.
@@ -186,7 +185,6 @@ public class AllureTestNg
186185
* so they can no longer be reached through storage and are linked by these recorded uuids instead.
187186
*/
188187
private final Map<GroupKey, List<String>> groupTestUuidStorage = new ConcurrentHashMap<>();
189-
private final ReadWriteLock lock = new ReentrantReadWriteLock();
190188
private final AllureLifecycle lifecycle;
191189
private final AllureTestNgTestFilter testFilter;
192190

@@ -264,11 +262,6 @@ public void onStart(final ITestContext context) {
264262
final String uuid = getUniqueUuid(context);
265263
getLifecycle().registerScope(scopeKey(uuid));
266264

267-
Stream.of(context.getAllTestMethods())
268-
.map(ITestNGMethod::getTestClass)
269-
.distinct()
270-
.forEach(this::onBeforeClass);
271-
272265
if (!config.isHideDisabledTests()) {
273266
context.getExcludedMethods().stream()
274267
.filter(ITestNGMethod::isTest)
@@ -297,38 +290,34 @@ public void onFinish(final ITestContext context) {
297290
final String uuid = getUniqueUuid(context);
298291
getLifecycle().writeScope(scopeKey(uuid));
299292

300-
Stream.of(context.getAllTestMethods())
301-
.map(ITestNGMethod::getTestClass)
302-
.distinct()
303-
.forEach(this::onAfterClass);
304-
305-
groupScopeUuidStorage.entrySet().removeIf(entry -> {
293+
classScopeUuidStorage.entrySet().removeIf(entry -> {
306294
if (entry.getKey().context().equals(context)) {
307295
getLifecycle().writeScope(scopeKey(entry.getValue()));
308296
return true;
309297
}
310298
return false;
311299
});
312-
groupTestUuidStorage.keySet().removeIf(key -> key.context().equals(context));
313-
}
314300

315-
public void onBeforeClass(final ITestClass testClass) {
316-
final String uuid = UUID.randomUUID().toString();
317-
getLifecycle().registerScope(scopeKey(uuid));
318-
setClassScope(testClass, uuid);
319-
}
320-
321-
public void onAfterClass(final ITestClass testClass) {
322-
getClassScope(testClass).ifPresent(uuid -> {
323-
getLifecycle().writeScope(scopeKey(uuid));
324-
});
301+
final List<ITestClass> contextClasses = Stream.of(context.getAllTestMethods())
302+
.map(ITestNGMethod::getTestClass)
303+
.distinct()
304+
.collect(Collectors.toList());
325305
dataProviderScopeUuidStorage.entrySet().removeIf(entry -> {
326-
if (entry.getKey().getTestClass().equals(testClass)) {
306+
if (contextClasses.stream().anyMatch(clazz -> entry.getKey().getTestClass().equals(clazz))) {
327307
getLifecycle().writeScope(scopeKey(entry.getValue()));
328308
return true;
329309
}
330310
return false;
331311
});
312+
313+
groupScopeUuidStorage.entrySet().removeIf(entry -> {
314+
if (entry.getKey().context().equals(context)) {
315+
getLifecycle().writeScope(scopeKey(entry.getValue()));
316+
return true;
317+
}
318+
return false;
319+
});
320+
groupTestUuidStorage.keySet().removeIf(key -> key.context().equals(context));
332321
}
333322

334323
@Override
@@ -345,10 +334,7 @@ public void onTestStart(final ITestResult testResult) {
345334

346335
linkPendingBeforeMethodScopes(testKey(uuid));
347336

348-
Optional.of(testResult)
349-
.map(ITestResult::getMethod)
350-
.map(ITestNGMethod::getTestClass)
351-
.ifPresent(clazz -> addTestToClassScope(clazz, uuid));
337+
addTestToClassScope(testResult, uuid);
352338

353339
Optional.of(testResult)
354340
.map(ITestResult::getMethod)
@@ -357,6 +343,14 @@ public void onTestStart(final ITestResult testResult) {
357343
addTestToGroupScopes(testResult, uuid);
358344
}
359345

346+
private void addTestToClassScope(final ITestResult testResult, final String uuid) {
347+
final ITestClass testClass = testResult.getMethod().getTestClass();
348+
final String scopeUuid = getOrCreateClassScope(
349+
testResult.getTestContext(), testClass, testResult.getInstance()
350+
);
351+
getLifecycle().addTestToScope(scopeKey(scopeUuid), testKey(uuid));
352+
}
353+
360354
private void addTestToGroupScopes(final ITestResult testResult, final String uuid) {
361355
final String[] groups = testResult.getMethod().getGroups();
362356
if (groups.length == 0) {
@@ -555,7 +549,7 @@ public void beforeInvocation(final IInvokedMethod method, final ITestResult test
555549
ifSuiteFixtureStarted(context.getSuite(), testMethod);
556550
ifTestFixtureStarted(context, testMethod);
557551
ifGroupsFixtureStarted(context, testMethod);
558-
ifClassFixtureStarted(testMethod);
552+
ifClassFixtureStarted(context, testMethod, testResult.getInstance());
559553
ifMethodFixtureStarted(testMethod);
560554
}
561555
}
@@ -569,17 +563,34 @@ private void ifSuiteFixtureStarted(final ISuite suite, final ITestNGMethod testM
569563
}
570564
}
571565

572-
private void ifClassFixtureStarted(final ITestNGMethod testMethod) {
566+
private void ifClassFixtureStarted(final ITestContext context,
567+
final ITestNGMethod testMethod,
568+
final Object instance) {
573569
if (testMethod.isBeforeClassConfiguration()) {
574-
getClassScope(testMethod.getTestClass())
575-
.ifPresent(parentUuid -> startBefore(parentUuid, testMethod));
570+
startBefore(getOrCreateClassScope(context, testMethod.getTestClass(), instance), testMethod);
576571
}
577572
if (testMethod.isAfterClassConfiguration()) {
578-
getClassScope(testMethod.getTestClass())
579-
.ifPresent(parentUuid -> startAfter(parentUuid, testMethod));
573+
startAfter(getOrCreateClassScope(context, testMethod.getTestClass(), instance), testMethod);
580574
}
581575
}
582576

577+
/**
578+
* Returns the uuid of the scope shared by the class fixtures and tests running on the given test class
579+
* instance, registering it on first use. The scope is keyed by the real class rather than {@link ITestClass}:
580+
* TestNG hands out different {@link ITestClass} references for invocation-count and data-provider clones of
581+
* the same class, while the real class and the instance stay stable.
582+
*/
583+
private String getOrCreateClassScope(final ITestContext context,
584+
final ITestClass testClass,
585+
final Object instance) {
586+
final ClassInstanceKey key = new ClassInstanceKey(context, testClass.getRealClass(), instance);
587+
return classScopeUuidStorage.computeIfAbsent(key, k -> {
588+
final String uuid = UUID.randomUUID().toString();
589+
getLifecycle().registerScope(scopeKey(uuid));
590+
return uuid;
591+
});
592+
}
593+
583594
private void ifTestFixtureStarted(final ITestContext context, final ITestNGMethod testMethod) {
584595
if (testMethod.isBeforeTestConfiguration()) {
585596
startBefore(getUniqueUuid(context), testMethod);
@@ -720,7 +731,10 @@ public void onConfigurationFailure(final ITestResult itr) {
720731

721732
linkTestToScope(getUniqueUuid(itr.getTestContext()), uuid);
722733
linkTestToScope(getUniqueUuid(itr.getTestContext().getSuite()), uuid);
723-
addTestToClassScope(itr.getMethod().getTestClass(), uuid);
734+
linkTestToScope(
735+
getOrCreateClassScope(itr.getTestContext(), itr.getMethod().getTestClass(), itr.getInstance()),
736+
uuid
737+
);
724738
if (itr.getMethod().isBeforeGroupsConfiguration()) {
725739
linkTestToScope(getOrCreateGroupsScope(itr.getTestContext(), itr.getMethod().getBeforeGroups()), uuid);
726740
}
@@ -1004,36 +1018,9 @@ private void addTestToDataProviderScope(final ITestNGMethod method, final String
10041018
this.linkTestToScope(dataProviderScopeUuidStorage.get(method), childUuid);
10051019
}
10061020

1007-
private void addTestToClassScope(final ITestClass clazz, final String childUuid) {
1008-
this.linkTestToScope(classScopeUuidStorage.get(clazz), childUuid);
1009-
}
1010-
10111021
private void linkTestToScope(final String scopeUuid, final String childUuid) {
1012-
lock.writeLock().lock();
1013-
try {
1014-
if (nonNull(scopeUuid)) {
1015-
getLifecycle().addTestToScope(scopeKey(scopeUuid), testKey(childUuid));
1016-
}
1017-
} finally {
1018-
lock.writeLock().unlock();
1019-
}
1020-
}
1021-
1022-
private Optional<String> getClassScope(final ITestClass clazz) {
1023-
lock.readLock().lock();
1024-
try {
1025-
return Optional.ofNullable(classScopeUuidStorage.get(clazz));
1026-
} finally {
1027-
lock.readLock().unlock();
1028-
}
1029-
}
1030-
1031-
private void setClassScope(final ITestClass clazz, final String uuid) {
1032-
lock.writeLock().lock();
1033-
try {
1034-
classScopeUuidStorage.put(clazz, uuid);
1035-
} finally {
1036-
lock.writeLock().unlock();
1022+
if (nonNull(scopeUuid)) {
1023+
getLifecycle().addTestToScope(scopeKey(scopeUuid), testKey(childUuid));
10371024
}
10381025
}
10391026

@@ -1053,6 +1040,26 @@ private static boolean isClassAvailableOnClasspath(final String clazz) {
10531040
private record CurrentTest(ITestResult source, String uuid) {
10541041
}
10551042

1043+
/**
1044+
* The identity of a per-class scope: the test context and real class plus the specific instance its class
1045+
* fixtures and tests run on. The instance is compared by reference: test classes may override equals, and
1046+
* factory-created instances built from equal parameters must still get separate scopes.
1047+
*/
1048+
private record ClassInstanceKey(ITestContext context, Class<?> realClass, Object instance) {
1049+
@Override
1050+
public boolean equals(final Object other) {
1051+
return other instanceof ClassInstanceKey key
1052+
&& Objects.equals(context, key.context)
1053+
&& Objects.equals(realClass, key.realClass)
1054+
&& instance == key.instance;
1055+
}
1056+
1057+
@Override
1058+
public int hashCode() {
1059+
return Objects.hash(context, realClass) * 31 + System.identityHashCode(instance);
1060+
}
1061+
}
1062+
10561063
/**
10571064
* The identity of a group fixture scope: the test context the group configuration methods run in, plus the set
10581065
* of group names they declare.

allure-testng/src/test/java/io/qameta/allure/testng/AllureTestNgTest.java

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,10 @@
6464
import java.util.Comparator;
6565
import java.util.HashSet;
6666
import java.util.List;
67+
import java.util.Map;
6768
import java.util.Objects;
6869
import java.util.Optional;
70+
import java.util.Set;
6971
import java.util.function.Consumer;
7072
import java.util.function.Predicate;
7173
import java.util.stream.Collectors;
@@ -474,6 +476,62 @@ public void perClassFixtures(final XmlSuite.ParallelMode mode, final int threadC
474476
.contains(test1.getUuid(), test2.getUuid());
475477
}
476478

479+
@AllureFeatures.Fixtures
480+
@Issue("896")
481+
@ParameterizedTest
482+
@MethodSource("parallelConfiguration")
483+
@DisplayName("Class fixtures of factory-created instances")
484+
public void perInstanceClassFixtures(final XmlSuite.ParallelMode mode, final int threadCount) {
485+
final AllureResults results = runTestNgSuites(
486+
parallel(mode, threadCount),
487+
"suites/factory-class-fixtures.xml"
488+
);
489+
490+
final List<TestResult> testResults = results.getTestResults();
491+
assertThat(testResults).hasSize(6);
492+
493+
// tests created from the same factory instance share the instance parameter value
494+
final Map<String, String> instanceByUuid = testResults.stream()
495+
.collect(
496+
Collectors.toMap(
497+
TestResult::getUuid,
498+
result -> result.getParameters().stream()
499+
.filter(parameter -> "param".equals(parameter.getName()))
500+
.map(Parameter::getValue)
501+
.findFirst()
502+
.orElse("")
503+
)
504+
);
505+
506+
final List<TestResultContainer> classScopes = findContainersByFixtureName(
507+
results.getTestResultContainers(), "beforeClass"
508+
);
509+
assertThat(classScopes)
510+
.as("Each factory-created instance should get its own class scope")
511+
.hasSize(3);
512+
513+
assertThat(classScopes).allSatisfy(scope -> {
514+
assertThat(scope.getBefores())
515+
.extracting(FixtureResult::getName)
516+
.containsExactly("beforeClass");
517+
assertThat(scope.getAfters())
518+
.extracting(FixtureResult::getName)
519+
.containsExactly("afterClass");
520+
final Set<String> instances = scope.getChildren().stream()
521+
.map(instanceByUuid::get)
522+
.collect(Collectors.toSet());
523+
assertThat(scope.getChildren()).hasSize(2);
524+
assertThat(instances)
525+
.as("Class scope should hold the tests of a single instance")
526+
.hasSize(1);
527+
});
528+
529+
assertThat(classScopes)
530+
.flatExtracting(TestResultContainer::getChildren)
531+
.as("Class scopes should partition all tests without overlap")
532+
.containsExactlyInAnyOrderElementsOf(instanceByUuid.keySet());
533+
}
534+
477535
@AllureFeatures.Fixtures
478536
@Test
479537
@DisplayName("Before method fixture metadata propagates to the test")
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright 2016-2026 Qameta Software Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.qameta.allure.testng.samples;
17+
18+
import io.qameta.allure.Step;
19+
import io.qameta.allure.testng.TestInstanceParameter;
20+
import org.testng.annotations.AfterClass;
21+
import org.testng.annotations.BeforeClass;
22+
import org.testng.annotations.DataProvider;
23+
import org.testng.annotations.Factory;
24+
import org.testng.annotations.Test;
25+
26+
public class FactoryWithClassFixtures {
27+
28+
@TestInstanceParameter
29+
private final String param;
30+
31+
@Factory(dataProvider = "parameters")
32+
public FactoryWithClassFixtures(final String param) {
33+
this.param = param;
34+
}
35+
36+
@DataProvider
37+
public static Object[][] parameters() {
38+
return new Object[][]{{"first"}, {"second"}, {"third"}};
39+
}
40+
41+
@BeforeClass
42+
public void beforeClass() {
43+
step();
44+
}
45+
46+
@Test
47+
public void test1() {
48+
step();
49+
}
50+
51+
@Test
52+
public void test2() {
53+
step();
54+
}
55+
56+
@AfterClass
57+
public void afterClass() {
58+
step();
59+
}
60+
61+
@Step
62+
public void step() {
63+
64+
}
65+
66+
}

0 commit comments

Comments
 (0)