Skip to content

Commit a1d9640

Browse files
committed
improve assertj
1 parent 35ce7c1 commit a1d9640

14 files changed

Lines changed: 1405 additions & 101 deletions

File tree

allure-assertj/src/main/java/io/qameta/allure/assertj/AllureAspectJ.java

Lines changed: 88 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -17,102 +17,103 @@
1717

1818
import io.qameta.allure.Allure;
1919
import io.qameta.allure.AllureLifecycle;
20-
import io.qameta.allure.model.Status;
21-
import io.qameta.allure.model.StepResult;
22-
import io.qameta.allure.util.ObjectUtils;
20+
import org.assertj.core.api.AbstractAssert;
2321
import org.aspectj.lang.JoinPoint;
22+
import org.aspectj.lang.ProceedingJoinPoint;
2423
import org.aspectj.lang.annotation.After;
2524
import org.aspectj.lang.annotation.AfterReturning;
26-
import org.aspectj.lang.annotation.AfterThrowing;
25+
import org.aspectj.lang.annotation.Around;
2726
import org.aspectj.lang.annotation.Aspect;
28-
import org.aspectj.lang.annotation.Before;
2927
import org.aspectj.lang.annotation.Pointcut;
3028
import org.aspectj.lang.reflect.MethodSignature;
31-
import org.slf4j.Logger;
32-
import org.slf4j.LoggerFactory;
3329

34-
import java.util.UUID;
35-
import java.util.stream.Collectors;
36-
import java.util.stream.Stream;
37-
38-
import static io.qameta.allure.util.ResultsUtils.getStatus;
39-
import static io.qameta.allure.util.ResultsUtils.getStatusDetails;
30+
import java.util.function.Supplier;
4031

4132
/**
33+
* Captures user-side AssertJ factories and fluent calls, then delegates assertion-chain state
34+
* to {@link AssertJRecorder}.
35+
*
4236
* @author charlie (Dmitry Baev).
4337
* @author sskorol (Sergey Korol).
4438
*/
4539
@SuppressWarnings("all")
4640
@Aspect
4741
public class AllureAspectJ {
4842

49-
private static final Logger LOGGER = LoggerFactory.getLogger(AllureAspectJ.class);
50-
5143
private static InheritableThreadLocal<AllureLifecycle> lifecycle = new InheritableThreadLocal<AllureLifecycle>() {
5244
@Override
5345
protected AllureLifecycle initialValue() {
5446
return Allure.getLifecycle();
5547
}
5648
};
5749

58-
@Pointcut("execution(!private org.assertj.core.api.AbstractAssert.new(..))")
59-
public void anyAssertCreation() {
50+
private static final ThreadLocal<AssertJRecorder> RECORDER = ThreadLocal.withInitial(AssertJRecorder::new);
51+
52+
private static final ThreadLocal<Boolean> RECORDING_MUTED = ThreadLocal.withInitial(() -> false);
53+
54+
@Pointcut("("
55+
+ "call(public static * org.assertj.core.api.Assertions*.assertThat*(..))"
56+
+ " || call(public static * org.assertj.core.api.BDDAssertions*.then*(..))"
57+
+ " || call(public * org.assertj.core.api.*SoftAssertionsProvider+.assertThat*(..))"
58+
+ " || call(public * org.assertj.core.api.*SoftAssertionsProvider+.then*(..))"
59+
+ ")")
60+
public void assertFactoryCall() {
6061
//pointcut body, should be empty
6162
}
6263

63-
@Pointcut("execution(* org.assertj.core.api.AssertJProxySetup.*(..))")
64-
public void proxyMethod() {
64+
@Pointcut("("
65+
+ "call(public * org.assertj.core.api.AbstractAssert+.*(..))"
66+
+ " || call(public * org.assertj.core.api.Assert+.*(..))"
67+
+ " || call(public * org.assertj.core.api.Descriptable+.*(..))"
68+
+ ")"
69+
+ " && target(assertion)")
70+
public void assertOperationCall(final AbstractAssert<?, ?> assertion) {
6571
//pointcut body, should be empty
6672
}
6773

68-
@Pointcut("execution(public * org.assertj.core.api.AbstractAssert+.*(..)) && !proxyMethod()")
69-
public void anyAssert() {
74+
@Pointcut("!within(org.assertj..*) && !within(io.qameta.allure.assertj.AllureAspectJ)")
75+
public void userCodeCall() {
7076
//pointcut body, should be empty
7177
}
7278

73-
@After("anyAssertCreation()")
74-
public void logAssertCreation(final JoinPoint joinPoint) {
75-
final String actual = joinPoint.getArgs().length > 0
76-
? ObjectUtils.toString(joinPoint.getArgs()[0])
77-
: "<?>";
78-
final String uuid = UUID.randomUUID().toString();
79-
final String name = String.format("assertThat \'%s\'", actual);
80-
81-
final StepResult result = new StepResult()
82-
.setName(name)
83-
.setStatus(Status.PASSED);
79+
@AfterReturning(pointcut = "assertFactoryCall() && userCodeCall()", returning = "result")
80+
public void logAssertCreation(final JoinPoint joinPoint, final Object result) {
81+
if (isRecordingMuted() || !(result instanceof AbstractAssert)) {
82+
return;
83+
}
8484

85-
getLifecycle().startStep(uuid, result);
86-
getLifecycle().stopStep(uuid);
85+
final AbstractAssert<?, ?> assertion = (AbstractAssert<?, ?>) result;
86+
getRecorder().assertionCreated(getLifecycle(), assertion, firstArgumentOf(joinPoint));
8787
}
8888

89-
@Before("anyAssert()")
90-
public void stepStart(final JoinPoint joinPoint) {
91-
final MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
92-
93-
final String uuid = UUID.randomUUID().toString();
94-
final String name = joinPoint.getArgs().length > 0
95-
? String.format("%s \'%s\'", methodSignature.getName(), arrayToString(joinPoint.getArgs()))
96-
: methodSignature.getName();
97-
98-
final StepResult result = new StepResult()
99-
.setName(name);
100-
101-
getLifecycle().startStep(uuid, result);
102-
}
89+
@Around("assertOperationCall(assertion) && userCodeCall()")
90+
public Object logAssertOperation(final ProceedingJoinPoint joinPoint,
91+
final AbstractAssert<?, ?> assertion) throws Throwable {
92+
final String methodName = getMethodName(joinPoint);
93+
if (isRecordingMuted() || getRecorder().isIgnored(methodName)) {
94+
return joinPoint.proceed();
95+
}
10396

104-
@AfterThrowing(pointcut = "anyAssert()", throwing = "e")
105-
public void stepFailed(final Throwable e) {
106-
getLifecycle().updateStep(s -> s
107-
.setStatus(getStatus(e).orElse(Status.BROKEN))
108-
.setStatusDetails(getStatusDetails(e).orElse(null)));
109-
getLifecycle().stopStep();
97+
final AssertJOperation operation = getRecorder().startOperation(
98+
getLifecycle(),
99+
assertion,
100+
methodName,
101+
joinPoint.getArgs()
102+
);
103+
try {
104+
final Object result = joinPoint.proceed();
105+
getRecorder().operationPassed(operation, result);
106+
return result;
107+
} catch (Throwable throwable) {
108+
getRecorder().operationFailed(operation, throwable);
109+
throw throwable;
110+
}
110111
}
111112

112-
@AfterReturning(pointcut = "anyAssert()")
113-
public void stepStop() {
114-
getLifecycle().updateStep(s -> s.setStatus(Status.PASSED));
115-
getLifecycle().stopStep();
113+
@After("execution(public void org.assertj.core.api.DefaultAssertionErrorCollector.collectAssertionError("
114+
+ "java.lang.AssertionError)) && args(error)")
115+
public void softAssertionFailed(final AssertionError error) {
116+
getRecorder().softAssertionFailed(error);
116117
}
117118

118119
/**
@@ -122,15 +123,40 @@ public void stepStop() {
122123
*/
123124
public static void setLifecycle(final AllureLifecycle allure) {
124125
lifecycle.set(allure);
126+
clearContext();
125127
}
126128

127129
public static AllureLifecycle getLifecycle() {
128130
return lifecycle.get();
129131
}
130132

131-
private static String arrayToString(final Object... array) {
132-
return Stream.of(array)
133-
.map(ObjectUtils::toString)
134-
.collect(Collectors.joining(" "));
133+
public static void clearContext() {
134+
RECORDER.remove();
135+
}
136+
137+
static <T> T withoutRecording(final Supplier<T> supplier) {
138+
final boolean previous = RECORDING_MUTED.get();
139+
RECORDING_MUTED.set(true);
140+
try {
141+
return supplier.get();
142+
} finally {
143+
RECORDING_MUTED.set(previous);
144+
}
145+
}
146+
147+
private static AssertJRecorder getRecorder() {
148+
return RECORDER.get();
149+
}
150+
151+
private static boolean isRecordingMuted() {
152+
return RECORDING_MUTED.get();
153+
}
154+
155+
private static Object firstArgumentOf(final JoinPoint joinPoint) {
156+
return joinPoint.getArgs().length == 0 ? null : joinPoint.getArgs()[0];
157+
}
158+
159+
private static String getMethodName(final ProceedingJoinPoint joinPoint) {
160+
return ((MethodSignature) joinPoint.getSignature()).getMethod().getName();
135161
}
136162
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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.assertj;
17+
18+
import io.qameta.allure.model.Stage;
19+
import io.qameta.allure.model.Status;
20+
import io.qameta.allure.model.StatusDetails;
21+
import io.qameta.allure.model.StepResult;
22+
import org.assertj.core.api.AbstractAssert;
23+
24+
import java.util.Optional;
25+
import java.util.UUID;
26+
27+
/**
28+
* Parent Allure step for one AssertJ assertion chain.
29+
*
30+
* <p>A chain is the stable container for all meaningful fluent operations produced by one AssertJ assertion object.
31+
* {@link AssertJRecorder} creates it when user code calls an AssertJ factory such as {@code assertThat(actual)},
32+
* stores it by assertion object identity, and appends one {@link AssertJOperation} child for every reported fluent
33+
* call. Methods such as {@code extracting}, {@code first}, or {@code asInstanceOf} can return another assertion
34+
* object, but they should still read as the same assertion story, so the returned assertion is associated with this
35+
* chain instead of creating an unrelated top-level step.</p>
36+
*
37+
* <p>For a scalar assertion:</p>
38+
* <pre>{@code
39+
* assertThat("Data").hasSize(4)
40+
*
41+
* AssertJ: "Data"
42+
* hasSize(4)
43+
* }</pre>
44+
*
45+
* <p>For an assertion with a description, the parent step is renamed while the operation history stays visible:</p>
46+
* <pre>{@code
47+
* assertThat(user).as("user profile").isNotNull()
48+
*
49+
* AssertJ: user profile
50+
* as("user profile")
51+
* isNotNull()
52+
* }</pre>
53+
*
54+
* <p>For navigation or extraction, later checks remain under the same parent:</p>
55+
* <pre>{@code
56+
* assertThat(results).extracting(Result::getName).containsExactly("passed")
57+
*
58+
* AssertJ: Collection(size=1)
59+
* extracting(<lambda>) -> Collection(size=1)
60+
* containsExactly(["passed"])
61+
* }</pre>
62+
*
63+
* <p>This class is intentionally only a small mutable model around the retained {@link StepResult}. It owns the
64+
* parent step name, status, timing, and child operation list. It does not decide which AssertJ methods are meaningful
65+
* or how subjects and arguments are rendered; those decisions belong to {@link AssertJRecorder},
66+
* {@link AssertJMethodSupport}, and {@link AssertJValueRenderer}.</p>
67+
*/
68+
final class AssertJChain {
69+
70+
private static final String ASSERTJ_STEP_PREFIX = "AssertJ: ";
71+
72+
private final String uuid;
73+
74+
private final AbstractAssert<?, ?> assertion;
75+
76+
private final StepResult step;
77+
78+
AssertJChain(final AbstractAssert<?, ?> assertion, final String subject) {
79+
this.uuid = UUID.randomUUID().toString();
80+
this.assertion = assertion;
81+
this.step = new StepResult()
82+
.setName(ASSERTJ_STEP_PREFIX + subject)
83+
.setStatus(Status.PASSED)
84+
.setStage(Stage.FINISHED)
85+
.setStart(System.currentTimeMillis())
86+
.setStop(System.currentTimeMillis());
87+
}
88+
89+
String getUuid() {
90+
return uuid;
91+
}
92+
93+
AbstractAssert<?, ?> getAssertion() {
94+
return assertion;
95+
}
96+
97+
StepResult getStep() {
98+
return step;
99+
}
100+
101+
void addOperation(final AssertJOperation operation) {
102+
step.getSteps().add(operation.getStep());
103+
}
104+
105+
void rename(final Optional<String> description) {
106+
description.ifPresent(value -> step.setName(ASSERTJ_STEP_PREFIX + value));
107+
}
108+
109+
void updateStatus(final Status status, final StatusDetails details) {
110+
step
111+
.setStatus(status)
112+
.setStatusDetails(details);
113+
finish();
114+
}
115+
116+
void finish() {
117+
step.setStop(System.currentTimeMillis());
118+
}
119+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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.assertj;
17+
18+
import io.qameta.allure.listener.FixtureLifecycleListener;
19+
import io.qameta.allure.listener.TestLifecycleListener;
20+
import io.qameta.allure.model.FixtureResult;
21+
import io.qameta.allure.model.TestResult;
22+
23+
/**
24+
* Clears per-thread AssertJ recorder state after Allure has finished owning the current result.
25+
*
26+
* <p>{@link AllureAspectJ} keeps an {@link AssertJRecorder} in a {@link ThreadLocal} so assertion objects can
27+
* be matched by identity across later fluent calls. Test engines commonly reuse worker threads, so that
28+
* thread-local map would otherwise keep old assertion objects, rendered steps, and operation stack state after
29+
* the test or fixture result has already been written. The retained {@code StepResult}s are already attached to
30+
* the Allure model by reference, so removing the recorder here does not remove any reported steps; it only
31+
* releases per-thread bookkeeping before the next test or fixture starts on the same thread.</p>
32+
*/
33+
public class AssertJLifecycleListener implements TestLifecycleListener, FixtureLifecycleListener {
34+
35+
@Override
36+
public void afterTestWrite(final TestResult result) {
37+
AllureAspectJ.clearContext();
38+
}
39+
40+
@Override
41+
public void afterFixtureStop(final FixtureResult result) {
42+
AllureAspectJ.clearContext();
43+
}
44+
}

0 commit comments

Comments
 (0)