Prevent extracting from leaking RuntimeException in soft assertions#4219
Prevent extracting from leaking RuntimeException in soft assertions#4219filiphr wants to merge 1 commit intoassertj:mainfrom
extracting from leaking RuntimeException in soft assertions#4219Conversation
When `actual` is null, `extracting` internally fails its null-guard with an AssertionError (collected by soft assertions), then continues to apply the extractor to null and leaks a RuntimeException that bypasses soft collection. Introduce a `skipAssertions` dead-chain flag on AbstractAssert, propagated via `withAssertionState`. When a soft null-guard in `extracting` fails, return a dead-chain navigated assertion so downstream assertions no-op instead of throwing or piling up errors that cannot succeed.
✅ All tests passed ✅🏷️ Commit: d294223 Learn more about TestLens at testlens.app. |
| } | ||
|
|
||
| private <T, ASSERT extends AbstractAssert<?, ?>> ASSERT deadChainNavigation(AssertFactory<T, ASSERT> assertFactory) { | ||
| return markAsDeadChain(assertFactory.createAssert((T) null)); |
There was a problem hiding this comment.
I think this could be a bit simpler, since we can't really extract anything, we could simply return this assertion after setting the skipAssertions to true, I mean at this point since we failed the navigation, it would make sense not to navigate at all.
I suggest
- removing
markAsDeadChain, - changing visibility of this method to
protectedso that users can override it, - implementing it like:
skipChainedAssertions = true;
// Noop assert since we just have skipChainedAssertions
return (ASSERT)myself;For navigation that returns a different type of assertion, we can - as you did for the list assertion - return an assertion with actual set to null.
WDYT ?
There was a problem hiding this comment.
I am not sure that I am following.
In this particular case we can't do what you are suggestion since this method is for navigation, i.e. we can't just do `(ASSERT)myself) it'll lead to a class cast exception.
This method is used in the 2 extracting from AbstractAssert that take an AssertFactory and return a different type of assertion.
Also in the AbstractObjectAssert, I can't return myself as it expects a AbstractListAssert as a return type.
Perhaps we can only keep markAsDeadChain and do
if (actual == null) return markAsDeadChain(assertFactory.createAssert((T) null));If you like the approach I can go ahead and implement it for the rest of the classes to see how it would look like fully.
There was a problem hiding this comment.
Since extracting a single value returns AbstractAssert, you can return any abstract assert including myself as long as you set the skip assertions flag to true, since it's a "dead" assert instance it does not matter to build a precise subtype of assert (I'm probably not much clearer than my previous comment but I don't know how to express my point in a clearer way).
I have tried with my suggested implementation on your branch branch locally, ie this:
protected <ASSERT extends AbstractAssert<?, ?>> ASSERT extracting(String propertyOrField,
AssertFactory<Object, ASSERT> assertFactory) {
requireNonNull(propertyOrField, shouldNotBeNull("propertyOrField")::create);
requireNonNull(assertFactory, shouldNotBeNull("assertFactory")::create);
isNotNull();
if (actual == null) {
skipAssertions = true;
return (ASSERT)myself;
}
.... and all the SoftAssertions_extracting_on_null_actual_Test are green.
I also tested with strongly typed extracting and it works too, ex:
@Test
void should_not_throw_when_extracting_by_name_without_explicit_isNotNull() {
// GIVEN / WHEN
softly.assertThat((Object) null).extracting("foo", STRING).startsWith("bar"); // extract as String
// THEN - extracting's internal isNotNull collects one error; downstream is noop
List<Throwable> errors = softly.errorsCollected();
then(errors).hasSize(1);
then(errors.get(0)).hasMessageContaining("Expecting actual not to be null");
}@filiphr I'm more than happy to be proven wrong, could you think of a test that would break my suggested implementation ?
we can't do that for extracting multiple values since it returns a list assert, in this case we obviously can't return myself.
There was a problem hiding this comment.
I've checked this a bit more. Yes you are right about the AbstractAssert#extracting. However, that pattern won't work for the AbsractIterable#first(InstanceOfAssertFactory).
I'll try to find some time to apply a general pattern for everything
There was a problem hiding this comment.
I agree it won't work with first() since myself is an iterable assert and first and object assert.
In that case, simply returning an ObjectAssert would work.
BTW I find the deadChain term to be quite good and expressive
Keen to see where your exploration is going to go
This is a spike on skipping errors in chained assertions. Similar to PR #4218 and should fix #4213.
When
actualis null,extractinginternally fails its null-guard with an AssertionError (collected by soft assertions), then continues to apply the extractor to null and leaks a RuntimeException that bypasses soft collection.Introduce a
skipAssertionsdead-chain flag on AbstractAssert, propagated viawithAssertionState. When a soft null-guard inextractingfails, return a dead-chain navigated assertion so downstream assertions no-op instead of throwing or piling up errors that cannot succeed.Check List: