diff --git a/allure-java-commons/src/main/java/io/qameta/allure/util/NamingUtils.java b/allure-java-commons/src/main/java/io/qameta/allure/util/NamingUtils.java index 6946aca2..1c854f44 100644 --- a/allure-java-commons/src/main/java/io/qameta/allure/util/NamingUtils.java +++ b/allure-java-commons/src/main/java/io/qameta/allure/util/NamingUtils.java @@ -18,7 +18,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.lang.reflect.Field; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -94,42 +93,12 @@ private static String extractProperties(final Object object, final String[] part } private static Object extractChild(final Object object, final String part) { - final Class type = object == null ? Object.class : object.getClass(); + Objects.requireNonNull(object, "object"); + final Class type = object.getClass(); try { - return extractField(object, part, type); + return ReflectionUtils.getFieldValue(object, part); } catch (ReflectiveOperationException e) { throw new IllegalStateException("Unable to extract " + part + " value from " + type.getName(), e); } } - - @SuppressWarnings("PMD.EmptyCatchBlock") - private static Object extractField(final Object object, final String part, final Class type) - throws ReflectiveOperationException { - try { - final Field field = type.getField(part); - return fieldValue(object, field); - } catch (NoSuchFieldException e) { - Class t = type; - while (t != null) { - try { - final Field declaredField = t.getDeclaredField(part); - return fieldValue(object, declaredField); - } catch (NoSuchFieldException ignore) { - // Ignore - } - t = t.getSuperclass(); - } - throw e; - } - } - - @SuppressWarnings("PMD.AvoidAccessibilityAlteration") - private static Object fieldValue(final Object object, final Field field) throws IllegalAccessException { - try { - return field.get(object); - } catch (IllegalAccessException e) { - field.setAccessible(true); - return field.get(object); - } - } } diff --git a/allure-java-commons/src/main/java/io/qameta/allure/util/ReflectionUtils.java b/allure-java-commons/src/main/java/io/qameta/allure/util/ReflectionUtils.java new file mode 100644 index 00000000..ec229664 --- /dev/null +++ b/allure-java-commons/src/main/java/io/qameta/allure/util/ReflectionUtils.java @@ -0,0 +1,181 @@ +/* + * 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.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Optional; + +/** + * Common reflection helpers. + */ +final class ReflectionUtils { + + private static final Logger LOGGER = LoggerFactory.getLogger(ReflectionUtils.class); + private static final String GET_PREFIX = "get"; + private static final String IS_PREFIX = "is"; + private static final String METHOD_READ_ERROR = "Unable to read value via method {}"; + private static final String FIELD_READ_ERROR = "Unable to read value via field {}"; + + private ReflectionUtils() { + throw new IllegalStateException("Do not instance"); + } + + static Object getValue(final Object target, final String propertyName) { + if (target == null || propertyName == null || propertyName.isEmpty()) { + return null; + } + final Optional method = findGetter(target.getClass(), propertyName); + if (method.isPresent()) { + try { + return invokeMethod(target, method.get()); + } catch (ReflectiveOperationException | SecurityException e) { + LOGGER.trace(METHOD_READ_ERROR, method.get().getName(), e); + return null; + } + } + try { + return getFieldValue(target, propertyName); + } catch (ReflectiveOperationException | SecurityException e) { + LOGGER.trace(FIELD_READ_ERROR, propertyName, e); + return null; + } + } + + static Boolean getBooleanValue(final Object target, final String propertyName) { + if (target == null || propertyName == null || propertyName.isEmpty()) { + return absentBooleanValue(); + } + final Optional method = findPrimitiveBooleanGetter(target.getClass(), propertyName); + if (method.isPresent()) { + return invokeBooleanMethod(target, method.get()); + } + return toBooleanValue(getValue(target, propertyName), propertyName); + } + + @SuppressWarnings("PMD.EmptyCatchBlock") + static Object getFieldValue(final Object target, final String fieldName) throws ReflectiveOperationException { + final Class type = target.getClass(); + try { + final Field field = type.getField(fieldName); + return getFieldValue(target, field); + } catch (NoSuchFieldException e) { + Class currentType = type; + while (currentType != null) { + try { + final Field declaredField = currentType.getDeclaredField(fieldName); + return getFieldValue(target, declaredField); + } catch (NoSuchFieldException ignore) { + // Ignore + } + currentType = currentType.getSuperclass(); + } + throw e; + } + } + + @SuppressWarnings("PMD.AvoidAccessibilityAlteration") + private static Object getFieldValue(final Object target, final Field field) throws IllegalAccessException { + try { + return field.get(target); + } catch (IllegalAccessException e) { + field.setAccessible(true); + return field.get(target); + } + } + + @SuppressWarnings("PMD.AvoidAccessibilityAlteration") + private static Object invokeMethod(final Object target, final Method method) throws ReflectiveOperationException { + try { + return method.invoke(target); + } catch (IllegalAccessException e) { + method.setAccessible(true); + return method.invoke(target); + } + } + + private static Optional findGetter(final Class type, final String propertyName) { + final String capitalizedName = capitalize(propertyName); + final String[] methodNames = {GET_PREFIX + capitalizedName, propertyName}; + Class currentType = type; + while (currentType != null) { + for (String methodName : methodNames) { + try { + return Optional.of(currentType.getDeclaredMethod(methodName)); + } catch (NoSuchMethodException ignored) { + // Ignore + } catch (SecurityException e) { + LOGGER.trace(METHOD_READ_ERROR, methodName, e); + return Optional.empty(); + } + } + currentType = currentType.getSuperclass(); + } + return Optional.empty(); + } + + private static Optional findPrimitiveBooleanGetter(final Class type, final String propertyName) { + final String methodName = IS_PREFIX + capitalize(propertyName); + Class currentType = type; + while (currentType != null) { + try { + final Method method = currentType.getDeclaredMethod(methodName); + return method.getReturnType() == boolean.class ? Optional.of(method) : Optional.empty(); + } catch (NoSuchMethodException ignored) { + // Ignore + } catch (SecurityException e) { + LOGGER.trace(METHOD_READ_ERROR, methodName, e); + return Optional.empty(); + } + currentType = currentType.getSuperclass(); + } + return Optional.empty(); + } + + private static String capitalize(final String value) { + return Character.toUpperCase(value.charAt(0)) + value.substring(1); + } + + private static Boolean invokeBooleanMethod(final Object target, final Method method) { + try { + return Boolean.class.cast(invokeMethod(target, method)); + } catch (ReflectiveOperationException | SecurityException e) { + LOGGER.trace(METHOD_READ_ERROR, method.getName(), e); + return absentBooleanValue(); + } + } + + private static Boolean toBooleanValue(final Object value, final String propertyName) { + if (value instanceof Boolean) { + return (Boolean) value; + } + if (value != null) { + LOGGER.trace( + "Unable to read boolean value {}: actual value type is {}", + propertyName, + value.getClass().getName() + ); + } + return absentBooleanValue(); + } + + private static Boolean absentBooleanValue() { + return Optional.empty().orElse(null); + } +} 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 9e8b28ed..03a08b0e 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 @@ -105,6 +105,9 @@ public final class ResultsUtils { private static final String ALLURE_DESCRIPTIONS_FOLDER = "META-INF/allureDescriptions/"; private static final String MD_5 = "MD5"; private static final String DOT = "."; + private static final String ACTUAL = "actual"; + private static final String EXPECTED = "expected"; + private static final String DEFINED_PROPERTY_SUFFIX = "Defined"; private static String cachedHost; @@ -335,13 +338,17 @@ public static Optional getStatus(final Throwable throwable) { public static Optional getStatusDetails(final Throwable e) { return Optional.ofNullable(e) - .map(throwable -> new StatusDetails() - .setMessage(Optional - .ofNullable(throwable.getMessage()) - .orElse(throwable.getClass().getName()) - ) - .setTrace(getStackTraceAsString(throwable)) - ); + .map(throwable -> { + final StatusDetails details = new StatusDetails() + .setMessage(Optional + .ofNullable(throwable.getMessage()) + .orElse(throwable.getClass().getName()) + ) + .setTrace(getStackTraceAsString(throwable)); + getRichErrorProperty(throwable, ACTUAL).ifPresent(details::setActual); + getRichErrorProperty(throwable, EXPECTED).ifPresent(details::setExpected); + return details; + }); } public static Optional getJavadocDescription(final ClassLoader classLoader, @@ -440,6 +447,20 @@ private static String getStackTraceAsString(final Throwable throwable) { return stringWriter.toString(); } + private static Optional getRichErrorProperty(final Throwable throwable, final String propertyName) { + final Boolean propertyDefined = ReflectionUtils.getBooleanValue( + throwable, + propertyName + DEFINED_PROPERTY_SUFFIX + ); + // Some assertion libraries expose isActualDefined/isExpectedDefined to distinguish absent values + // from values that are present but null. + if (Boolean.FALSE.equals(propertyDefined)) { + return Optional.empty(); + } + final Object value = ReflectionUtils.getValue(throwable, propertyName); + return Optional.ofNullable(value).map(ObjectUtils::toString); + } + public static void processDescription(final ClassLoader classLoader, final Method method, final Consumer setDescription, diff --git a/allure-java-commons/src/test/java/io/qameta/allure/FileSystemResultsWriterTest.java b/allure-java-commons/src/test/java/io/qameta/allure/FileSystemResultsWriterTest.java index 4433340f..7086876b 100644 --- a/allure-java-commons/src/test/java/io/qameta/allure/FileSystemResultsWriterTest.java +++ b/allure-java-commons/src/test/java/io/qameta/allure/FileSystemResultsWriterTest.java @@ -15,6 +15,7 @@ */ package io.qameta.allure; +import io.qameta.allure.model.StatusDetails; import io.qameta.allure.model.TestResult; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -73,6 +74,24 @@ void shouldWriteTitlePath(@TempDir final Path folder) throws IOException { .contains("\"child\""); } + @Test + void shouldWriteStatusDetailsActualAndExpected(@TempDir final Path folder) throws IOException { + FileSystemResultsWriter writer = new FileSystemResultsWriter(folder); + final String uuid = UUID.randomUUID().toString(); + final TestResult testResult = new TestResult() + .setUuid(uuid) + .setStatusDetails(new StatusDetails() + .setActual("actual value") + .setExpected("expected value")); + + writer.write(testResult); + + final String payload = Files.readString(folder.resolve(generateTestResultName(uuid))); + assertThat(payload) + .contains("\"actual\":\"actual value\"") + .contains("\"expected\":\"expected value\""); + } + @Test void shouldPreserveOldResultsWhenCleanIsDisabled(@TempDir final Path folder) throws IOException { Path existingFile = folder.resolve("existing-result.json"); 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 a6ee55ed..7576938e 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 @@ -17,12 +17,15 @@ import io.github.glytching.junit.extension.system.SystemProperty; import io.github.glytching.junit.extension.system.SystemPropertyExtension; +import io.qameta.allure.model.StatusDetails; import io.qameta.allure.util.ResultsUtils; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.opentest4j.AssertionFailedError; +import org.opentest4j.ValueWrapper; import java.io.Serializable; import java.lang.annotation.Annotation; @@ -240,6 +243,108 @@ public void shouldCreateLink(final String value, } } + @Test + void shouldExtractActualAndExpectedFromJunit4LikeComparisonFailure() { + final Junit4LikeComparisonFailure error = new Junit4LikeComparisonFailure( + "values differ", + "expected value", + "actual value" + ); + + final StatusDetails details = ResultsUtils.getStatusDetails(error).get(); + + assertThat(details.getActual()).isEqualTo("actual value"); + assertThat(details.getExpected()).isEqualTo("expected value"); + } + + @Test + void shouldExtractActualAndExpectedFromOpenTest4jAssertionFailedError() { + final AssertionFailedError error = new AssertionFailedError( + "values differ", + "expected value", + "actual value" + ); + + final StatusDetails details = ResultsUtils.getStatusDetails(error).get(); + + assertThat(details.getActual()).startsWith("actual value ("); + assertThat(details.getExpected()).startsWith("expected value ("); + } + + @Test + void shouldUseOpenTest4jValueWrapperToString() { + final AssertionFailedError error = new AssertionFailedError( + "values differ", + ValueWrapper.create("expected value", "expected representation"), + ValueWrapper.create("actual value", "actual representation") + ); + + final StatusDetails details = ResultsUtils.getStatusDetails(error).get(); + + assertThat(details.getActual()).startsWith("actual representation ("); + assertThat(details.getExpected()).startsWith("expected representation ("); + } + + @Test + void shouldPreserveOpenTest4jNullValues() { + final AssertionFailedError error = new AssertionFailedError("values differ", null, null); + + final StatusDetails details = ResultsUtils.getStatusDetails(error).get(); + + assertThat(details.getActual()).isEqualTo("null"); + assertThat(details.getExpected()).isEqualTo("null"); + } + + @Test + void shouldSkipUndefinedOpenTest4jValues() { + final AssertionFailedError error = new AssertionFailedError("values differ"); + + final StatusDetails details = ResultsUtils.getStatusDetails(error).get(); + + assertThat(details.getActual()).isNull(); + assertThat(details.getExpected()).isNull(); + } + + @Test + void shouldExtractActualAndExpectedFromGenericAssertionError() { + final StatusDetails details = ResultsUtils.getStatusDetails(new GenericRichAssertionError()).get(); + + assertThat(details.getActual()).isEqualTo("[1, 2]"); + assertThat(details.getExpected()).isEqualTo("expected value"); + } + + @Test + void shouldExtractActualAndExpectedFromFieldAssertionError() { + final StatusDetails details = ResultsUtils.getStatusDetails(new FieldRichAssertionError()).get(); + + assertThat(details.getActual()).isEqualTo("actual value"); + assertThat(details.getExpected()).isEqualTo("expected value"); + } + + @Test + void shouldExtractActualAndExpectedFromRecordStyleAssertionError() { + final StatusDetails details = ResultsUtils.getStatusDetails(new RecordStyleRichAssertionError()).get(); + + assertThat(details.getActual()).isEqualTo("actual value"); + assertThat(details.getExpected()).isEqualTo("expected value"); + } + + @Test + void shouldSkipNullActualAndUnavailableExpected() { + final StatusDetails details = ResultsUtils.getStatusDetails(new PartiallyAvailableAssertionError()).get(); + + assertThat(details.getActual()).isNull(); + assertThat(details.getExpected()).isNull(); + } + + @Test + void shouldRespectGenericDefinedFlags() { + final StatusDetails details = ResultsUtils.getStatusDetails(new UndefinedActualAssertionError()).get(); + + assertThat(details.getActual()).isNull(); + assertThat(details.getExpected()).isEqualTo("expected value"); + } + public void clearSystemProperty(final String type, final String sysProp) { if (Objects.nonNull(type) && Objects.nonNull(sysProp)) { System.clearProperty(getLinkTypePatternPropertyName(type)); @@ -262,4 +367,96 @@ String getName() { return name; } } + + public static class Junit4LikeComparisonFailure extends AssertionError { + + private static final long serialVersionUID = 1L; + + private final String expected; + private final String actual; + + public Junit4LikeComparisonFailure(final String message, + final String expected, + final String actual) { + super(message); + this.expected = expected; + this.actual = actual; + } + + public String getExpected() { + return expected; + } + + public String getActual() { + return actual; + } + } + + public static class GenericRichAssertionError extends AssertionError { + + private static final long serialVersionUID = 1L; + + public Object getActual() { + return new int[]{1, 2}; + } + + public Object getExpected() { + return "expected value"; + } + } + + public static class PartiallyAvailableAssertionError extends AssertionError { + + private static final long serialVersionUID = 1L; + + public Object getActual() { + return null; + } + + public Object getExpected() { + throw new IllegalStateException("not available"); + } + } + + public static class FieldRichAssertionError extends AssertionError { + + private static final long serialVersionUID = 1L; + + private final Object actual = "actual value"; + private final Object expected = "expected value"; + } + + public static class RecordStyleRichAssertionError extends AssertionError { + + private static final long serialVersionUID = 1L; + + public Object actual() { + return "actual value"; + } + + public Object expected() { + return "expected value"; + } + } + + public static class UndefinedActualAssertionError extends AssertionError { + + private static final long serialVersionUID = 1L; + + public boolean isActualDefined() { + return false; + } + + public Object getActual() { + return "actual value"; + } + + public boolean isExpectedDefined() { + return true; + } + + public Object getExpected() { + return "expected value"; + } + } } diff --git a/allure-java-commons/src/test/java/io/qameta/allure/util/ReflectionUtilsTest.java b/allure-java-commons/src/test/java/io/qameta/allure/util/ReflectionUtilsTest.java new file mode 100644 index 00000000..d44db617 --- /dev/null +++ b/allure-java-commons/src/test/java/io/qameta/allure/util/ReflectionUtilsTest.java @@ -0,0 +1,180 @@ +/* + * 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.util; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ReflectionUtilsTest { + + @Test + void shouldReadValueFromGetter() { + final Object value = ReflectionUtils.getValue(new GetterSample(), "value"); + + assertThat(value).isEqualTo("getter value"); + } + + @Test + void shouldPreferGetterOverField() { + final Object value = ReflectionUtils.getValue(new GetterAndFieldSample(), "value"); + + assertThat(value).isEqualTo("getter value"); + } + + @Test + void shouldReadValueFromRecordStyleMethod() { + final Object value = ReflectionUtils.getValue(new RecordStyleSample(), "value"); + + assertThat(value).isEqualTo("record value"); + } + + @Test + void shouldReadValueFromField() { + final Object value = ReflectionUtils.getValue(new FieldSample(), "value"); + + assertThat(value).isEqualTo("field value"); + } + + @Test + void shouldReadValueFromInheritedField() { + final Object value = ReflectionUtils.getValue(new ChildFieldSample(), "value"); + + assertThat(value).isEqualTo("parent field value"); + } + + @Test + void shouldReturnNullForMissingValue() { + final Object value = ReflectionUtils.getValue(new GetterSample(), "missing"); + + assertThat(value).isNull(); + } + + @Test + void shouldReadBooleanValueFromPrimitiveIsGetter() { + final Boolean value = ReflectionUtils.getBooleanValue(new PrimitiveBooleanSample(), "value"); + + assertThat(value).isFalse(); + } + + @Test + void shouldPreferPrimitiveIsGetterForBooleanValue() { + final Boolean value = ReflectionUtils.getBooleanValue(new PrimitiveIsAndGetSample(), "value"); + + assertThat(value).isFalse(); + } + + @Test + void shouldReadBooleanValueFromGetter() { + final Boolean value = ReflectionUtils.getBooleanValue(new BooleanGetterSample(), "value"); + + assertThat(value).isTrue(); + } + + @Test + void shouldReadBooleanValueFromField() { + final Boolean value = ReflectionUtils.getBooleanValue(new BooleanFieldSample(), "value"); + + assertThat(value).isFalse(); + } + + @Test + void shouldIgnoreBoxedBooleanIsGetter() { + final Boolean value = ReflectionUtils.getBooleanValue(new BoxedBooleanIsSample(), "value"); + + assertThat(value).isNull(); + } + + @Test + void shouldReturnNullForNonBooleanValue() { + final Boolean value = ReflectionUtils.getBooleanValue(new GetterSample(), "value"); + + assertThat(value).isNull(); + } + + static class GetterSample { + + public String getValue() { + return "getter value"; + } + } + + static class GetterAndFieldSample { + + private final String value = "field value"; + + public String getValue() { + return "getter value"; + } + } + + static class RecordStyleSample { + + public String value() { + return "record value"; + } + } + + static class FieldSample { + + private final String value = "field value"; + } + + static class ParentFieldSample { + + private final String value = "parent field value"; + } + + static class ChildFieldSample extends ParentFieldSample { + } + + static class PrimitiveBooleanSample { + + public boolean isValue() { + return false; + } + } + + static class PrimitiveIsAndGetSample { + + public boolean isValue() { + return false; + } + + public Boolean getValue() { + return true; + } + } + + static class BooleanGetterSample { + + public Boolean getValue() { + return true; + } + } + + static class BooleanFieldSample { + + private final Boolean value = false; + } + + static class BoxedBooleanIsSample { + + public Boolean isValue() { + return true; + } + } +} diff --git a/allure-junit-platform/src/main/java/io/qameta/allure/junitplatform/AllureJunitPlatform.java b/allure-junit-platform/src/main/java/io/qameta/allure/junitplatform/AllureJunitPlatform.java index f33f45e5..a4eb3cc7 100644 --- a/allure-junit-platform/src/main/java/io/qameta/allure/junitplatform/AllureJunitPlatform.java +++ b/allure-junit-platform/src/main/java/io/qameta/allure/junitplatform/AllureJunitPlatform.java @@ -488,6 +488,10 @@ private void failFixture(final Map keyValue) { .ifPresent(fixtureResult.getStatusDetails()::setMessage); Optional.of(keyValue.get("trace")) .ifPresent(fixtureResult.getStatusDetails()::setTrace); + Optional.of(keyValue.get("actual")) + .ifPresent(fixtureResult.getStatusDetails()::setActual); + Optional.of(keyValue.get("expected")) + .ifPresent(fixtureResult.getStatusDetails()::setExpected); }); getLifecycle().stopFixture(uuid); } @@ -634,6 +638,14 @@ private void stopTestCase(final TestIdentifier testIdentifier, .map(StatusDetails::getTrace) .ifPresent(currentSd::setTrace); + Optional.of(statusDetails) + .map(StatusDetails::getActual) + .ifPresent(currentSd::setActual); + + Optional.of(statusDetails) + .map(StatusDetails::getExpected) + .ifPresent(currentSd::setExpected); + currentSd.setMuted(currentSd.isMuted() || statusDetails.isMuted()); currentSd.setFlaky(currentSd.isFlaky() || statusDetails.isFlaky()); currentSd.setKnown(currentSd.isKnown() || statusDetails.isKnown()); diff --git a/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/AllureJunitPlatformTest.java b/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/AllureJunitPlatformTest.java index 7e0b6e53..32ef1fd0 100644 --- a/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/AllureJunitPlatformTest.java +++ b/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/AllureJunitPlatformTest.java @@ -17,6 +17,7 @@ import io.github.glytching.junit.extension.system.SystemProperty; import io.qameta.allure.Issue; +import io.qameta.allure.junitplatform.features.ActualExpectedStatusDetailsTests; import io.qameta.allure.junitplatform.features.AllureIdAnnotationSupport; import io.qameta.allure.junitplatform.features.BrokenInAfterAllTests; import io.qameta.allure.junitplatform.features.BrokenInBeforeAllTests; @@ -169,6 +170,21 @@ void shouldProcessFailedTests() { } + @Test + @AllureFeatures.FailedTests + void shouldReportActualAndExpectedStatusDetails() { + final AllureResults results = runClasses(ActualExpectedStatusDetailsTests.class); + final TestResult testResult = results.getTestResults().stream() + .filter(result -> ("io.qameta.allure.junitplatform.features.ActualExpectedStatusDetailsTests" + + ".failingComparison").equals(result.getFullName())) + .findFirst() + .get(); + + assertThat(testResult.getStatus()).isEqualTo(Status.FAILED); + assertThat(testResult.getStatusDetails().getActual()).startsWith("actual value ("); + assertThat(testResult.getStatusDetails().getExpected()).startsWith("expected value ("); + } + @Test @AllureFeatures.BrokenTests void shouldProcessBrokenTests() { diff --git a/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/features/ActualExpectedStatusDetailsTests.java b/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/features/ActualExpectedStatusDetailsTests.java new file mode 100644 index 00000000..7f210cd9 --- /dev/null +++ b/allure-junit-platform/src/test/java/io/qameta/allure/junitplatform/features/ActualExpectedStatusDetailsTests.java @@ -0,0 +1,31 @@ +/* + * 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.junitplatform.features; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * @author baev (Dmitry Baev). + */ +public class ActualExpectedStatusDetailsTests { + + @Test + void failingComparison() { + assertEquals("expected value", "actual value"); + } +} diff --git a/allure-junit4/src/test/java/io/qameta/allure/junit4/AllureJunit4Test.java b/allure-junit4/src/test/java/io/qameta/allure/junit4/AllureJunit4Test.java index 31018cf6..02cb9cf7 100644 --- a/allure-junit4/src/test/java/io/qameta/allure/junit4/AllureJunit4Test.java +++ b/allure-junit4/src/test/java/io/qameta/allure/junit4/AllureJunit4Test.java @@ -16,6 +16,7 @@ package io.qameta.allure.junit4; import io.qameta.allure.Step; +import io.qameta.allure.junit4.samples.ActualExpectedStatusDetailsTest; import io.qameta.allure.junit4.samples.AssumptionFailedTest; import io.qameta.allure.junit4.samples.BrokenTest; import io.qameta.allure.junit4.samples.BrokenWithoutMessageTest; @@ -143,6 +144,24 @@ void shouldProcessFailedTest() { .containsExactly(Status.FAILED); } + @Test + @AllureFeatures.FailedTests + void shouldReportActualAndExpectedStatusDetails() { + final AllureResults results = runClasses(ActualExpectedStatusDetailsTest.class); + + assertThat(results.getTestResults()) + .filteredOn("fullName", + "io.qameta.allure.junit4.samples.ActualExpectedStatusDetailsTest.failingComparison") + .extracting( + TestResult::getStatus, + testResult -> testResult.getStatusDetails().getActual(), + testResult -> testResult.getStatusDetails().getExpected() + ) + .containsExactly( + tuple(Status.FAILED, "actual value", "expected value") + ); + } + @Test @AllureFeatures.BrokenTests void shouldProcessBrokenTest() { diff --git a/allure-junit4/src/test/java/io/qameta/allure/junit4/samples/ActualExpectedStatusDetailsTest.java b/allure-junit4/src/test/java/io/qameta/allure/junit4/samples/ActualExpectedStatusDetailsTest.java new file mode 100644 index 00000000..1fd1c4b0 --- /dev/null +++ b/allure-junit4/src/test/java/io/qameta/allure/junit4/samples/ActualExpectedStatusDetailsTest.java @@ -0,0 +1,31 @@ +/* + * 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.junit4.samples; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +/** + * @author baev (Dmitry Baev). + */ +public class ActualExpectedStatusDetailsTest { + + @Test + public void failingComparison() { + assertEquals("expected value", "actual value"); + } +} diff --git a/allure-jupiter/src/main/java/io/qameta/allure/jupiter/AllureJupiter.java b/allure-jupiter/src/main/java/io/qameta/allure/jupiter/AllureJupiter.java index 1641ed1f..f48a1de7 100644 --- a/allure-jupiter/src/main/java/io/qameta/allure/jupiter/AllureJupiter.java +++ b/allure-jupiter/src/main/java/io/qameta/allure/jupiter/AllureJupiter.java @@ -200,6 +200,8 @@ public Map buildFailureEvent(final String type, final Optional maybeDetails = ResultsUtils.getStatusDetails(throwable); maybeDetails.map(StatusDetails::getMessage).ifPresent(message -> map.put("message", message)); maybeDetails.map(StatusDetails::getTrace).ifPresent(trace -> map.put("trace", trace)); + maybeDetails.map(StatusDetails::getActual).ifPresent(actual -> map.put("actual", actual)); + maybeDetails.map(StatusDetails::getExpected).ifPresent(expected -> map.put("expected", expected)); return map; } diff --git a/allure-jupiter/src/test/java/io/qameta/allure/junit5/AllureJunit5Test.java b/allure-jupiter/src/test/java/io/qameta/allure/junit5/AllureJunit5Test.java index 66d102e2..a2ca022c 100644 --- a/allure-jupiter/src/test/java/io/qameta/allure/junit5/AllureJunit5Test.java +++ b/allure-jupiter/src/test/java/io/qameta/allure/junit5/AllureJunit5Test.java @@ -17,6 +17,7 @@ import io.qameta.allure.Issue; import io.qameta.allure.Step; +import io.qameta.allure.junit5.features.ActualExpectedStatusDetailsTests; import io.qameta.allure.junit5.features.AfterEachFixtureBrokenSupport; import io.qameta.allure.junit5.features.AllFixtureSupport; import io.qameta.allure.junit5.features.BeforeAllFixtureFailureSupport; @@ -148,6 +149,20 @@ void shouldSupportParamAnnotationForParameters() { } + @Test + void shouldReportActualAndExpectedStatusDetails() { + final AllureResults results = runClasses(ActualExpectedStatusDetailsTests.class); + final TestResult testResult = results.getTestResults().stream() + .filter(result -> "io.qameta.allure.junit5.features.ActualExpectedStatusDetailsTests.failingComparison" + .equals(result.getFullName())) + .findFirst() + .get(); + + assertThat(testResult.getStatus()).isEqualTo(Status.FAILED); + assertThat(testResult.getStatusDetails().getActual()).startsWith("actual value ("); + assertThat(testResult.getStatusDetails().getExpected()).startsWith("expected value ("); + } + @Test void shouldSkipReportingOfTestInjectablesTestReporterForRegularTest() { final AllureResults results = runClasses(SkipOtherInjectables.class); diff --git a/allure-jupiter/src/test/java/io/qameta/allure/junit5/features/ActualExpectedStatusDetailsTests.java b/allure-jupiter/src/test/java/io/qameta/allure/junit5/features/ActualExpectedStatusDetailsTests.java new file mode 100644 index 00000000..7ce27061 --- /dev/null +++ b/allure-jupiter/src/test/java/io/qameta/allure/junit5/features/ActualExpectedStatusDetailsTests.java @@ -0,0 +1,31 @@ +/* + * 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.junit5.features; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * @author baev (Dmitry Baev). + */ +public class ActualExpectedStatusDetailsTests { + + @Test + void failingComparison() { + assertEquals("expected value", "actual value"); + } +} diff --git a/allure-model/src/main/java/io/qameta/allure/model/StatusDetails.java b/allure-model/src/main/java/io/qameta/allure/model/StatusDetails.java index 10ca653f..38be0caa 100644 --- a/allure-model/src/main/java/io/qameta/allure/model/StatusDetails.java +++ b/allure-model/src/main/java/io/qameta/allure/model/StatusDetails.java @@ -34,6 +34,8 @@ public class StatusDetails implements Serializable { private boolean flaky; private String message; private String trace; + private String actual; + private String expected; /** * Is known boolean. @@ -135,6 +137,46 @@ public StatusDetails setTrace(final String value) { return this; } + /** + * Gets actual. + * + * @return the actual + */ + public String getActual() { + return actual; + } + + /** + * Sets actual. + * + * @param value the value + * @return self for method chaining + */ + public StatusDetails setActual(final String value) { + this.actual = value; + return this; + } + + /** + * Gets expected. + * + * @return the expected + */ + public String getExpected() { + return expected; + } + + /** + * Sets expected. + * + * @param value the value + * @return self for method chaining + */ + public StatusDetails setExpected(final String value) { + this.expected = value; + return this; + } + /** * {@inheritDoc} */ @@ -148,7 +190,9 @@ public boolean equals(final Object o) { } final StatusDetails that = (StatusDetails) o; return Objects.equals(message, that.message) - && Objects.equals(trace, that.trace); + && Objects.equals(trace, that.trace) + && Objects.equals(actual, that.actual) + && Objects.equals(expected, that.expected); } /** @@ -156,6 +200,6 @@ public boolean equals(final Object o) { */ @Override public int hashCode() { - return Objects.hash(message, trace); + return Objects.hash(message, trace, actual, expected); } } diff --git a/allure-spock2/src/test/groovy/io/qameta/allure/spock2/AllureSpock2Test.java b/allure-spock2/src/test/groovy/io/qameta/allure/spock2/AllureSpock2Test.java index 3771a655..24f0b27c 100644 --- a/allure-spock2/src/test/groovy/io/qameta/allure/spock2/AllureSpock2Test.java +++ b/allure-spock2/src/test/groovy/io/qameta/allure/spock2/AllureSpock2Test.java @@ -26,6 +26,7 @@ import io.qameta.allure.model.StepResult; import io.qameta.allure.model.TestResult; import io.qameta.allure.model.TestResultContainer; +import io.qameta.allure.spock2.samples.ActualExpectedStatusDetailsTest; import io.qameta.allure.spock2.samples.BrokenTest; import io.qameta.allure.spock2.samples.DataDrivenTest; import io.qameta.allure.spock2.samples.DerivedSpec; @@ -245,6 +246,17 @@ void shouldProcessFailedTest() { .containsExactly(Status.FAILED); } + @Test + void shouldReportActualAndExpectedStatusDetails() { + final AllureResults results = runClasses(ActualExpectedStatusDetailsTest.class); + final TestResult testResult = results.getTestResults().get(0); + + assertThat(results.getTestResults()).hasSize(1); + assertThat(testResult.getStatus()).isEqualTo(Status.FAILED); + assertThat(testResult.getStatusDetails().getActual()).startsWith("actual value\n ("); + assertThat(testResult.getStatusDetails().getExpected()).startsWith("expected value\n ("); + } + @Test void shouldProcessBrokenTest() { final AllureResults results = runClasses(BrokenTest.class); diff --git a/allure-spock2/src/test/groovy/io/qameta/allure/spock2/samples/ActualExpectedStatusDetailsTest.groovy b/allure-spock2/src/test/groovy/io/qameta/allure/spock2/samples/ActualExpectedStatusDetailsTest.groovy new file mode 100644 index 00000000..cab9f362 --- /dev/null +++ b/allure-spock2/src/test/groovy/io/qameta/allure/spock2/samples/ActualExpectedStatusDetailsTest.groovy @@ -0,0 +1,29 @@ +/* + * 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.spock2.samples + +import spock.lang.Specification + +/** + * @author baev (Dmitry Baev). + */ +class ActualExpectedStatusDetailsTest extends Specification { + + def "failing comparison"() { + expect: + "actual value" == "expected value" + } +} diff --git a/allure-testng/src/main/java/io/qameta/allure/testng/AllureTestNg.java b/allure-testng/src/main/java/io/qameta/allure/testng/AllureTestNg.java index d49ebe23..2569828a 100644 --- a/allure-testng/src/main/java/io/qameta/allure/testng/AllureTestNg.java +++ b/allure-testng/src/main/java/io/qameta/allure/testng/AllureTestNg.java @@ -913,6 +913,8 @@ private Consumer setStatus(final Status status, final StatusDetails if (nonNull(details)) { result.getStatusDetails().setTrace(details.getTrace()); result.getStatusDetails().setMessage(details.getMessage()); + result.getStatusDetails().setActual(details.getActual()); + result.getStatusDetails().setExpected(details.getExpected()); } }; }