Skip to content

Commit

Permalink
Introduce new ExceptionMatcher.with(...) (#33)
Browse files Browse the repository at this point in the history
* Introduce new ExceptionMatcher.with(...)
* Add Javadoc
  • Loading branch information
oswaldobapvicjr authored May 15, 2024
1 parent 5714b09 commit 10367d9
Show file tree
Hide file tree
Showing 3 changed files with 266 additions and 3 deletions.
109 changes: 107 additions & 2 deletions src/main/java/net/obvj/junit/utils/matchers/ExceptionMatcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,17 @@

package net.obvj.junit.utils.matchers;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.function.Function;
import java.util.stream.IntStream;

import org.hamcrest.*;
import org.hamcrest.CoreMatchers;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeDiagnosingMatcher;

import net.obvj.junit.utils.Procedure;

Expand Down Expand Up @@ -257,6 +263,25 @@ void describeTo(ExceptionMatcher parent, Description description)
abstract void describeTo(ExceptionMatcher parent, Description description);
}

/**
* A simple association between a function and a matcher, to extract and validate custom data
* from a throwable.
*
* @since 1.8.0
*/
private static class CustomFunction
{
private final Function<? super Throwable, Object> function;
private final Matcher<?> matcher;

private CustomFunction(Function<? super Throwable, Object> function, Matcher<?> matcher)
{
this.function = function;
this.matcher = matcher;
}
}


private static final String INDENT = " ";
private static final String NEW_LINE_INDENT = "\n" + INDENT;

Expand All @@ -273,6 +298,9 @@ void describeTo(ExceptionMatcher parent, Description description)
private List<String> expectedMessageSubstrings = Collections.emptyList();
private Matcher<String> messageMatcher;

private List<CustomFunction> customFunctions;


/**
* Builds this Matcher.
*
Expand Down Expand Up @@ -583,6 +611,41 @@ public ExceptionMatcher withMessageContaining(String... substrings)
return this;
}

/**
* Uses the given function to extract a value from the expected throwable, and a matcher to be
* used against the function's result.
* <p>
* This can be particularly useful to extract custom exception data using a method
* reference or a lambda expression.
* <p>
* For example:
*
* <blockquote>
* <pre>
* {@code
* assertThat(() -> obj.doStuff(null),
* throwsException(MyCustomException.class)
* .with(MyCustomException::getErrorCode, equalTo(12001))
* .with(MyCustomException::getLocalizedMessage, equalTo("Invalid parameter")));}
* </pre>
* </blockquote>
*
* @param <T> the expected throwable type
* @param function a function to be applied to extract the value from the throwable
* @param matcher the matcher to be used against the function result
* @return the matcher, incremented with the specified custom function and matcher
* @since 1.8.0
*/
public <T> ExceptionMatcher with(Function<? super T, Object> function, Matcher<?> matcher)
{
if (customFunctions == null)
{
customFunctions = new ArrayList<>();
}
customFunctions.add(new CustomFunction((Function<? super Throwable, Object>) function, matcher));
return this;
}

/**
* Execute the matcher business logic for the specified procedure.
*
Expand Down Expand Up @@ -622,7 +685,11 @@ protected boolean validateFully(Throwable throwable, Description mismatch)
{
return false;
}
return !(checkCauseFlag && !validateCause(throwable, mismatch));
if (checkCauseFlag && !validateCause(throwable, mismatch))
{
return false;
}
return validateCustomFunctions(throwable, mismatch);
}

/**
Expand Down Expand Up @@ -667,6 +734,35 @@ private boolean validateCause(Throwable throwable, Description mismatch)
return causeMatchingStrategy.validateCause(this, throwable, mismatch);
}


/**
* Validates the custom functions, if present.
*
* @param throwable the Throwable whose cause is to be validated
* @param mismatch the description to be used for reporting in case of mismatch
* @return a flag indicating whether or not the matching has succeeded
* @since 1.8.0
*/
private boolean validateCustomFunctions(Throwable throwable, Description mismatch)
{
if (customFunctions != null)
{
for (int i = 0; i < customFunctions.size(); i++)
{
CustomFunction custom = customFunctions.get(i);
Object actual = custom.function.apply(throwable);
if (!custom.matcher.matches(actual))
{
mismatch.appendText(NEW_LINE_INDENT)
.appendText("the value retrieved by the function #" + (i + 1) + " ");
custom.matcher.describeMismatch(actual, mismatch);
return false;
}
}
}
return true;
}

/**
* Describes the "expected" pat of the test description.
*
Expand All @@ -684,6 +780,15 @@ public void describeTo(Description description)
{
causeMatchingStrategy.describeTo(this, description);
}
if (customFunctions != null)
{
IntStream.range(0, customFunctions.size()).forEach((int i) ->
{
description.appendText(NEW_LINE_INDENT)
.appendText("and the function #" + (i + 1) + ": ");
customFunctions.get(i).matcher.describeTo(description);
});
}
}

/**
Expand Down
43 changes: 43 additions & 0 deletions src/main/java/net/obvj/junit/utils/matchers/MyCustomException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright 2024 obvj.net
*
* 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 net.obvj.junit.utils.matchers;

public class MyCustomException extends Exception
{
private static final long serialVersionUID = 5725758277733721926L;

private final String customString;
private final int code;

public MyCustomException(String message, String customString, int code)
{
super(message);
this.customString = customString;
this.code = code;
}

public String getCustomString()
{
return customString;
}

public int getCode()
{
return code;
}

}
117 changes: 116 additions & 1 deletion src/test/java/net/obvj/junit/utils/matchers/ExceptionMatcherTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import static net.obvj.junit.utils.matchers.ExceptionMatcher.throwsNoException;
import static net.obvj.junit.utils.matchers.StringMatcher.containsAny;
import static org.hamcrest.CoreMatchers.either;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.CoreMatchers.startsWith;
import static org.hamcrest.MatcherAssert.assertThat;

Expand Down Expand Up @@ -718,4 +718,119 @@ void throwsException_checkedException_success()
},
throwsException(IOException.class).withCause(FileNotFoundException.class));
}

@Test
void with_validFunctionAndMatcherMatching_success()
{
assertThat(() ->
{
throw new MyCustomException(MESSAGE1, ERR_0001, 1910);
},
throwsException(MyCustomException.class)
.with(MyCustomException::getCustomString, equalTo(ERR_0001)));
}

@Test
void with_validFunctionAndMatcherNotMatching_exception()
{
try
{
assertThat(() ->
{
throw new MyCustomException(MESSAGE1, ERR_0001, 1910);
},
throwsException(MyCustomException.class)
.with(MyCustomException::getCustomString, startsWith("ERR-0002")));
}
catch (AssertionError e)
{
String[] lines = extractMessageLines(e);
assertThat(lines[1].trim(), equalTo("Expected:"));
assertThat(lines[2].trim(), equalTo(MyCustomException.class.getCanonicalName()));
assertThat(lines[3].trim(), equalTo("and the function #1: a string starting with \"ERR-0002\""));
assertThat(lines[4].trim(), equalTo("but:"));
assertThat(lines[5].trim(), equalTo("the value retrieved by the function #1 was \"ERR-0001\""));
}
}

@Test
void with_multipleValidFunctionsAndMatchersMatching_success()
{
assertThat(() ->
{
throw new MyCustomException(MESSAGE1, ERR_0001, 1910);
},
throwsException(MyCustomException.class)
.with(MyCustomException::getCustomString, equalTo(ERR_0001))
.with(MyCustomException::getCode, equalTo(1910))
.with(MyCustomException::getLocalizedMessage, equalTo(MESSAGE1)));
}

@Test
void with_multipleValidFunctionsAndOneMatcherNotMatching_exception()
{
try
{
assertThat(() ->
{
throw new MyCustomException(MESSAGE1, ERR_0001, 1910);
},
throwsException(MyCustomException.class)
.with(MyCustomException::getCustomString, equalTo(ERR_0001))
.with(MyCustomException::getCode, equalTo(1910))
.with(MyCustomException::getLocalizedMessage, endsWith(MESSAGE2)));
}
catch (AssertionError e)
{
String[] lines = extractMessageLines(e);
assertThat(lines[1].trim(), equalTo("Expected:"));
assertThat(lines[2].trim(), equalTo(MyCustomException.class.getCanonicalName()));
assertThat(lines[3].trim(), equalTo("and the function #1: \"ERR-0001\""));
assertThat(lines[4].trim(), equalTo("and the function #2: <1910>"));
assertThat(lines[5].trim(), equalTo("and the function #3: a string ending with \"message2\""));
assertThat(lines[6].trim(), equalTo("but:"));
assertThat(lines[7].trim(), equalTo("the value retrieved by the function #3 was \"message1\""));
}
}

@Test
void with_mixedMessageContainingAndValidFunctionsAndAllMatching_success()
{
assertThat(() ->
{
throw new MyCustomException(MESSAGE2, ERR_0001, 1911);
},
throwsException(MyCustomException.class)
.withMessageContaining(MESSAGE2)
.with(MyCustomException::getCustomString, equalTo(ERR_0001))
.with(MyCustomException::getCode, equalTo(1911)));
}

@Test
void with_mixedMessageContainingAndValidFunctionsAndOneMatcherNotMatching_exception()
{
try
{
assertThat(() ->
{
throw new MyCustomException(MESSAGE2, ERR_0001, 1910);
},
throwsException(MyCustomException.class)
.withMessageContaining(MESSAGE2)
.with(MyCustomException::getCustomString, equalTo(ERR_0001))
.with(MyCustomException::getCode, equalTo(1911)));
}
catch (AssertionError e)
{
String[] lines = extractMessageLines(e);
assertThat(lines[1].trim(), equalTo("Expected:"));
assertThat(lines[2].trim(), equalTo(MyCustomException.class.getCanonicalName()));
assertThat(lines[3].trim(), equalTo("with message containing: [message2]"));
assertThat(lines[4].trim(), equalTo("and the function #1: \"ERR-0001\""));
assertThat(lines[5].trim(), equalTo("and the function #2: <1911>"));
assertThat(lines[6].trim(), equalTo("but:"));
assertThat(lines[7].trim(), equalTo("the value retrieved by the function #2 was <1910>"));
}
}

}

0 comments on commit 10367d9

Please sign in to comment.