From 8fda7532d4fb481f2e39c3ff77c37ada3ec65d7d Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Wed, 6 May 2026 17:21:15 +0100 Subject: [PATCH 1/4] improve assertj --- .../qameta/allure/assertj/AllureAspectJ.java | 150 ++++---- .../qameta/allure/assertj/AssertJChain.java | 119 +++++++ .../assertj/AssertJLifecycleListener.java | 44 +++ .../allure/assertj/AssertJMethodSupport.java | 96 +++++ .../allure/assertj/AssertJOperation.java | 155 ++++++++ .../allure/assertj/AssertJRecorder.java | 258 ++++++++++++++ .../allure/assertj/AssertJValueRenderer.java | 242 +++++++++++++ ...a.allure.listener.FixtureLifecycleListener | 1 + ...meta.allure.listener.TestLifecycleListener | 1 + .../allure/assertj/AllureAspectJTest.java | 335 ++++++++++++++++-- .../io/qameta/allure/util/ObjectUtils.java | 4 +- .../io/qameta/allure/util/ResultsUtils.java | 46 +++ .../io/qameta/allure/ResultsUtilsTest.java | 41 +++ .../qameta/allure/util/ObjectUtilsTest.java | 14 + 14 files changed, 1405 insertions(+), 101 deletions(-) create mode 100644 allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java create mode 100644 allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJLifecycleListener.java create mode 100644 allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJMethodSupport.java create mode 100644 allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJOperation.java create mode 100644 allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java create mode 100644 allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJValueRenderer.java create mode 100644 allure-assertj/src/main/resources/META-INF/services/io.qameta.allure.listener.FixtureLifecycleListener create mode 100644 allure-assertj/src/main/resources/META-INF/services/io.qameta.allure.listener.TestLifecycleListener diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AllureAspectJ.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AllureAspectJ.java index dbd92dc36..00af9c095 100644 --- a/allure-assertj/src/main/java/io/qameta/allure/assertj/AllureAspectJ.java +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AllureAspectJ.java @@ -17,28 +17,22 @@ 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). */ @@ -46,8 +40,6 @@ @Aspect public class AllureAspectJ { - private static final Logger LOGGER = LoggerFactory.getLogger(AllureAspectJ.class); - private static InheritableThreadLocal lifecycle = new InheritableThreadLocal() { @Override protected AllureLifecycle initialValue() { @@ -55,64 +47,73 @@ protected AllureLifecycle initialValue() { } }; - @Pointcut("execution(!private org.assertj.core.api.AbstractAssert.new(..))") - public void anyAssertCreation() { + private static final ThreadLocal RECORDER = ThreadLocal.withInitial(AssertJRecorder::new); + + private static final ThreadLocal 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); } /** @@ -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 withoutRecording(final Supplier 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(); } } diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java new file mode 100644 index 000000000..a9d6338f1 --- /dev/null +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java @@ -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. + * + *

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.

+ * + *

For a scalar assertion:

+ *
{@code
+ * assertThat("Data").hasSize(4)
+ *
+ * AssertJ: "Data"
+ *   hasSize(4)
+ * }
+ * + *

For an assertion with a description, the parent step is renamed while the operation history stays visible:

+ *
{@code
+ * assertThat(user).as("user profile").isNotNull()
+ *
+ * AssertJ: user profile
+ *   as("user profile")
+ *   isNotNull()
+ * }
+ * + *

For navigation or extraction, later checks remain under the same parent:

+ *
{@code
+ * assertThat(results).extracting(Result::getName).containsExactly("passed")
+ *
+ * AssertJ: Collection(size=1)
+ *   extracting() -> Collection(size=1)
+ *   containsExactly(["passed"])
+ * }
+ * + *

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}.

+ */ +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 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()); + } +} diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJLifecycleListener.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJLifecycleListener.java new file mode 100644 index 000000000..c9ea7a1ea --- /dev/null +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJLifecycleListener.java @@ -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. + * + *

{@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.

+ */ +public class AssertJLifecycleListener implements TestLifecycleListener, FixtureLifecycleListener { + + @Override + public void afterTestWrite(final TestResult result) { + AllureAspectJ.clearContext(); + } + + @Override + public void afterFixtureStop(final FixtureResult result) { + AllureAspectJ.clearContext(); + } +} diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJMethodSupport.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJMethodSupport.java new file mode 100644 index 000000000..685e51bf0 --- /dev/null +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJMethodSupport.java @@ -0,0 +1,96 @@ +/* + * 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 java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Keeps method-name decisions out of the aspect and recorder flow. + */ +final class AssertJMethodSupport { + + private static final String AS = "as"; + private static final String DESCRIBED_AS = "describedAs"; + + private static final List IGNORED_METHODS = Arrays.asList( + "actual", + "descriptionText", + "equals", + "getWritableAssertionInfo", + "hashCode", + "toString" + ); + + private static final Set NAVIGATION_METHODS = new HashSet<>(Arrays.asList( + "asBase64Decoded", + "asBoolean", + "asByte", + "asDouble", + "asFloat", + "asInstanceOf", + "asInt", + "asList", + "asLong", + "asShort", + "asString", + "bytes", + "decodedAsBase64", + "element", + "elements", + "extracting", + "extractingResultOf", + "first", + "flatExtracting", + "flatMap", + "last", + "map", + "rootCause", + "singleElement", + "size", + "usingRecursiveAssertion", + "usingRecursiveComparison" + )); + + private AssertJMethodSupport() { + throw new IllegalStateException("do not instantiate"); + } + + static boolean isIgnored(final String methodName) { + return IGNORED_METHODS.contains(methodName); + } + + static String normalize(final String methodName) { + final int accessorIndex = methodName.indexOf("$accessor$"); + if (accessorIndex > 0) { + return methodName.substring(0, accessorIndex); + } + if (DESCRIBED_AS.equals(methodName)) { + return AS; + } + return methodName; + } + + static boolean isDescription(final String methodName) { + return AS.equals(methodName) || DESCRIBED_AS.equals(methodName); + } + + static boolean isNavigation(final String methodName) { + return NAVIGATION_METHODS.contains(methodName); + } +} diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJOperation.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJOperation.java new file mode 100644 index 000000000..33026ca9d --- /dev/null +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJOperation.java @@ -0,0 +1,155 @@ +/* + * 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 static io.qameta.allure.util.ResultsUtils.getStatus; +import static io.qameta.allure.util.ResultsUtils.getStatusDetails; + +/** + * Child Allure step for one meaningful AssertJ fluent operation. + * + *

An operation is the report entry for one fluent method call inside an {@link AssertJChain}. The recorder creates + * it before proceeding with the intercepted AssertJ call, marks it passed or failed after the call returns, and keeps + * it attached to the chain that owns the assertion object. Earlier operations remain passed when a later operation + * fails, so the report shows the exact point where the assertion chain stopped matching the expectation.

+ * + *

For a simple assertion, each checked method becomes one operation:

+ *
{@code
+ * assertThat("Data").startsWith("Da").endsWith("ta")
+ *
+ * AssertJ: "Data"
+ *   startsWith("Da")
+ *   endsWith("ta")
+ * }
+ * + *

For navigation methods, the operation name is enriched with the returned subject. The returned AssertJ object + * still belongs to the same chain, so the report stays readable as one story:

+ *
{@code
+ * assertThat(users).first(InstanceOfAssertFactories.STRING).startsWith("alice")
+ *
+ * AssertJ: Collection(size=1)
+ *   first(InstanceOfAssertFactory) -> "alice@example.org"
+ *   startsWith("alice")
+ * }
+ * + *

For failures, this operation receives the failure status and status details, and the parent chain receives the + * same status. This makes the failed operation visible without losing the successful context before it:

+ *
{@code
+ * assertThat("Data").startsWith("Da").hasSize(5)
+ *
+ * AssertJ: "Data"                 FAILED
+ *   startsWith("Da")              PASSED
+ *   hasSize(5)                    FAILED
+ * }
+ * + *

Some AssertJ methods call other assertion methods internally. Those calls should not become extra child steps + * because they would duplicate implementation details instead of user intent. The {@code nestedLevel} counter lets the + * recorder reuse the active operation while those internal calls run, then finish only the user-visible operation.

+ */ +final class AssertJOperation { + + private final AssertJChain chain; + + private final String methodName; + + private final StepResult step; + + private final boolean navigation; + + private String returnedSubject; + + private int nestedLevel; + + AssertJOperation(final AssertJChain chain, + final String methodName, + final String name, + final boolean navigation) { + this.chain = chain; + this.methodName = methodName; + this.navigation = navigation; + this.step = new StepResult() + .setName(name) + .setStage(Stage.RUNNING) + .setStart(System.currentTimeMillis()); + } + + AssertJChain getChain() { + return chain; + } + + StepResult getStep() { + return step; + } + + boolean isNavigation() { + return navigation; + } + + boolean isDescription() { + return AssertJMethodSupport.isDescription(methodName); + } + + boolean isNested() { + return nestedLevel > 0; + } + + AssertJOperation nested() { + nestedLevel++; + return this; + } + + void leaveNested() { + nestedLevel--; + } + + void setReturnedSubject(final String subject) { + if (returnedSubject != null) { + return; + } + + returnedSubject = subject; + step.setName(step.getName() + " -> " + subject); + } + + void passed() { + if (step.getStatus() == null) { + step.setStatus(Status.PASSED); + } + finish(); + } + + void failed(final Throwable throwable) { + final Status status = getStatus(throwable).orElse(Status.BROKEN); + final StatusDetails details = getStatusDetails(throwable).orElse(null); + step + .setStatus(status) + .setStatusDetails(details); + chain.updateStatus(status, details); + finish(); + } + + private void finish() { + step + .setStage(Stage.FINISHED) + .setStop(System.currentTimeMillis()); + chain.finish(); + } +} diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java new file mode 100644 index 000000000..7f97434db --- /dev/null +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java @@ -0,0 +1,258 @@ +/* + * 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.AllureLifecycle; +import org.assertj.core.api.AbstractAssert; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Records AssertJ objects by identity and builds one Allure step tree per assertion chain. + * + *

The recorder is the stateful part behind {@link AllureAspectJ}. The aspect only detects user-side AssertJ + * factory calls and fluent operation calls; this class decides which {@link AssertJChain} owns the assertion object, + * where a new {@link AssertJOperation} should be attached, and how pass/fail state should be reflected in the retained + * Allure {@code StepResult} tree.

+ * + *

Each aspect thread gets its own recorder instance. Assertion objects are tracked in an {@link IdentityHashMap} + * because AssertJ assertion classes can override {@code equals} and {@code hashCode}; object identity is the only safe + * way to know that a later fluent call belongs to the same assertion object that was created earlier.

+ * + *

The normal hard-assertion flow is:

+ *
{@code
+ * assertThat("Data").startsWith("Da").endsWith("ta")
+ *
+ * assertionCreated(assertThat result, "Data")
+ * startOperation(startsWith, ["Da"])
+ * operationPassed(startsWith)
+ * startOperation(endsWith, ["ta"])
+ * operationPassed(endsWith)
+ *
+ * AssertJ: "Data"
+ *   startsWith("Da")
+ *   endsWith("ta")
+ * }
+ * + *

Stored assertion instances keep separate chains because the map key is the assertion instance itself:

+ *
{@code
+ * final AbstractStringAssert a = assertThat("alpha");
+ * final AbstractStringAssert b = assertThat("bravo");
+ *
+ * a.isEqualTo("alpha");
+ * b.isEqualTo("bravo");
+ *
+ * AssertJ: "alpha"
+ *   isEqualTo("alpha")
+ * AssertJ: "bravo"
+ *   isEqualTo("bravo")
+ * }
+ * + *

Navigation operations such as {@code extracting}, {@code first}, and {@code asInstanceOf} may return new AssertJ + * assertion objects. Those returned objects are registered against the existing chain, so later checks stay under the + * same parent step:

+ *
{@code
+ * assertThat(results).extracting(Result::getName).containsExactly("passed")
+ *
+ * AssertJ: Collection(size=1)
+ *   extracting() -> Collection(size=1)
+ *   containsExactly(["passed"])
+ * }
+ * + *

The {@code operations} stack tracks the currently executing user-visible operation. It has two jobs: assertions + * created inside callbacks such as {@code satisfies} are attached beneath the active operation, and AssertJ internal + * calls on the same chain are counted as nested work instead of being reported as extra steps.

+ * + *
{@code
+ * assertThat("alpha").satisfies(value -> assertThat(value).startsWith("al"))
+ *
+ * AssertJ: "alpha"
+ *   satisfies()
+ *     AssertJ: "alpha"
+ *       startsWith("al")
+ * }
+ * + *

Soft assertion failures are reported before {@code assertAll()} throws. The AssertJ error collector callback calls + * {@link #softAssertionFailed(AssertionError)}, which marks the active operation and its chain as failed while + * preserving the earlier passed operations.

+ */ +final class AssertJRecorder { + + private final Map, AssertJChain> chains = new IdentityHashMap<>(); + + private final Deque operations = new ArrayDeque<>(); + + private final AssertJValueRenderer renderer = new AssertJValueRenderer(); + + void assertionCreated(final AllureLifecycle lifecycle, + final AbstractAssert assertion, + final Object actual) { + if (chains.containsKey(assertion)) { + return; + } + + final AssertJOperation activeOperation = activeOperation(); + if (isNavigationResult(activeOperation)) { + chains.put(assertion, activeOperation.getChain()); + return; + } + + final AssertJChain chain = new AssertJChain(assertion, renderer.renderSubject(actual)); + chains.put(assertion, chain); + attachChain(lifecycle, chain, activeOperation); + } + + AssertJOperation startOperation(final AllureLifecycle lifecycle, + final AbstractAssert assertion, + final String methodName, + final Object[] args) { + final AssertJChain chain = chainFor(lifecycle, assertion); + final String normalizedName = AssertJMethodSupport.normalize(methodName); + + final AssertJOperation activeOperation = activeOperation(); + if (isInternalCallOnSameChain(activeOperation, chain)) { + return activeOperation.nested(); + } + + final AssertJOperation operation = new AssertJOperation( + chain, + normalizedName, + renderer.renderOperation(normalizedName, args), + AssertJMethodSupport.isNavigation(normalizedName) + ); + chain.addOperation(operation); + operations.push(operation); + return operation; + } + + void operationPassed(final AssertJOperation operation, final Object result) { + if (operation.isNested()) { + pop(operation); + return; + } + + registerReturnedAssertion(operation, result); + renameChainFromDescription(operation); + operation.passed(); + pop(operation); + } + + void operationFailed(final AssertJOperation operation, final Throwable throwable) { + operation.failed(throwable); + pop(operation); + } + + void softAssertionFailed(final AssertionError error) { + final AssertJOperation current = activeOperation(); + if (current != null) { + current.failed(error); + } + } + + boolean isIgnored(final String methodName) { + return AssertJMethodSupport.isIgnored(methodName); + } + + private AssertJChain chainFor(final AllureLifecycle lifecycle, final AbstractAssert assertion) { + final AssertJChain chain = chains.get(assertion); + if (chain != null) { + return chain; + } + + final AssertJChain created = new AssertJChain(assertion, renderer.renderSubject(actualOf(assertion))); + chains.put(assertion, created); + attachChain(lifecycle, created, activeOperation()); + return created; + } + + private void attachChain(final AllureLifecycle lifecycle, + final AssertJChain chain, + final AssertJOperation parentOperation) { + if (parentOperation == null) { + lifecycle.startStep(chain.getUuid(), chain.getStep()); + lifecycle.stopStep(chain.getUuid()); + return; + } + + parentOperation.getStep().getSteps().add(chain.getStep()); + } + + private void registerReturnedAssertion(final AssertJOperation operation, final Object result) { + if (!(result instanceof AbstractAssert)) { + return; + } + + final AbstractAssert returned = (AbstractAssert) result; + chains.put(returned, operation.getChain()); + if (operation.isNavigation()) { + operation.setReturnedSubject(renderer.renderSubject(actualOf(returned))); + } + } + + private void renameChainFromDescription(final AssertJOperation operation) { + if (operation.isDescription()) { + operation.getChain().rename(descriptionOf(operation.getChain().getAssertion())); + } + } + + private AssertJOperation activeOperation() { + return operations.peek(); + } + + private boolean isNavigationResult(final AssertJOperation activeOperation) { + return activeOperation != null && activeOperation.isNavigation(); + } + + private boolean isInternalCallOnSameChain(final AssertJOperation activeOperation, final AssertJChain chain) { + return activeOperation != null && activeOperation.getChain() == chain; + } + + private void pop(final AssertJOperation operation) { + if (operation.isNested()) { + operation.leaveNested(); + return; + } + if (!operations.isEmpty() && operations.peek() == operation) { + operations.pop(); + } + } + + private Object actualOf(final AbstractAssert assertion) { + return AllureAspectJ.withoutRecording(() -> { + try { + return assertion.actual(); + } catch (RuntimeException e) { + return null; + } + }); + } + + private Optional descriptionOf(final AbstractAssert assertion) { + return AllureAspectJ.withoutRecording(() -> { + try { + return Optional.ofNullable(assertion.descriptionText()) + .map(String::trim) + .filter(value -> !value.isEmpty()); + } catch (RuntimeException e) { + return Optional.empty(); + } + }); + } +} diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJValueRenderer.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJValueRenderer.java new file mode 100644 index 000000000..51a224dc3 --- /dev/null +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJValueRenderer.java @@ -0,0 +1,242 @@ +/* + * 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.util.ObjectUtils; +import org.assertj.core.description.Description; + +import java.lang.reflect.Array; +import java.net.URI; +import java.net.URL; +import java.nio.file.Path; +import java.time.temporal.TemporalAccessor; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static io.qameta.allure.util.ResultsUtils.getLambdaName; + +/** + * Renders AssertJ subjects and arguments into semantic step names. + */ +@SuppressWarnings("all") +final class AssertJValueRenderer { + + private static final String LAMBDA = ""; + + String renderSubject(final Object value) { + if (value == null) { + return "null"; + } + if (value instanceof CharSequence || isSimple(value)) { + return renderSimple(value); + } + if (value instanceof Collection) { + return "Collection(size=" + ((Collection) value).size() + ")"; + } + if (value instanceof Map) { + return "Map(size=" + ((Map) value).size() + ")"; + } + if (value.getClass().isArray()) { + return value.getClass().getComponentType().getSimpleName() + + "[](length=" + Array.getLength(value) + ")"; + } + if (value instanceof Iterable) { + return "Iterable"; + } + return simpleClassName(value); + } + + String renderOperation(final String methodName, final Object[] args) { + if (args.length == 0) { + return methodName + "()"; + } + return methodName + "(" + renderArguments(methodName, args) + ")"; + } + + private String renderArguments(final String methodName, final Object[] args) { + if (isDescriptionWithEmptyValues(args)) { + return renderArgument(args[0]); + } + if (isSingleVarargToUnwrap(methodName, args)) { + return renderArgument(Array.get(args[0], 0)); + } + if (isSingleArrayArgument(args)) { + return renderArray(args[0]); + } + return renderEach(args); + } + + private boolean isDescriptionWithEmptyValues(final Object[] args) { + return args.length == 2 + && args[1] != null + && args[1].getClass().isArray() + && Array.getLength(args[1]) == 0; + } + + private boolean isSingleVarargToUnwrap(final String methodName, final Object[] args) { + return args.length == 1 + && args[0] != null + && args[0].getClass().isArray() + && Array.getLength(args[0]) == 1 + && shouldUnwrapSingleVararg(methodName); + } + + private boolean isSingleArrayArgument(final Object[] args) { + return args.length == 1 + && args[0] != null + && args[0].getClass().isArray(); + } + + private boolean shouldUnwrapSingleVararg(final String methodName) { + return !methodName.contains("Any") + && !methodName.contains("Exactly") + && !methodName.contains("Only") + && !methodName.contains("Sequence") + && !methodName.contains("Subsequence") + && !methodName.endsWith("In"); + } + + private String renderEach(final Object[] args) { + final List values = new ArrayList<>(); + for (Object arg : args) { + values.add(renderArgument(arg)); + } + return values.stream().collect(Collectors.joining(", ")); + } + + private String renderArgument(final Object value) { + if (value == null) { + return "null"; + } + if (isLambda(value)) { + return renderLambda(value); + } + if (value instanceof Description) { + return renderSimple(value.toString()); + } + if (value instanceof CharSequence || isSimple(value)) { + return renderSimple(value); + } + if (value instanceof Collection) { + return "Collection(size=" + ((Collection) value).size() + ")"; + } + if (value instanceof Map) { + return "Map(size=" + ((Map) value).size() + ")"; + } + if (value.getClass().isArray()) { + return renderArray(value); + } + return simpleClassName(value); + } + + private String renderArray(final Object array) { + if (array instanceof byte[]) { + return ObjectUtils.toString(array); + } + + final int length = Array.getLength(array); + if (array.getClass().getComponentType().isPrimitive()) { + return ObjectUtils.toString(array); + } + if (allLambdas(array, length)) { + return length == 1 ? renderLambda(Array.get(array, 0)) : lambdaList(array, length); + } + if (allSimple(array, length) || !array.getClass().getComponentType().isPrimitive()) { + return renderObjectArray(array, length); + } + return array.getClass().getComponentType().getSimpleName() + "[](length=" + length + ")"; + } + + private String renderObjectArray(final Object array, final int length) { + final List values = new ArrayList<>(); + for (int i = 0; i < length; i++) { + values.add(renderArgument(Array.get(array, i))); + } + return values.stream().collect(Collectors.joining(", ", "[", "]")); + } + + private boolean allLambdas(final Object array, final int length) { + if (length == 0) { + return false; + } + for (int i = 0; i < length; i++) { + if (!isLambda(Array.get(array, i))) { + return false; + } + } + return true; + } + + private String lambdaList(final Object array, final int length) { + final List values = new ArrayList<>(); + for (int i = 0; i < length; i++) { + values.add(renderLambda(Array.get(array, i))); + } + return values.stream().collect(Collectors.joining(", ", "[", "]")); + } + + private boolean allSimple(final Object array, final int length) { + for (int i = 0; i < length; i++) { + final Object item = Array.get(array, i); + if (item != null && !isSimple(item) && !(item instanceof CharSequence)) { + return false; + } + } + return true; + } + + private boolean isSimple(final Object value) { + return value instanceof Number + || value instanceof Boolean + || value instanceof Character + || value instanceof Enum + || value instanceof Path + || value instanceof URI + || value instanceof URL + || value instanceof TemporalAccessor; + } + + private boolean isLambda(final Object value) { + final Class type = value.getClass(); + return type.isSynthetic() || type.getName().contains("$$Lambda$"); + } + + private String renderLambda(final Object value) { + return getLambdaName(value) + .orElse(LAMBDA); + } + + private String renderSimple(final Object value) { + if (value instanceof CharSequence || value instanceof Character) { + return "\"" + ObjectUtils.toString(value) + "\""; + } + if (value instanceof Enum) { + return ((Enum) value).name(); + } + return ObjectUtils.toString(value); + } + + private String simpleClassName(final Object value) { + final Class type = value.getClass(); + if (type.isAnonymousClass()) { + return type.getSuperclass().getSimpleName(); + } + return type.getSimpleName(); + } +} diff --git a/allure-assertj/src/main/resources/META-INF/services/io.qameta.allure.listener.FixtureLifecycleListener b/allure-assertj/src/main/resources/META-INF/services/io.qameta.allure.listener.FixtureLifecycleListener new file mode 100644 index 000000000..65aaf3eca --- /dev/null +++ b/allure-assertj/src/main/resources/META-INF/services/io.qameta.allure.listener.FixtureLifecycleListener @@ -0,0 +1 @@ +io.qameta.allure.assertj.AssertJLifecycleListener diff --git a/allure-assertj/src/main/resources/META-INF/services/io.qameta.allure.listener.TestLifecycleListener b/allure-assertj/src/main/resources/META-INF/services/io.qameta.allure.listener.TestLifecycleListener new file mode 100644 index 000000000..65aaf3eca --- /dev/null +++ b/allure-assertj/src/main/resources/META-INF/services/io.qameta.allure.listener.TestLifecycleListener @@ -0,0 +1 @@ +io.qameta.allure.assertj.AssertJLifecycleListener diff --git a/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java b/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java index 957657b14..1da74e2e2 100644 --- a/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java +++ b/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java @@ -15,18 +15,25 @@ */ package io.qameta.allure.assertj; +import io.qameta.allure.model.Status; import io.qameta.allure.model.StepResult; import io.qameta.allure.model.TestResult; import io.qameta.allure.test.AllureFeatures; import io.qameta.allure.test.AllureResults; +import org.assertj.core.api.AbstractStringAssert; +import org.assertj.core.api.InstanceOfAssertFactories; import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.Test; +import java.io.Serializable; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.Collections; +import java.util.function.Function; import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; /** * @author charlie (Dmitry Baev). @@ -35,90 +42,342 @@ class AllureAspectJTest { @AllureFeatures.Steps @Test - void shouldCreateStepsForAsserts() { + void shouldCreateSemanticChainForScalarAssert() { final AllureResults results = runWithinTestContext(() -> { assertThat("Data") .hasSize(4); }, AllureAspectJ::setLifecycle); - assertThat(results.getTestResults()) - .flatExtracting(TestResult::getSteps) + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly(tuple("AssertJ: \"Data\"", Status.PASSED)); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) - .containsExactly( - "assertThat 'Data'", - "hasSize '4'" - ); + .containsExactly("hasSize(4)"); } @AllureFeatures.Steps @Test - void shouldHandleNullableObject() { + void shouldUseAssertDescriptionAsChainName() { final AllureResults results = runWithinTestContext(() -> { assertThat((Object) null) .as("Nullable object") .isNull(); }, AllureAspectJ::setLifecycle); - assertThat(results.getTestResults()) - .flatExtracting(TestResult::getSteps) + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("AssertJ: Nullable object"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("as(\"Nullable object\")", "isNull()"); + } + + @AllureFeatures.Steps + @Test + void shouldRenderByteArraysWithoutPayload() { + final String value = "some string"; + final AllureResults results = runWithinTestContext(() -> { + assertThat(value.getBytes(StandardCharsets.UTF_8)) + .as("Byte array object") + .isEqualTo(value.getBytes(StandardCharsets.UTF_8)); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("AssertJ: Byte array object"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("as(\"Byte array object\")", "isEqualTo()"); + } + + @AllureFeatures.Steps + @Test + void shouldRenderCollectionsAsSubjectsAndExpectedValuesAsValues() { + final AllureResults results = runWithinTestContext(() -> { + assertThat(Arrays.asList("a", "b")) + .containsExactly("a", "b"); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("AssertJ: Collection(size=2)"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("containsExactly([\"a\", \"b\"])"); + } + + @AllureFeatures.Steps + @Test + void shouldCreateSeparateChainsForMultipleAssertThatCalls() { + final AllureResults results = runWithinTestContext(() -> { + assertThat("Data") + .hasSize(4); + + assertThat(42) + .isPositive() + .isEqualTo(42); + + assertThat(Arrays.asList("a", "b")) + .hasSize(2) + .contains("a"); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly( + tuple("AssertJ: \"Data\"", Status.PASSED), + tuple("AssertJ: 42", Status.PASSED), + tuple("AssertJ: Collection(size=2)", Status.PASSED) + ); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) .containsExactly( - "assertThat 'null'", - "as 'Nullable object []'", - "isNull" + "hasSize(4)", + "isPositive()", + "isEqualTo(42)", + "hasSize(2)", + "contains(\"a\")" ); } @AllureFeatures.Steps @Test - void shouldHandleByteArrayObject() { - final String s = "some string"; + void shouldAttachOperationsToStoredAssertionInstances() { + final String targetA = "alpha"; + final String targetB = "bravo"; + final AllureResults results = runWithinTestContext(() -> { - assertThat(s.getBytes(StandardCharsets.UTF_8)) - .as("Byte array object") - .isEqualTo(s.getBytes(StandardCharsets.UTF_8)); + final AbstractStringAssert a = assertThat(targetA); + final AbstractStringAssert b = assertThat(targetB); + + a.isEqualTo("alpha"); + b.isEqualTo("bravo"); }, AllureAspectJ::setLifecycle); - assertThat(results.getTestResults()) - .flatExtracting(TestResult::getSteps) + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly( + tuple("AssertJ: \"alpha\"", Status.PASSED), + tuple("AssertJ: \"bravo\"", Status.PASSED) + ); + assertThat(result.getSteps()) + .filteredOn("name", "AssertJ: \"alpha\"") + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("isEqualTo(\"alpha\")"); + assertThat(result.getSteps()) + .filteredOn("name", "AssertJ: \"bravo\"") + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("isEqualTo(\"bravo\")"); + } + + @AllureFeatures.Steps + @Test + void shouldAvoidVerboseModelToStringPayloads() { + final TestResult model = new TestResult() + .setUuid("uid") + .setName("testPassed") + .setFullName("other.PassingTest.testPassed"); + + final AllureResults results = runWithinTestContext(() -> { + assertThat(Collections.singletonList(model)) + .hasSize(1) + .containsExactly(model); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("AssertJ: Collection(size=1)"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("hasSize(1)", "containsExactly([TestResult])"); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .noneMatch(name -> name.contains("fullName=")) + .noneMatch(name -> name.contains("other.PassingTest.testPassed")); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .noneMatch(name -> name.contains("fullName=")) + .noneMatch(name -> name.contains("other.PassingTest.testPassed")); + } + + @AllureFeatures.Steps + @Test + void shouldKeepNavigationInsideTheSameChain() { + final TestResult model = new TestResult() + .setFullName("my.company.Test.testOne"); + + final AllureResults results = runWithinTestContext(() -> { + assertThat(Collections.singletonList(model)) + .extracting(TestResult::getFullName) + .containsExactly("my.company.Test.testOne"); + + assertThat(Collections.singletonList("alpha")) + .first(InstanceOfAssertFactories.STRING) + .startsWith("al"); + + assertThat(Collections.singletonList("bravo")) + .singleElement(InstanceOfAssertFactories.STRING) + .endsWith("vo"); + + assertThat((Object) "charlie") + .asInstanceOf(InstanceOfAssertFactories.STRING) + .contains("har"); + + assertThat(Collections.singletonList(Collections.singletonList("delta"))) + .flatExtracting(value -> value) + .containsExactly("delta"); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .hasSize(5); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) .containsExactly( - "assertThat ''", - "describedAs 'Byte array object'", - "isEqualTo ''" + "extracting() -> Collection(size=1)", + "containsExactly([\"my.company.Test.testOne\"])", + "first(InstanceOfAssertFactory) -> \"alpha\"", + "startsWith(\"al\")", + "singleElement(InstanceOfAssertFactory) -> \"bravo\"", + "endsWith(\"vo\")", + "asInstanceOf(InstanceOfAssertFactory) -> \"charlie\"", + "contains(\"har\")", + "flatExtracting() -> Collection(size=1)", + "containsExactly([\"delta\"])" ); } @AllureFeatures.Steps @Test - void shouldHandleCollections() { + void shouldRenderSerializedLambdaMethodReferences() { + final TestResult model = new TestResult() + .setFullName("my.company.Test.testOne"); + final AllureResults results = runWithinTestContext(() -> { - assertThat(Arrays.asList("a", "b")) - .containsExactly("a", "b"); + assertThat(Collections.singletonList(model)) + .extracting((Function & Serializable) TestResult::getFullName) + .containsExactly("my.company.Test.testOne"); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly( + "extracting(TestResult::getFullName) -> Collection(size=1)", + "containsExactly([\"my.company.Test.testOne\"])" + ); + } + + @AllureFeatures.Steps + @Test + void shouldMarkTheFailedHardAssertionOperation() { + final AllureResults results = runWithinTestContext(() -> { + assertThat("Data") + .hasSize(5); }, AllureAspectJ::setLifecycle); - assertThat(results.getTestResults()) - .flatExtracting(TestResult::getSteps) - .extracting(StepResult::getName) - .containsExactly( - "assertThatList '[a, b]'", - "containsExactly '[a, b]'" - ); + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly(tuple("AssertJ: \"Data\"", Status.FAILED)); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly(tuple("hasSize(5)", Status.FAILED)); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .filteredOn("name", "hasSize(5)") + .extracting(step -> step.getStatusDetails().getMessage()) + .singleElement() + .asString() + .contains("size"); } @AllureFeatures.Steps @Test - void softAssertions() { + void shouldMarkTheFailedSoftAssertionOperationBeforeAssertAll() { final AllureResults results = runWithinTestContext(() -> { final SoftAssertions soft = new SoftAssertions(); soft.assertThat(25) - .as("Test description") + .as("Age") .isEqualTo(26); soft.assertAll(); }, AllureAspectJ::setLifecycle); - assertThat(results.getTestResults()) - .flatExtracting(TestResult::getSteps) + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly(tuple("AssertJ: Age", Status.FAILED)); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly( + tuple("as(\"Age\")", Status.PASSED), + tuple("isEqualTo(26)", Status.FAILED) + ); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .filteredOn("name", "isEqualTo(26)") + .extracting(step -> step.getStatusDetails().getMessage()) + .singleElement() + .asString() + .contains("expected: 26"); + } + + @AllureFeatures.Steps + @Test + void shouldAttachNestedAssertionsUnderCallbackOperations() { + final AllureResults results = runWithinTestContext(() -> { + assertThat("alpha") + .satisfies(value -> assertThat(value) + .startsWith("al") + .endsWith("ha")); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("AssertJ: \"alpha\""); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("satisfies()"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .filteredOn("name", "satisfies()") + .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) - .contains("as 'Test description []'", "isEqualTo '26'"); + .containsExactly("AssertJ: \"alpha\""); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .filteredOn("name", "satisfies()") + .flatExtracting(StepResult::getSteps) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("startsWith(\"al\")", "endsWith(\"ha\")"); + } + + private TestResult assertOnlyOneResult(final AllureResults results) { + assertThat(results.getTestResults()).hasSize(1); + return results.getTestResults().get(0); } } diff --git a/allure-java-commons/src/main/java/io/qameta/allure/util/ObjectUtils.java b/allure-java-commons/src/main/java/io/qameta/allure/util/ObjectUtils.java index f409d81b5..f37ab44b4 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/util/ObjectUtils.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/util/ObjectUtils.java @@ -51,7 +51,9 @@ public static String toString(final Object object) { try { if (Objects.nonNull(object) && object.getClass().isArray()) { if (object instanceof Object[]) { - return Arrays.toString((Object[]) object); + return Arrays.stream((Object[]) object) + .map(ObjectUtils::toString) + .collect(Collectors.joining(", ", "[", "]")); } else if (object instanceof long[]) { return Arrays.toString((long[]) object); } else if (object instanceof short[]) { diff --git a/allure-java-commons/src/main/java/io/qameta/allure/util/ResultsUtils.java b/allure-java-commons/src/main/java/io/qameta/allure/util/ResultsUtils.java index d8d3b7f8c..f134b3c4d 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/util/ResultsUtils.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/util/ResultsUtils.java @@ -35,6 +35,7 @@ import java.io.InputStream; import java.io.PrintWriter; import java.io.StringWriter; +import java.lang.invoke.SerializedLambda; import java.lang.management.ManagementFactory; import java.lang.reflect.Method; import java.math.BigInteger; @@ -336,6 +337,11 @@ public static String md5(final String source) { return bytesToHex(getMd5Digest().digest(source.getBytes(StandardCharsets.UTF_8))); } + public static Optional getLambdaName(final Object lambda) { + return getSerializedLambda(lambda) + .flatMap(ResultsUtils::formatLambdaName); + } + public static String bytesToHex(final byte[] bytes) { return new BigInteger(1, bytes).toString(16); } @@ -442,4 +448,44 @@ private static boolean separateLines() { return parseBoolean(loadAllureProperties().getProperty(ALLURE_SEPARATE_LINES_SYSPROP)); } + private static Optional getSerializedLambda(final Object value) { + if (Objects.isNull(value)) { + return Optional.empty(); + } + try { + final Method writeReplace = value.getClass().getDeclaredMethod("writeReplace"); + writeReplace.setAccessible(true); + final Object replacement = writeReplace.invoke(value); + if (replacement instanceof SerializedLambda) { + return Optional.of((SerializedLambda) replacement); + } + } catch (ReflectiveOperationException | RuntimeException ignored) { + return Optional.empty(); + } + return Optional.empty(); + } + + private static Optional formatLambdaName(final SerializedLambda lambda) { + final String methodName = lambda.getImplMethodName(); + if (methodName.startsWith("lambda$")) { + return Optional.empty(); + } + return Optional.of(simpleClassName(lambda.getImplClass()) + "::" + getLambdaMethodName(methodName)); + } + + private static String getLambdaMethodName(final String methodName) { + if ("".equals(methodName)) { + return "new"; + } + return methodName; + } + + private static String simpleClassName(final String name) { + final String normalized = name.replace('/', '.'); + final int packageIndex = normalized.lastIndexOf('.'); + final int nestedClassIndex = normalized.lastIndexOf('$'); + final int index = Math.max(packageIndex, nestedClassIndex); + return index < 0 ? normalized : normalized.substring(index + 1); + } + } diff --git a/allure-java-commons/src/test/java/io/qameta/allure/ResultsUtilsTest.java b/allure-java-commons/src/test/java/io/qameta/allure/ResultsUtilsTest.java index 79b6505a3..dea5482c8 100644 --- a/allure-java-commons/src/test/java/io/qameta/allure/ResultsUtilsTest.java +++ b/allure-java-commons/src/test/java/io/qameta/allure/ResultsUtilsTest.java @@ -24,8 +24,10 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import java.io.Serializable; import java.lang.annotation.Annotation; import java.util.Objects; +import java.util.function.Function; import java.util.stream.Stream; import static io.qameta.allure.util.ResultsUtils.ISSUE_LINK_TYPE; @@ -151,6 +153,32 @@ public String value() { .hasFieldOrPropertyWithValue("type", TMS_LINK_TYPE); } + @Test + void shouldGetSerializedLambdaName() { + final Function getter = + (Function & Serializable) LambdaSubject::getName; + + assertThat(ResultsUtils.getLambdaName(getter)) + .hasValue("LambdaSubject::getName"); + } + + @Test + void shouldIgnoreGeneratedSerializedLambdaBody() { + final Function getter = + (Function & Serializable) subject -> subject.getName(); + + assertThat(ResultsUtils.getLambdaName(getter)) + .isEmpty(); + } + + @Test + void shouldIgnoreNonSerializedLambda() { + final Function getter = LambdaSubject::getName; + + assertThat(ResultsUtils.getLambdaName(getter)) + .isEmpty(); + } + public static Stream data() { return Stream.of( Arguments.of("a", "b", "c", "d", "e", link("a", "c", "d")), @@ -200,4 +228,17 @@ public void clearSystemProperty(final String type, final String sysProp) { private static io.qameta.allure.model.Link link(String name, String url, String type) { return new io.qameta.allure.model.Link().setName(name).setUrl(url).setType(type); } + + private static final class LambdaSubject { + + private final String name; + + LambdaSubject(final String name) { + this.name = name; + } + + String getName() { + return name; + } + } } diff --git a/allure-java-commons/src/test/java/io/qameta/allure/util/ObjectUtilsTest.java b/allure-java-commons/src/test/java/io/qameta/allure/util/ObjectUtilsTest.java index e28ee2cfb..586697b55 100644 --- a/allure-java-commons/src/test/java/io/qameta/allure/util/ObjectUtilsTest.java +++ b/allure-java-commons/src/test/java/io/qameta/allure/util/ObjectUtilsTest.java @@ -34,6 +34,20 @@ void shouldProcessToStringNpe() { .isEqualTo(""); } + @Test + void shouldProcessArraysItemByItem() { + final Object[] array = { + "value", + "binary".getBytes(), + new MyNpeClass(), + }; + + final String string = ObjectUtils.toString(array); + + assertThat(string) + .isEqualTo("[value, , ]"); + } + public class MyNpeClass { Integer value = null; From 40005f9a2dbd48f39e931c484e6095729c1f6e25 Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Thu, 7 May 2026 13:56:09 +0100 Subject: [PATCH 2/4] fix format --- .../main/java/io/qameta/allure/assertj/AssertJRecorder.java | 4 +++- .../src/main/java/io/qameta/allure/util/ResultsUtils.java | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java index 7f97434db..a834061e6 100644 --- a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java @@ -122,7 +122,7 @@ void assertionCreated(final AllureLifecycle lifecycle, AssertJOperation startOperation(final AllureLifecycle lifecycle, final AbstractAssert assertion, final String methodName, - final Object[] args) { + final Object... args) { final AssertJChain chain = chainFor(lifecycle, assertion); final String normalizedName = AssertJMethodSupport.normalize(methodName); @@ -220,10 +220,12 @@ private boolean isNavigationResult(final AssertJOperation activeOperation) { return activeOperation != null && activeOperation.isNavigation(); } + @SuppressWarnings("PMD.CompareObjectsWithEquals") private boolean isInternalCallOnSameChain(final AssertJOperation activeOperation, final AssertJChain chain) { return activeOperation != null && activeOperation.getChain() == chain; } + @SuppressWarnings("PMD.CompareObjectsWithEquals") private void pop(final AssertJOperation operation) { if (operation.isNested()) { operation.leaveNested(); diff --git a/allure-java-commons/src/main/java/io/qameta/allure/util/ResultsUtils.java b/allure-java-commons/src/main/java/io/qameta/allure/util/ResultsUtils.java index f134b3c4d..38faa3a50 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/util/ResultsUtils.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/util/ResultsUtils.java @@ -448,6 +448,7 @@ private static boolean separateLines() { return parseBoolean(loadAllureProperties().getProperty(ALLURE_SEPARATE_LINES_SYSPROP)); } + @SuppressWarnings("PMD.AvoidAccessibilityAlteration") private static Optional getSerializedLambda(final Object value) { if (Objects.isNull(value)) { return Optional.empty(); From fe2b3d2612894b611f2276a2b075adb3c9cd027f Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Thu, 7 May 2026 15:24:07 +0100 Subject: [PATCH 3/4] replace AssertJ prefix with just assert keyword --- .../qameta/allure/assertj/AssertJChain.java | 8 ++--- .../allure/assertj/AssertJOperation.java | 6 ++-- .../allure/assertj/AssertJRecorder.java | 12 +++---- .../allure/assertj/AllureAspectJTest.java | 32 +++++++++---------- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java index a9d6338f1..bba86666b 100644 --- a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java @@ -38,7 +38,7 @@ *
{@code
  * assertThat("Data").hasSize(4)
  *
- * AssertJ: "Data"
+ * assert "Data"
  *   hasSize(4)
  * }
* @@ -46,7 +46,7 @@ *
{@code
  * assertThat(user).as("user profile").isNotNull()
  *
- * AssertJ: user profile
+ * assert user profile
  *   as("user profile")
  *   isNotNull()
  * }
@@ -55,7 +55,7 @@ *
{@code
  * assertThat(results).extracting(Result::getName).containsExactly("passed")
  *
- * AssertJ: Collection(size=1)
+ * assert Collection(size=1)
  *   extracting() -> Collection(size=1)
  *   containsExactly(["passed"])
  * }
@@ -67,7 +67,7 @@ */ final class AssertJChain { - private static final String ASSERTJ_STEP_PREFIX = "AssertJ: "; + private static final String ASSERTJ_STEP_PREFIX = "assert "; private final String uuid; diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJOperation.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJOperation.java index 33026ca9d..e610cb1b6 100644 --- a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJOperation.java +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJOperation.java @@ -35,7 +35,7 @@ *
{@code
  * assertThat("Data").startsWith("Da").endsWith("ta")
  *
- * AssertJ: "Data"
+ * assert "Data"
  *   startsWith("Da")
  *   endsWith("ta")
  * }
@@ -45,7 +45,7 @@ *
{@code
  * assertThat(users).first(InstanceOfAssertFactories.STRING).startsWith("alice")
  *
- * AssertJ: Collection(size=1)
+ * assert Collection(size=1)
  *   first(InstanceOfAssertFactory) -> "alice@example.org"
  *   startsWith("alice")
  * }
@@ -55,7 +55,7 @@ *
{@code
  * assertThat("Data").startsWith("Da").hasSize(5)
  *
- * AssertJ: "Data"                 FAILED
+ * assert "Data"                 FAILED
  *   startsWith("Da")              PASSED
  *   hasSize(5)                    FAILED
  * }
diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java index a834061e6..c69a269c6 100644 --- a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java @@ -46,7 +46,7 @@ * startOperation(endsWith, ["ta"]) * operationPassed(endsWith) * - * AssertJ: "Data" + * assert "Data" * startsWith("Da") * endsWith("ta") * } @@ -59,9 +59,9 @@ * a.isEqualTo("alpha"); * b.isEqualTo("bravo"); * - * AssertJ: "alpha" + * assert "alpha" * isEqualTo("alpha") - * AssertJ: "bravo" + * assert "bravo" * isEqualTo("bravo") * } * @@ -71,7 +71,7 @@ *
{@code
  * assertThat(results).extracting(Result::getName).containsExactly("passed")
  *
- * AssertJ: Collection(size=1)
+ * assert Collection(size=1)
  *   extracting() -> Collection(size=1)
  *   containsExactly(["passed"])
  * }
@@ -83,9 +83,9 @@ *
{@code
  * assertThat("alpha").satisfies(value -> assertThat(value).startsWith("al"))
  *
- * AssertJ: "alpha"
+ * assert "alpha"
  *   satisfies()
- *     AssertJ: "alpha"
+ *     assert "alpha"
  *       startsWith("al")
  * }
* diff --git a/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java b/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java index 1da74e2e2..f2d37e2bb 100644 --- a/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java +++ b/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java @@ -51,7 +51,7 @@ void shouldCreateSemanticChainForScalarAssert() { final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) .extracting(StepResult::getName, StepResult::getStatus) - .containsExactly(tuple("AssertJ: \"Data\"", Status.PASSED)); + .containsExactly(tuple("assert \"Data\"", Status.PASSED)); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) @@ -70,7 +70,7 @@ void shouldUseAssertDescriptionAsChainName() { final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) .extracting(StepResult::getName) - .containsExactly("AssertJ: Nullable object"); + .containsExactly("assert Nullable object"); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) @@ -90,7 +90,7 @@ void shouldRenderByteArraysWithoutPayload() { final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) .extracting(StepResult::getName) - .containsExactly("AssertJ: Byte array object"); + .containsExactly("assert Byte array object"); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) @@ -108,7 +108,7 @@ void shouldRenderCollectionsAsSubjectsAndExpectedValuesAsValues() { final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) .extracting(StepResult::getName) - .containsExactly("AssertJ: Collection(size=2)"); + .containsExactly("assert Collection(size=2)"); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) @@ -135,9 +135,9 @@ void shouldCreateSeparateChainsForMultipleAssertThatCalls() { assertThat(result.getSteps()) .extracting(StepResult::getName, StepResult::getStatus) .containsExactly( - tuple("AssertJ: \"Data\"", Status.PASSED), - tuple("AssertJ: 42", Status.PASSED), - tuple("AssertJ: Collection(size=2)", Status.PASSED) + tuple("assert \"Data\"", Status.PASSED), + tuple("assert 42", Status.PASSED), + tuple("assert Collection(size=2)", Status.PASSED) ); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) @@ -169,16 +169,16 @@ void shouldAttachOperationsToStoredAssertionInstances() { assertThat(result.getSteps()) .extracting(StepResult::getName, StepResult::getStatus) .containsExactly( - tuple("AssertJ: \"alpha\"", Status.PASSED), - tuple("AssertJ: \"bravo\"", Status.PASSED) + tuple("assert \"alpha\"", Status.PASSED), + tuple("assert \"bravo\"", Status.PASSED) ); assertThat(result.getSteps()) - .filteredOn("name", "AssertJ: \"alpha\"") + .filteredOn("name", "assert \"alpha\"") .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) .containsExactly("isEqualTo(\"alpha\")"); assertThat(result.getSteps()) - .filteredOn("name", "AssertJ: \"bravo\"") + .filteredOn("name", "assert \"bravo\"") .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) .containsExactly("isEqualTo(\"bravo\")"); @@ -201,7 +201,7 @@ void shouldAvoidVerboseModelToStringPayloads() { final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) .extracting(StepResult::getName) - .containsExactly("AssertJ: Collection(size=1)"); + .containsExactly("assert Collection(size=1)"); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) @@ -298,7 +298,7 @@ void shouldMarkTheFailedHardAssertionOperation() { final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) .extracting(StepResult::getName, StepResult::getStatus) - .containsExactly(tuple("AssertJ: \"Data\"", Status.FAILED)); + .containsExactly(tuple("assert \"Data\"", Status.FAILED)); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName, StepResult::getStatus) @@ -326,7 +326,7 @@ void shouldMarkTheFailedSoftAssertionOperationBeforeAssertAll() { final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) .extracting(StepResult::getName, StepResult::getStatus) - .containsExactly(tuple("AssertJ: Age", Status.FAILED)); + .containsExactly(tuple("assert Age", Status.FAILED)); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName, StepResult::getStatus) @@ -356,7 +356,7 @@ void shouldAttachNestedAssertionsUnderCallbackOperations() { final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) .extracting(StepResult::getName) - .containsExactly("AssertJ: \"alpha\""); + .containsExactly("assert \"alpha\""); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) @@ -366,7 +366,7 @@ void shouldAttachNestedAssertionsUnderCallbackOperations() { .filteredOn("name", "satisfies()") .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) - .containsExactly("AssertJ: \"alpha\""); + .containsExactly("assert \"alpha\""); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .filteredOn("name", "satisfies()") From a7feff88cf8957b6c9b24954ea6b592a63f12e2a Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Thu, 7 May 2026 17:11:42 +0100 Subject: [PATCH 4/4] improve render --- .../qameta/allure/assertj/AssertJChain.java | 20 +- .../allure/assertj/AssertJOperation.java | 21 +- .../allure/assertj/AssertJRecorder.java | 19 +- .../allure/assertj/AssertJValueRenderer.java | 334 +++++++++++++++++- .../allure/assertj/AllureAspectJTest.java | 189 ++++++++-- 5 files changed, 512 insertions(+), 71 deletions(-) diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java index bba86666b..c4d30c752 100644 --- a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJChain.java @@ -39,7 +39,7 @@ * assertThat("Data").hasSize(4) * * assert "Data" - * hasSize(4) + * has size 4 * } * *

For an assertion with a description, the parent step is renamed while the operation history stays visible:

@@ -47,17 +47,17 @@ * assertThat(user).as("user profile").isNotNull() * * assert user profile - * as("user profile") - * isNotNull() + * described as "user profile" + * is not null * } * *

For navigation or extraction, later checks remain under the same parent:

*
{@code
  * assertThat(results).extracting(Result::getName).containsExactly("passed")
  *
- * assert Collection(size=1)
- *   extracting() -> Collection(size=1)
- *   containsExactly(["passed"])
+ * assert 1 Result item
+ *   extracts Result::getName -> 1 string
+ *   contains exactly ["passed"]
  * }
* *

This class is intentionally only a small mutable model around the retained {@link StepResult}. It owns the @@ -79,7 +79,7 @@ final class AssertJChain { this.uuid = UUID.randomUUID().toString(); this.assertion = assertion; this.step = new StepResult() - .setName(ASSERTJ_STEP_PREFIX + subject) + .setName(chainName(subject)) .setStatus(Status.PASSED) .setStage(Stage.FINISHED) .setStart(System.currentTimeMillis()) @@ -103,7 +103,7 @@ void addOperation(final AssertJOperation operation) { } void rename(final Optional description) { - description.ifPresent(value -> step.setName(ASSERTJ_STEP_PREFIX + value)); + description.ifPresent(value -> step.setName(chainName(value))); } void updateStatus(final Status status, final StatusDetails details) { @@ -116,4 +116,8 @@ void updateStatus(final Status status, final StatusDetails details) { void finish() { step.setStop(System.currentTimeMillis()); } + + private String chainName(final String subject) { + return AssertJValueRenderer.truncateStepName(ASSERTJ_STEP_PREFIX + subject); + } } diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJOperation.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJOperation.java index e610cb1b6..d79b025fc 100644 --- a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJOperation.java +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJOperation.java @@ -16,10 +16,13 @@ package io.qameta.allure.assertj; import io.qameta.allure.model.Stage; +import io.qameta.allure.model.Parameter; import io.qameta.allure.model.Status; import io.qameta.allure.model.StatusDetails; import io.qameta.allure.model.StepResult; +import java.util.List; + import static io.qameta.allure.util.ResultsUtils.getStatus; import static io.qameta.allure.util.ResultsUtils.getStatusDetails; @@ -36,8 +39,8 @@ * assertThat("Data").startsWith("Da").endsWith("ta") * * assert "Data" - * startsWith("Da") - * endsWith("ta") + * starts with "Da" + * ends with "ta" * } * *

For navigation methods, the operation name is enriched with the returned subject. The returned AssertJ object @@ -45,9 +48,9 @@ *

{@code
  * assertThat(users).first(InstanceOfAssertFactories.STRING).startsWith("alice")
  *
- * assert Collection(size=1)
- *   first(InstanceOfAssertFactory) -> "alice@example.org"
- *   startsWith("alice")
+ * assert 1 string
+ *   first element as InstanceOfAssertFactory -> "alice@example.org"
+ *   starts with "alice"
  * }
* *

For failures, this operation receives the failure status and status details, and the parent chain receives the @@ -56,8 +59,8 @@ * assertThat("Data").startsWith("Da").hasSize(5) * * assert "Data" FAILED - * startsWith("Da") PASSED - * hasSize(5) FAILED + * starts with "Da" PASSED + * has size 5 FAILED * } * *

Some AssertJ methods call other assertion methods internally. Those calls should not become extra child steps @@ -81,12 +84,14 @@ final class AssertJOperation { AssertJOperation(final AssertJChain chain, final String methodName, final String name, + final List parameters, final boolean navigation) { this.chain = chain; this.methodName = methodName; this.navigation = navigation; this.step = new StepResult() .setName(name) + .setParameters(parameters) .setStage(Stage.RUNNING) .setStart(System.currentTimeMillis()); } @@ -126,7 +131,7 @@ void setReturnedSubject(final String subject) { } returnedSubject = subject; - step.setName(step.getName() + " -> " + subject); + step.setName(AssertJValueRenderer.truncateStepName(step.getName() + " -> " + subject)); } void passed() { diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java index c69a269c6..423d5a48d 100644 --- a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJRecorder.java @@ -47,8 +47,8 @@ * operationPassed(endsWith) * * assert "Data" - * startsWith("Da") - * endsWith("ta") + * starts with "Da" + * ends with "ta" * } * *

Stored assertion instances keep separate chains because the map key is the assertion instance itself:

@@ -60,9 +60,9 @@ * b.isEqualTo("bravo"); * * assert "alpha" - * isEqualTo("alpha") + * is equal to "alpha" * assert "bravo" - * isEqualTo("bravo") + * is equal to "bravo" * } * *

Navigation operations such as {@code extracting}, {@code first}, and {@code asInstanceOf} may return new AssertJ @@ -71,9 +71,9 @@ *

{@code
  * assertThat(results).extracting(Result::getName).containsExactly("passed")
  *
- * assert Collection(size=1)
- *   extracting() -> Collection(size=1)
- *   containsExactly(["passed"])
+ * assert 1 Result item
+ *   extracts Result::getName -> 1 string
+ *   contains exactly ["passed"]
  * }
* *

The {@code operations} stack tracks the currently executing user-visible operation. It has two jobs: assertions @@ -84,9 +84,9 @@ * assertThat("alpha").satisfies(value -> assertThat(value).startsWith("al")) * * assert "alpha" - * satisfies() + * satisfies * assert "alpha" - * startsWith("al") + * starts with "al" * } * *

Soft assertion failures are reported before {@code assertAll()} throws. The AssertJ error collector callback calls @@ -135,6 +135,7 @@ AssertJOperation startOperation(final AllureLifecycle lifecycle, chain, normalizedName, renderer.renderOperation(normalizedName, args), + renderer.renderParameters(normalizedName, args), AssertJMethodSupport.isNavigation(normalizedName) ); chain.addOperation(operation); diff --git a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJValueRenderer.java b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJValueRenderer.java index 51a224dc3..d7bee7c89 100644 --- a/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJValueRenderer.java +++ b/allure-assertj/src/main/java/io/qameta/allure/assertj/AssertJValueRenderer.java @@ -15,8 +15,10 @@ */ package io.qameta.allure.assertj; +import io.qameta.allure.model.Parameter; import io.qameta.allure.util.ObjectUtils; import org.assertj.core.description.Description; +import org.assertj.core.groups.Tuple; import java.lang.reflect.Array; import java.net.URI; @@ -25,8 +27,10 @@ import java.time.temporal.TemporalAccessor; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import static io.qameta.allure.util.ResultsUtils.getLambdaName; @@ -37,9 +41,51 @@ @SuppressWarnings("all") final class AssertJValueRenderer { + private static final int STEP_NAME_LIMIT = 1000; + + private static final int INLINE_VALUE_LIMIT = 3; + private static final String LAMBDA = ""; + private static final String TRUNCATED = "..."; + String renderSubject(final Object value) { + return truncateStepName(renderSubjectValue(value)); + } + + String renderOperation(final String methodName, final Object[] args) { + return truncateStepName(renderOperationName(methodName, args)); + } + + List renderParameters(final String methodName, final Object[] args) { + final Object[] values = parameterArguments(methodName, args); + if (values.length == 0) { + return Collections.emptyList(); + } + + final String renderedOperation = renderOperation(methodName, args); + final List parameters = new ArrayList<>(); + for (int index = 0; index < values.length; index++) { + final String value = renderParameterValue(values[index]); + if (renderedOperation.contains(value)) { + continue; + } + parameters.add(new Parameter() + .setName(parameterName(methodName, index)) + .setValue(value) + .setMode(Parameter.Mode.DEFAULT)); + } + return parameters; + } + + static String truncateStepName(final String value) { + if (value == null || value.length() <= STEP_NAME_LIMIT) { + return value; + } + return value.substring(0, STEP_NAME_LIMIT - TRUNCATED.length()) + TRUNCATED; + } + + private String renderSubjectValue(final Object value) { if (value == null) { return "null"; } @@ -47,26 +93,135 @@ String renderSubject(final Object value) { return renderSimple(value); } if (value instanceof Collection) { - return "Collection(size=" + ((Collection) value).size() + ")"; + if (isInlineCollection((Collection) value)) { + return renderCollectionValue((Collection) value); + } + return renderCollectionSubject((Collection) value); } if (value instanceof Map) { - return "Map(size=" + ((Map) value).size() + ")"; + return "map with " + renderEntryCount(((Map) value).size()); } if (value.getClass().isArray()) { - return value.getClass().getComponentType().getSimpleName() - + "[](length=" + Array.getLength(value) + ")"; + return renderArraySubject(value); } if (value instanceof Iterable) { - return "Iterable"; + return "iterable"; } return simpleClassName(value); } - String renderOperation(final String methodName, final Object[] args) { + private Object[] parameterArguments(final String methodName, final Object[] args) { + if (isDescriptionWithEmptyValues(args)) { + return new Object[]{args[0]}; + } + if (isSingleVarargToUnwrap(methodName, args)) { + return new Object[]{Array.get(args[0], 0)}; + } + return args; + } + + private String parameterName(final String methodName, final int index) { + if ("hasFieldOrPropertyWithValue".equals(methodName)) { + return index == 0 ? "field or property" : "expected value"; + } + + if (index > 0) { + return "argument " + (index + 1); + } + + switch (methodName) { + case "as": + return "description"; + case "asInstanceOf": + case "first": + case "singleElement": + return "factory"; + case "extracting": + case "flatExtracting": + return "extractor"; + case "hasSize": + return "expected size"; + case "satisfies": + return "condition"; + case "contains": + case "containsExactly": + case "containsExactlyInAnyOrder": + case "endsWith": + case "isEqualTo": + case "startsWith": + return "expected"; + default: + return "argument 1"; + } + } + + private String renderParameterValue(final Object value) { + return renderArgument(value); + } + + private String renderOperationName(final String methodName, final Object[] args) { if (args.length == 0) { - return methodName + "()"; + return readableMethodName(methodName); } - return methodName + "(" + renderArguments(methodName, args) + ")"; + + final String arguments = renderArguments(methodName, args); + switch (methodName) { + case "as": + return "described as " + arguments; + case "asInstanceOf": + return "as instance of " + arguments; + case "contains": + return "contains " + arguments; + case "containsExactly": + return "contains exactly " + arguments; + case "containsExactlyInAnyOrder": + return "contains exactly in any order " + arguments; + case "endsWith": + return "ends with " + arguments; + case "extracting": + return "extracts " + arguments; + case "flatExtracting": + return "flat extracts " + arguments; + case "first": + return "first element as " + arguments; + case "hasFieldOrPropertyWithValue": + return renderHasFieldOrPropertyWithValue(args); + case "hasSize": + return "has size " + arguments; + case "isEqualTo": + return "is equal to " + arguments; + case "singleElement": + return "single element as " + arguments; + case "startsWith": + return "starts with " + arguments; + case "satisfies": + return "satisfies " + arguments; + default: + return readableMethodName(methodName) + " " + arguments; + } + } + + private String renderHasFieldOrPropertyWithValue(final Object[] args) { + if (args.length != 2) { + return "has field or property with value " + renderEach(args); + } + return "has field or property " + renderArgument(args[0]) + " with value " + renderArgument(args[1]); + } + + private String readableMethodName(final String methodName) { + if (methodName.startsWith("is") && methodName.length() > 2 && Character.isUpperCase(methodName.charAt(2))) { + return "is " + splitCamelCase(methodName.substring(2)); + } + if (methodName.startsWith("has") && methodName.length() > 3 && Character.isUpperCase(methodName.charAt(3))) { + return "has " + splitCamelCase(methodName.substring(3)); + } + return splitCamelCase(methodName); + } + + private String splitCamelCase(final String value) { + return value + .replaceAll("([a-z0-9])([A-Z])", "$1 $2") + .toLowerCase(); } private String renderArguments(final String methodName, final Object[] args) { @@ -130,14 +285,20 @@ private String renderArgument(final Object value) { if (value instanceof Description) { return renderSimple(value.toString()); } + if (value instanceof Tuple) { + return renderTuple((Tuple) value); + } if (value instanceof CharSequence || isSimple(value)) { return renderSimple(value); } if (value instanceof Collection) { - return "Collection(size=" + ((Collection) value).size() + ")"; + if (isInlineCollection((Collection) value)) { + return renderCollectionValue((Collection) value); + } + return renderCollectionSubject((Collection) value); } if (value instanceof Map) { - return "Map(size=" + ((Map) value).size() + ")"; + return "map with " + renderEntryCount(((Map) value).size()); } if (value.getClass().isArray()) { return renderArray(value); @@ -163,6 +324,159 @@ private String renderArray(final Object array) { return array.getClass().getComponentType().getSimpleName() + "[](length=" + length + ")"; } + private String renderCollectionSubject(final Collection value) { + final int size = value.size(); + if (size == 0) { + return "empty collection"; + } + return commonElementType(value) + .map(type -> renderElementCount(size, type)) + .orElseGet(() -> renderItemCount(size)); + } + + private String renderArraySubject(final Object array) { + final int length = Array.getLength(array); + if (isInlineArray(array, length)) { + return renderArrayValue(array, length); + } + if (array instanceof byte[]) { + return "byte array with " + renderByteCount(length); + } + return renderElementCount(length, array.getClass().getComponentType()); + } + + private boolean isInlineCollection(final Collection value) { + return value.size() <= INLINE_VALUE_LIMIT && allInlineValues(value); + } + + private boolean allInlineValues(final Collection value) { + for (Object item : value) { + if (!isInlineValue(item)) { + return false; + } + } + return true; + } + + private boolean isInlineValue(final Object value) { + return value == null + || isLambda(value) + || value instanceof Description + || isInlineTuple(value) + || value instanceof CharSequence + || isSimple(value); + } + + private boolean isInlineTuple(final Object value) { + if (!(value instanceof Tuple)) { + return false; + } + final Object[] values = ((Tuple) value).toArray(); + return isInlineArray(values, values.length); + } + + private String renderTuple(final Tuple tuple) { + final Object[] values = tuple.toArray(); + return renderObjectArray(values, values.length) + .replaceFirst("^\\[", "(") + .replaceFirst("]$", ")"); + } + + private String renderCollectionValue(final Collection value) { + final List values = new ArrayList<>(); + for (Object item : value) { + values.add(renderArgument(item)); + } + return values.stream().collect(Collectors.joining(", ", "[", "]")); + } + + private boolean isInlineArray(final Object array, final int length) { + if (length > INLINE_VALUE_LIMIT || array instanceof byte[]) { + return false; + } + if (array.getClass().getComponentType().isPrimitive()) { + return true; + } + for (int i = 0; i < length; i++) { + if (!isInlineValue(Array.get(array, i))) { + return false; + } + } + return true; + } + + private String renderArrayValue(final Object array, final int length) { + if (array.getClass().getComponentType().isPrimitive()) { + return ObjectUtils.toString(array); + } + return renderObjectArray(array, length); + } + + private Optional> commonElementType(final Collection value) { + Class result = null; + for (Object item : value) { + if (item == null) { + continue; + } + final Class itemType = elementTypeOf(item); + if (result == null) { + result = itemType; + } else if (!result.equals(itemType)) { + return java.util.Optional.empty(); + } + } + return java.util.Optional.ofNullable(result); + } + + private Class elementTypeOf(final Object item) { + if (item instanceof Collection) { + return Collection.class; + } + if (item instanceof Map) { + return Map.class; + } + return item.getClass(); + } + + private String renderElementCount(final int size, final Class type) { + if (String.class.equals(type)) { + return size + " " + pluralize("string", size); + } + if (Boolean.class.equals(type) || Boolean.TYPE.equals(type)) { + return size + " " + pluralize("boolean", size); + } + if (Character.class.equals(type) || Character.TYPE.equals(type)) { + return size + " " + pluralize("character", size); + } + if (Number.class.isAssignableFrom(type) || type.isPrimitive() && !Boolean.TYPE.equals(type) + && !Character.TYPE.equals(type)) { + return size + " " + pluralize("number", size); + } + if (Collection.class.equals(type)) { + return size + " " + pluralize("collection", size); + } + if (Map.class.equals(type)) { + return size + " " + pluralize("map", size); + } + return size + " " + type.getSimpleName() + " " + pluralize("item", size); + } + + private String renderItemCount(final int size) { + return size + " " + pluralize("item", size); + } + + private String renderEntryCount(final int size) { + return size + " " + pluralize("entry", size); + } + + private String renderByteCount(final int size) { + return size + " " + pluralize("byte", size); + } + + private String pluralize(final String word, final int count) { + return count == 1 ? word : word + "s"; + } + private String renderObjectArray(final Object array, final int length) { final List values = new ArrayList<>(); for (int i = 0; i < length; i++) { diff --git a/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java b/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java index f2d37e2bb..1567e7e11 100644 --- a/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java +++ b/allure-assertj/src/test/java/io/qameta/allure/assertj/AllureAspectJTest.java @@ -15,7 +15,9 @@ */ package io.qameta.allure.assertj; +import io.qameta.allure.model.Parameter; import io.qameta.allure.model.Status; +import io.qameta.allure.model.StatusDetails; import io.qameta.allure.model.StepResult; import io.qameta.allure.model.TestResult; import io.qameta.allure.test.AllureFeatures; @@ -55,7 +57,11 @@ void shouldCreateSemanticChainForScalarAssert() { assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) - .containsExactly("hasSize(4)"); + .containsExactly("has size 4"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .flatExtracting(StepResult::getParameters) + .isEmpty(); } @AllureFeatures.Steps @@ -74,7 +80,7 @@ void shouldUseAssertDescriptionAsChainName() { assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) - .containsExactly("as(\"Nullable object\")", "isNull()"); + .containsExactly("described as \"Nullable object\"", "is null"); } @AllureFeatures.Steps @@ -94,7 +100,7 @@ void shouldRenderByteArraysWithoutPayload() { assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) - .containsExactly("as(\"Byte array object\")", "isEqualTo()"); + .containsExactly("described as \"Byte array object\"", "is equal to "); } @AllureFeatures.Steps @@ -108,11 +114,122 @@ void shouldRenderCollectionsAsSubjectsAndExpectedValuesAsValues() { final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) .extracting(StepResult::getName) - .containsExactly("assert Collection(size=2)"); + .containsExactly("assert [\"a\", \"b\"]"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("contains exactly [\"a\", \"b\"]"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .flatExtracting(StepResult::getParameters) + .isEmpty(); + } + + @AllureFeatures.Steps + @Test + void shouldRenderSmallArraysAsValues() { + final AllureResults results = runWithinTestContext(() -> { + assertThat(new int[]{1, 2}) + .containsExactly(1, 2); + + assertThat(new String[]{"alpha", "bravo"}) + .containsExactly("alpha", "bravo"); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("assert [1, 2]", "assert [\"alpha\", \"bravo\"]"); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) - .containsExactly("containsExactly([\"a\", \"b\"])"); + .containsExactly("contains exactly [1, 2]", "contains exactly [\"alpha\", \"bravo\"]"); + } + + @AllureFeatures.Steps + @Test + void shouldRenderTuplesAsValues() { + final AllureResults results = runWithinTestContext(() -> { + assertThat(Arrays.asList( + tuple("first", Status.PASSED), + tuple("second", Status.FAILED) + )) + .containsExactly( + tuple("first", Status.PASSED), + tuple("second", Status.FAILED) + ); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("assert [(\"first\", PASSED), (\"second\", FAILED)]"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("contains exactly [(\"first\", PASSED), (\"second\", FAILED)]"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .flatExtracting(StepResult::getParameters) + .isEmpty(); + } + + @AllureFeatures.Steps + @Test + void shouldRenderFieldOrPropertyValueAssertions() { + final StatusDetails details = new StatusDetails() + .setMessage("Make the test failed"); + + final AllureResults results = runWithinTestContext(() -> { + assertThat(details) + .hasFieldOrPropertyWithValue("message", "Make the test failed"); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .containsExactly("assert StatusDetails"); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .containsExactly("has field or property \"message\" with value \"Make the test failed\""); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .flatExtracting(StepResult::getParameters) + .isEmpty(); + } + + @AllureFeatures.Steps + @Test + void shouldTruncateLongStepNamesAndAddOnlyTruncatedValuesAsParameters() { + final String value = String.join("", Collections.nCopies(1200, "a")); + + final AllureResults results = runWithinTestContext(() -> { + assertThat(value) + .isEqualTo(value); + }, AllureAspectJ::setLifecycle); + + final TestResult result = assertOnlyOneResult(results); + assertThat(result.getSteps()) + .extracting(StepResult::getName) + .singleElement() + .asString() + .hasSize(1000) + .startsWith("assert \"") + .endsWith("..."); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .extracting(StepResult::getName) + .singleElement() + .asString() + .hasSize(1000) + .startsWith("is equal to \"") + .endsWith("..."); + assertThat(result.getSteps()) + .flatExtracting(StepResult::getSteps) + .flatExtracting(StepResult::getParameters) + .extracting(Parameter::getName, Parameter::getValue) + .containsExactly(tuple("expected", "\"" + value + "\"")); } @AllureFeatures.Steps @@ -137,17 +254,17 @@ void shouldCreateSeparateChainsForMultipleAssertThatCalls() { .containsExactly( tuple("assert \"Data\"", Status.PASSED), tuple("assert 42", Status.PASSED), - tuple("assert Collection(size=2)", Status.PASSED) + tuple("assert [\"a\", \"b\"]", Status.PASSED) ); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) .containsExactly( - "hasSize(4)", - "isPositive()", - "isEqualTo(42)", - "hasSize(2)", - "contains(\"a\")" + "has size 4", + "is positive", + "is equal to 42", + "has size 2", + "contains \"a\"" ); } @@ -176,12 +293,12 @@ void shouldAttachOperationsToStoredAssertionInstances() { .filteredOn("name", "assert \"alpha\"") .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) - .containsExactly("isEqualTo(\"alpha\")"); + .containsExactly("is equal to \"alpha\""); assertThat(result.getSteps()) .filteredOn("name", "assert \"bravo\"") .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) - .containsExactly("isEqualTo(\"bravo\")"); + .containsExactly("is equal to \"bravo\""); } @AllureFeatures.Steps @@ -201,11 +318,11 @@ void shouldAvoidVerboseModelToStringPayloads() { final TestResult result = assertOnlyOneResult(results); assertThat(result.getSteps()) .extracting(StepResult::getName) - .containsExactly("assert Collection(size=1)"); + .containsExactly("assert 1 TestResult item"); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) - .containsExactly("hasSize(1)", "containsExactly([TestResult])"); + .containsExactly("has size 1", "contains exactly [TestResult]"); assertThat(result.getSteps()) .extracting(StepResult::getName) .noneMatch(name -> name.contains("fullName=")) @@ -252,16 +369,16 @@ void shouldKeepNavigationInsideTheSameChain() { .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) .containsExactly( - "extracting() -> Collection(size=1)", - "containsExactly([\"my.company.Test.testOne\"])", - "first(InstanceOfAssertFactory) -> \"alpha\"", - "startsWith(\"al\")", - "singleElement(InstanceOfAssertFactory) -> \"bravo\"", - "endsWith(\"vo\")", - "asInstanceOf(InstanceOfAssertFactory) -> \"charlie\"", - "contains(\"har\")", - "flatExtracting() -> Collection(size=1)", - "containsExactly([\"delta\"])" + "extracts -> [\"my.company.Test.testOne\"]", + "contains exactly [\"my.company.Test.testOne\"]", + "first element as InstanceOfAssertFactory -> \"alpha\"", + "starts with \"al\"", + "single element as InstanceOfAssertFactory -> \"bravo\"", + "ends with \"vo\"", + "as instance of InstanceOfAssertFactory -> \"charlie\"", + "contains \"har\"", + "flat extracts -> [\"delta\"]", + "contains exactly [\"delta\"]" ); } @@ -282,8 +399,8 @@ void shouldRenderSerializedLambdaMethodReferences() { .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) .containsExactly( - "extracting(TestResult::getFullName) -> Collection(size=1)", - "containsExactly([\"my.company.Test.testOne\"])" + "extracts TestResult::getFullName -> [\"my.company.Test.testOne\"]", + "contains exactly [\"my.company.Test.testOne\"]" ); } @@ -302,10 +419,10 @@ void shouldMarkTheFailedHardAssertionOperation() { assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName, StepResult::getStatus) - .containsExactly(tuple("hasSize(5)", Status.FAILED)); + .containsExactly(tuple("has size 5", Status.FAILED)); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) - .filteredOn("name", "hasSize(5)") + .filteredOn("name", "has size 5") .extracting(step -> step.getStatusDetails().getMessage()) .singleElement() .asString() @@ -331,12 +448,12 @@ void shouldMarkTheFailedSoftAssertionOperationBeforeAssertAll() { .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName, StepResult::getStatus) .containsExactly( - tuple("as(\"Age\")", Status.PASSED), - tuple("isEqualTo(26)", Status.FAILED) + tuple("described as \"Age\"", Status.PASSED), + tuple("is equal to 26", Status.FAILED) ); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) - .filteredOn("name", "isEqualTo(26)") + .filteredOn("name", "is equal to 26") .extracting(step -> step.getStatusDetails().getMessage()) .singleElement() .asString() @@ -360,20 +477,20 @@ void shouldAttachNestedAssertionsUnderCallbackOperations() { assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) - .containsExactly("satisfies()"); + .containsExactly("satisfies "); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) - .filteredOn("name", "satisfies()") + .filteredOn("name", "satisfies ") .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) .containsExactly("assert \"alpha\""); assertThat(result.getSteps()) .flatExtracting(StepResult::getSteps) - .filteredOn("name", "satisfies()") + .filteredOn("name", "satisfies ") .flatExtracting(StepResult::getSteps) .flatExtracting(StepResult::getSteps) .extracting(StepResult::getName) - .containsExactly("startsWith(\"al\")", "endsWith(\"ha\")"); + .containsExactly("starts with \"al\"", "ends with \"ha\""); } private TestResult assertOnlyOneResult(final AllureResults results) {