Skip to content

Add suppressedExceptions() navigation method to Throwable assertions#4194

Draft
scordio wants to merge 1 commit into3.xfrom
gh-4183-suppressed-exceptions-assert
Draft

Add suppressedExceptions() navigation method to Throwable assertions#4194
scordio wants to merge 1 commit into3.xfrom
gh-4183-suppressed-exceptions-assert

Conversation

@scordio
Copy link
Copy Markdown
Member

@scordio scordio commented Mar 21, 2026

Originally inspired by 013b35a.


This change also demonstrates the new architecture I'd like to target for all assertion classes, which generally provides more user-friendly class names, a smaller public API surface, and less boilerplate.

Pros

Simpler class names in the public API

The class name exposed to end users no longer includes the "Abstract" prefix (in this case, SuppressedExceptionsAssert), which improves the readability of metadata displayed in editors such as IntelliJ IDEA.

For example, the changes in this PR are shown as:

image

while the changes from 013b35a are shown as:

image

Smaller Public Surface and Controlled Instantiation

Only the abstract contract is public, and it is the sole extension point third-party authors should consider. On the other side, the concrete implementation is private (in this case, SuppressedExceptionsAssert.Default), which means less surface to keep stable and more freedom for future refactoring.

In addition, a single factory method (in this case, SuppressedExceptionsAssert#from(...)) centralizes the instantiation logic and ensures correct state propagation (e.g., .withAssertionState(sourceAssert)). While this factory method is currently package-private, we might consider promoting it to public if a concrete use case arises.

Cons

  • Code that reflectively instantiates existing concrete classes by name won't work anymore. However, I don't expect this to be a common use case for AssertJ users.
  • assertj-generator might have increased complexity when dealing with this pattern. However, the assertThat public entry points should always be preferred for obtaining assertion instances.

@scordio scordio added this to the 3.28.0 milestone Mar 21, 2026
@scordio scordio changed the title Add navigation methods for suppressed exceptions assertions Add suppressedExceptions() navigation method to Throwable assertions Mar 21, 2026
@scordio scordio force-pushed the gh-4183-suppressed-exceptions-assert branch from 3db8861 to 1eba1eb Compare March 21, 2026 11:34
@testlens-app

This comment has been minimized.

Comment thread assertj-core/src/main/java/org/assertj/core/api/SuppressedExceptionsAssert.java Outdated
@filiphr
Copy link
Copy Markdown
Contributor

filiphr commented Mar 26, 2026

I don't have anything special to say regarding the navigation. However, I have one question regarding:

Only the abstract contract is public, and it is the sole extension point third-party authors should consider. On the other side, the concrete implementation is private (in this case, SuppressedExceptionsAssert.Default), which means less surface to keep stable and more freedom for future refactoring.

This makes a lot of sense. However, how does this affect the existing AbstractXXXAssert / XXXAssert which are returned from the Assertions? Is the goal that we return the more specific one in the Assertions return?

@scordio
Copy link
Copy Markdown
Member Author

scordio commented Mar 26, 2026

Is the goal that we return the more specific one in the Assertions return?

From a signature perspective, only the abstract one would be visible to end users (after being renamed to XXXAssert).

The new suppressedExceptions method in this PR mimics the pattern that all assertThat methods would have.

For example, today we have:

public static <T> ObjectAssert<T> assertThat(T actual) {
return AssertionsForClassTypes.assertThat(actual);
}

Tomorrow, AbstractObjectAssert would take over the ObjectAssert name, and the method above would become something like:

 public static <T> ObjectAssert<T> assertThat(T actual) { // ObjectAssert is abstract!
   return ObjectAssert.for(actual); // this instantiates a concrete subtype of ObjectAssert, never exposed to end users
 }

Given the massive impact on existing signatures, I foresee that in version 4 only.

Does it make sense so far, or do you see any problems with this strategy?

@filiphr
Copy link
Copy Markdown
Contributor

filiphr commented Mar 26, 2026

Tomorrow, AbstractObjectAssert would take over the ObjectAssert name

Currently the AbstractObjectAssert is AbstractObjectAssert<SELF extends AbstractObjectAssert<SELF, ACTUAL>, ACTUAL>, and ObjectAssert is ObjectAssert<ACTUAL>. I assume that here the signature is already good.

How would the CharSequenceAssert look like? Currently there is AbstractCharSequenceAssert<SELF extends AbstractCharSequenceAssert<SELF, ACTUAL>, ACTUAL extends CharSequence>.

It's exposed it via

public static AbstractCharSequenceAssert<?, ? extends CharSequence> assertThat(CharSequence actual) {
  return AssertionsForInterfaceTypes.assertThat(actual);
}

How would the return type look like here?

@scordio
Copy link
Copy Markdown
Member Author

scordio commented Mar 26, 2026

CharSequenceAssert might be a tricky one due to #4079... let me play with it, I'll get back to you.

@scordio scordio force-pushed the gh-4183-suppressed-exceptions-assert branch from 1eba1eb to 492b670 Compare April 5, 2026 12:56
@testlens-app

This comment has been minimized.

@scordio scordio force-pushed the gh-4183-suppressed-exceptions-assert branch from 492b670 to 52564e7 Compare April 11, 2026 21:57
@testlens-app

This comment has been minimized.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a new navigation assertion for Throwable suppressed exceptions, enabling fluent array assertions on Throwable#getSuppressed() and the ability to navigate back to the originating throwable assertion. It also refactors several tests to reuse a shared ThrowingCallableFactory helper.

Changes:

  • Add suppressedExceptions() navigation method to AbstractThrowableAssert and withSuppressedExceptionsThat() to ThrowableAssertAlternative.
  • Introduce SuppressedExceptionsAssert to support array assertions on suppressed exceptions and returnToThrowable() navigation.
  • Add/adjust integration tests around suppressed exceptions navigation and consolidate codeThrowing(...) helper usage.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
assertj-core/src/main/java/org/assertj/core/api/AbstractThrowableAssert.java Adds suppressedExceptions() navigation method returning SuppressedExceptionsAssert.
assertj-core/src/main/java/org/assertj/core/api/ThrowableAssertAlternative.java Adds withSuppressedExceptionsThat() delegating to the core throwable assertion navigation.
assertj-core/src/main/java/org/assertj/core/api/SuppressedExceptionsAssert.java New assertion type for suppressed exceptions with returnToThrowable().
assertj-tests/assertj-integration-tests/assertj-core-tests/src/test/java/org/assertj/tests/core/api/throwable/ThrowableAssert_suppressedExceptions_Test.java Tests for the new ThrowableAssert#suppressedExceptions() navigation method and state propagation.
assertj-tests/assertj-integration-tests/assertj-core-tests/src/test/java/org/assertj/tests/core/api/ThrowableAssertAlternative_withSuppressedExceptionsThat_Test.java Tests for ThrowableAssertAlternative#withSuppressedExceptionsThat().
assertj-tests/assertj-integration-tests/assertj-core-tests/src/test/java/org/assertj/tests/core/api/suppressedexceptions/SuppressedExceptionsAssert_Test.java Tests basic structure/behavior of SuppressedExceptionsAssert including navigation back to origin.
assertj-tests/assertj-integration-tests/assertj-core-tests/src/test/java/org/assertj/tests/core/api/SoftAssertions_ThrowableTypeAssert_Test.java Fixes package/imports and reuses shared codeThrowing(...) helper.
assertj-tests/assertj-integration-tests/assertj-core-tests/src/test/java/org/assertj/tests/core/api/BDDSoftAssertions_ThrowableTypeAssert_Test.java Fixes package/imports and reuses shared codeThrowing(...) helper.
assertj-tests/assertj-integration-tests/assertj-core-tests/src/test/java/org/assertj/tests/core/api/Assertions_catchThrowable_Test.java Replaces local codeThrowing(...) helper with shared factory.
assertj-tests/assertj-integration-tests/assertj-core-tests/src/test/java/org/assertj/tests/core/api/Assertions_catchRuntimeException_Test.java Updates static import to shared codeThrowing(...) helper.
assertj-tests/assertj-integration-tests/assertj-core-tests/src/test/java/org/assertj/tests/core/api/Assertions_assertThatThrownBy_Test.java Replaces local codeThrowing(...) helper with shared factory.
assertj-core/src/test/java/org/assertj/core/testkit/ThrowingCallableFactory.java Removes duplicate helper in favor of the shared testkit version under assertj-tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@scordio scordio force-pushed the gh-4183-suppressed-exceptions-assert branch from 52564e7 to 15558cb Compare April 11, 2026 23:31
@testlens-app

This comment has been minimized.

@scordio scordio force-pushed the gh-4183-suppressed-exceptions-assert branch from 15558cb to 842181e Compare April 13, 2026 12:01
@testlens-app

This comment has been minimized.

@scordio scordio force-pushed the gh-4183-suppressed-exceptions-assert branch from 842181e to 48c9b47 Compare April 14, 2026 06:58
@testlens-app

This comment has been minimized.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread assertj-core/src/main/java/org/assertj/core/api/SoftProxies.java
Comment thread assertj-core/src/main/java/org/assertj/core/api/SuppressedExceptionsAssert.java Outdated
@scordio scordio force-pushed the gh-4183-suppressed-exceptions-assert branch from 48c9b47 to 479c8f0 Compare April 14, 2026 14:08
@scordio scordio requested a review from Copilot April 14, 2026 14:09
@testlens-app
Copy link
Copy Markdown

testlens-app bot commented Apr 14, 2026

✅ All tests passed ✅

🏷️ Commit: 479c8f0
▶️ Tests: 15142 executed
⚪️ Checks: 25/25 completed


Learn more about TestLens at testlens.app.

@scordio scordio requested review from Copilot and removed request for Copilot April 14, 2026 14:24
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +31 to +48
public abstract class SuppressedExceptionsAssert<ORIGIN extends AbstractThrowableAssert<ORIGIN, THROWABLE>, THROWABLE extends Throwable>
extends AbstractObjectArrayAssert<SuppressedExceptionsAssert<ORIGIN, THROWABLE>, Throwable> {

private final ORIGIN originAssert;

static <ORIGIN extends AbstractThrowableAssert<ORIGIN, THROWABLE>, THROWABLE extends Throwable> SuppressedExceptionsAssert<ORIGIN, THROWABLE> from(ORIGIN originAssert) {
return new DefaultAssert<>(originAssert, originAssert.actual.getSuppressed()).withAssertionState(originAssert);
}

/**
* Creates a new instance from an {@link ORIGIN} assert instance and an array of suppressed exceptions.
*
* @param originAssert the {@link ORIGIN} assert that initiated the navigation.
* @param suppressedExceptions the suppressed exceptions.
*/
protected SuppressedExceptionsAssert(ORIGIN originAssert, Throwable[] suppressedExceptions) {
super(suppressedExceptions, SuppressedExceptionsAssert.class);
this.originAssert = requireNonNull(originAssert, shouldNotBeNull("originAssert")::create);
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SuppressedExceptionsAssert is described (in the PR description) as the public extension point, but its type signature hard-codes the self type as SuppressedExceptionsAssert<...> (it extends AbstractObjectArrayAssert<SuppressedExceptionsAssert<...>, ...> and passes SuppressedExceptionsAssert.class to super). This prevents subclasses from getting fluent return types for inherited assertion methods. If third-party extension is a goal, consider adding a SELF extends SuppressedExceptionsAssert<SELF, ORIGIN, THROWABLE> type parameter (and forwarding selfType into the constructor) to preserve fluent chaining for subclasses.

Copilot uses AI. Check for mistakes.
Class<?> proxyClass = createSoftAssertionProxyClass(SuppressedExceptionsAssert.class);
try {
AbstractThrowableAssert<?, ?> originAssert = suppressedExceptionsAssert.returnToThrowable();
Constructor<?> constructor = proxyClass.getConstructor(AbstractThrowableAssert.class, Throwable[].class);
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createSuppressedExceptionsAssertProxy looks up the proxy constructor with getConstructor(...), which only finds public constructors. SuppressedExceptionsAssert's constructor is protected (and the proxy likely mirrors that), so this can throw NoSuchMethodException at runtime when soft assertions navigate to suppressedExceptions(). Use getDeclaredConstructor(...) (and make it accessible) or otherwise ensure the generated proxy exposes a public constructor for this signature.

Suggested change
Constructor<?> constructor = proxyClass.getConstructor(AbstractThrowableAssert.class, Throwable[].class);
Constructor<?> constructor = proxyClass.getDeclaredConstructor(AbstractThrowableAssert.class, Throwable[].class);
constructor.setAccessible(true);

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants