Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,102 +17,103 @@

import io.qameta.allure.Allure;
import io.qameta.allure.AllureLifecycle;
import io.qameta.allure.model.Status;
import io.qameta.allure.model.StepResult;
import io.qameta.allure.util.ObjectUtils;
import org.assertj.core.api.AbstractAssert;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static io.qameta.allure.util.ResultsUtils.getStatus;
import static io.qameta.allure.util.ResultsUtils.getStatusDetails;
import java.util.function.Supplier;

/**
* Captures user-side AssertJ factories and fluent calls, then delegates assertion-chain state
* to {@link AssertJRecorder}.
*
* @author charlie (Dmitry Baev).
* @author sskorol (Sergey Korol).
*/
@SuppressWarnings("all")
@Aspect
public class AllureAspectJ {

private static final Logger LOGGER = LoggerFactory.getLogger(AllureAspectJ.class);

private static InheritableThreadLocal<AllureLifecycle> lifecycle = new InheritableThreadLocal<AllureLifecycle>() {
@Override
protected AllureLifecycle initialValue() {
return Allure.getLifecycle();
}
};

@Pointcut("execution(!private org.assertj.core.api.AbstractAssert.new(..))")
public void anyAssertCreation() {
private static final ThreadLocal<AssertJRecorder> RECORDER = ThreadLocal.withInitial(AssertJRecorder::new);

private static final ThreadLocal<Boolean> RECORDING_MUTED = ThreadLocal.withInitial(() -> false);

@Pointcut("("
+ "call(public static * org.assertj.core.api.Assertions*.assertThat*(..))"
+ " || call(public static * org.assertj.core.api.BDDAssertions*.then*(..))"
+ " || call(public * org.assertj.core.api.*SoftAssertionsProvider+.assertThat*(..))"
+ " || call(public * org.assertj.core.api.*SoftAssertionsProvider+.then*(..))"
+ ")")
public void assertFactoryCall() {
//pointcut body, should be empty
}

@Pointcut("execution(* org.assertj.core.api.AssertJProxySetup.*(..))")
public void proxyMethod() {
@Pointcut("("
+ "call(public * org.assertj.core.api.AbstractAssert+.*(..))"
+ " || call(public * org.assertj.core.api.Assert+.*(..))"
+ " || call(public * org.assertj.core.api.Descriptable+.*(..))"
+ ")"
+ " && target(assertion)")
public void assertOperationCall(final AbstractAssert<?, ?> assertion) {
//pointcut body, should be empty
}

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

@After("anyAssertCreation()")
public void logAssertCreation(final JoinPoint joinPoint) {
final String actual = joinPoint.getArgs().length > 0
? ObjectUtils.toString(joinPoint.getArgs()[0])
: "<?>";
final String uuid = UUID.randomUUID().toString();
final String name = String.format("assertThat \'%s\'", actual);

final StepResult result = new StepResult()
.setName(name)
.setStatus(Status.PASSED);
@AfterReturning(pointcut = "assertFactoryCall() && userCodeCall()", returning = "result")
public void logAssertCreation(final JoinPoint joinPoint, final Object result) {
if (isRecordingMuted() || !(result instanceof AbstractAssert)) {
return;
}

getLifecycle().startStep(uuid, result);
getLifecycle().stopStep(uuid);
final AbstractAssert<?, ?> assertion = (AbstractAssert<?, ?>) result;
getRecorder().assertionCreated(getLifecycle(), assertion, firstArgumentOf(joinPoint));
}

@Before("anyAssert()")
public void stepStart(final JoinPoint joinPoint) {
final MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();

final String uuid = UUID.randomUUID().toString();
final String name = joinPoint.getArgs().length > 0
? String.format("%s \'%s\'", methodSignature.getName(), arrayToString(joinPoint.getArgs()))
: methodSignature.getName();

final StepResult result = new StepResult()
.setName(name);

getLifecycle().startStep(uuid, result);
}
@Around("assertOperationCall(assertion) && userCodeCall()")
public Object logAssertOperation(final ProceedingJoinPoint joinPoint,
final AbstractAssert<?, ?> assertion) throws Throwable {
final String methodName = getMethodName(joinPoint);
if (isRecordingMuted() || getRecorder().isIgnored(methodName)) {
return joinPoint.proceed();
}

@AfterThrowing(pointcut = "anyAssert()", throwing = "e")
public void stepFailed(final Throwable e) {
getLifecycle().updateStep(s -> s
.setStatus(getStatus(e).orElse(Status.BROKEN))
.setStatusDetails(getStatusDetails(e).orElse(null)));
getLifecycle().stopStep();
final AssertJOperation operation = getRecorder().startOperation(
getLifecycle(),
assertion,
methodName,
joinPoint.getArgs()
);
try {
final Object result = joinPoint.proceed();
getRecorder().operationPassed(operation, result);
return result;
} catch (Throwable throwable) {
getRecorder().operationFailed(operation, throwable);
throw throwable;
}
}

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

/**
Expand All @@ -122,15 +123,40 @@ public void stepStop() {
*/
public static void setLifecycle(final AllureLifecycle allure) {
lifecycle.set(allure);
clearContext();
}

public static AllureLifecycle getLifecycle() {
return lifecycle.get();
}

private static String arrayToString(final Object... array) {
return Stream.of(array)
.map(ObjectUtils::toString)
.collect(Collectors.joining(" "));
public static void clearContext() {
RECORDER.remove();
}

static <T> T withoutRecording(final Supplier<T> supplier) {
final boolean previous = RECORDING_MUTED.get();
RECORDING_MUTED.set(true);
try {
return supplier.get();
} finally {
RECORDING_MUTED.set(previous);
}
}

private static AssertJRecorder getRecorder() {
return RECORDER.get();
}

private static boolean isRecordingMuted() {
return RECORDING_MUTED.get();
}

private static Object firstArgumentOf(final JoinPoint joinPoint) {
return joinPoint.getArgs().length == 0 ? null : joinPoint.getArgs()[0];
}

private static String getMethodName(final ProceedingJoinPoint joinPoint) {
return ((MethodSignature) joinPoint.getSignature()).getMethod().getName();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Copyright 2016-2026 Qameta Software Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.qameta.allure.assertj;

import io.qameta.allure.model.Stage;
import io.qameta.allure.model.Status;
import io.qameta.allure.model.StatusDetails;
import io.qameta.allure.model.StepResult;
import org.assertj.core.api.AbstractAssert;

import java.util.Optional;
import java.util.UUID;

/**
* Parent Allure step for one AssertJ assertion chain.
*
* <p>A chain is the stable container for all meaningful fluent operations produced by one AssertJ assertion object.
* {@link AssertJRecorder} creates it when user code calls an AssertJ factory such as {@code assertThat(actual)},
* stores it by assertion object identity, and appends one {@link AssertJOperation} child for every reported fluent
* call. Methods such as {@code extracting}, {@code first}, or {@code asInstanceOf} can return another assertion
* object, but they should still read as the same assertion story, so the returned assertion is associated with this
* chain instead of creating an unrelated top-level step.</p>
*
* <p>For a scalar assertion:</p>
* <pre>{@code
* assertThat("Data").hasSize(4)
*
* AssertJ: "Data"
* hasSize(4)
* }</pre>
*
* <p>For an assertion with a description, the parent step is renamed while the operation history stays visible:</p>
* <pre>{@code
* assertThat(user).as("user profile").isNotNull()
*
* AssertJ: user profile
* as("user profile")
* isNotNull()
* }</pre>
*
* <p>For navigation or extraction, later checks remain under the same parent:</p>
* <pre>{@code
* assertThat(results).extracting(Result::getName).containsExactly("passed")
*
* AssertJ: Collection(size=1)
* extracting(<lambda>) -> Collection(size=1)
* containsExactly(["passed"])
* }</pre>
*
* <p>This class is intentionally only a small mutable model around the retained {@link StepResult}. It owns the
* parent step name, status, timing, and child operation list. It does not decide which AssertJ methods are meaningful
* or how subjects and arguments are rendered; those decisions belong to {@link AssertJRecorder},
* {@link AssertJMethodSupport}, and {@link AssertJValueRenderer}.</p>
*/
final class AssertJChain {

private static final String ASSERTJ_STEP_PREFIX = "AssertJ: ";

private final String uuid;

private final AbstractAssert<?, ?> assertion;

private final StepResult step;

AssertJChain(final AbstractAssert<?, ?> assertion, final String subject) {
this.uuid = UUID.randomUUID().toString();
this.assertion = assertion;
this.step = new StepResult()
.setName(ASSERTJ_STEP_PREFIX + subject)
.setStatus(Status.PASSED)
.setStage(Stage.FINISHED)
.setStart(System.currentTimeMillis())
.setStop(System.currentTimeMillis());
}

String getUuid() {
return uuid;
}

AbstractAssert<?, ?> getAssertion() {
return assertion;
}

StepResult getStep() {
return step;
}

void addOperation(final AssertJOperation operation) {
step.getSteps().add(operation.getStep());
}

void rename(final Optional<String> description) {
description.ifPresent(value -> step.setName(ASSERTJ_STEP_PREFIX + value));
}

void updateStatus(final Status status, final StatusDetails details) {
step
.setStatus(status)
.setStatusDetails(details);
finish();
}

void finish() {
step.setStop(System.currentTimeMillis());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright 2016-2026 Qameta Software Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.qameta.allure.assertj;

import io.qameta.allure.listener.FixtureLifecycleListener;
import io.qameta.allure.listener.TestLifecycleListener;
import io.qameta.allure.model.FixtureResult;
import io.qameta.allure.model.TestResult;

/**
* Clears per-thread AssertJ recorder state after Allure has finished owning the current result.
*
* <p>{@link AllureAspectJ} keeps an {@link AssertJRecorder} in a {@link ThreadLocal} so assertion objects can
* be matched by identity across later fluent calls. Test engines commonly reuse worker threads, so that
* thread-local map would otherwise keep old assertion objects, rendered steps, and operation stack state after
* the test or fixture result has already been written. The retained {@code StepResult}s are already attached to
* the Allure model by reference, so removing the recorder here does not remove any reported steps; it only
* releases per-thread bookkeeping before the next test or fixture starts on the same thread.</p>
*/
public class AssertJLifecycleListener implements TestLifecycleListener, FixtureLifecycleListener {

@Override
public void afterTestWrite(final TestResult result) {
AllureAspectJ.clearContext();
}

@Override
public void afterFixtureStop(final FixtureResult result) {
AllureAspectJ.clearContext();
}
}
Loading
Loading