Heart containing Coding Chica Java 101

Adding ints! This Time With Exceptions!

TIP: References Quick List

Let’s overload our add method again for the int data type. However, this time, when Java does the addition, it’s still going to return an int. Therefore, we’re going to have to do some error checking (AKA throwing exceptions) to make sure that any happy path response is accurate and valid…AKA we don’t go beyond an int’s minimum and maximum value (overflow / underflow) and return an incorrect answer just because the sum was too large / small. Just as before, let’s do so in test-driven-development (TDD) fashion…writing a failing test, then updating the code to match the new tests.

Unit Test Updates

As always, first, let’s decide what our expectations are in the unit test class: codingchica.java101.AppTest.

New UT Imports

Let’s add 2 new imports to our unit test class. We will discuss what each does as we use them:

import org.junit.jupiter.api.function.Executable;
...
import static org.junit.jupiter.api.Assertions.assertThrows;

New Nested Class

Now, we should add a new, nested class to group the tests for the new runtime method:

/**
 * Unit tests for the add method.
 *
 * @see App#add(int, int)
 */
@Nested
class AddIntsTest {
}

New Test – Happy-Path

Inside of the new AddIntsTest class, let’s add a unit test for confirming happy-path logic.

@ParameterizedTest
@CsvSource({
        // Happy-path - Positive
        "1,2,3",
        // Happy-path - Neutral
        "0,0,0",
        // Happy-path - Negative
        "-1,-1,-2",
        // Edge Case - Upper Bound
        "2147483646,1,2147483647",
        "2,2147483645,2147483647",
        // Edge Case - Lower Bound
        "-2147483647,-1,-2147483648",
        "-2,-2147483646,-2147483648",
        // Edge Case - Beyond Upper Bound - N/A - Exception thrown
        // Edge Case - Beyond Lower Bound - N/A - Exception thrown
})
void add_whenInvokedWhereIntResults_thenReturnsExpectedResult(int value1, int value2, int expectedResult) {
    // Setup

    // Execution
    int actualResult = App.add(value1, value2);

    // Validation
    assertEquals(expectedResult, actualResult, () -> String.format("%s+%s=%s", value1, value2, expectedResult));
}

The different scenarios being covered are called out as comments inside of the CsvSource annotation. Edge cases are where things potentially change behavior, so it is good to test on either side of each edge. Here, our edges are:

  • Positive input values
  • Negative input values
  • Zero (Neutral input values – not as big a worry for adding, but it is for division)
  • The sum being right at/over the minimum int value
  • The sum being right at/over the maximum int value
  • The smallest value possible with two int parameters
  • The largest value possible with two int parameters

Here, we also have a couple of comments with no actual scenarios defined. Those are just reminders as to why those scenarios aren’t covered in this test.

You may also notice that sometimes the larger value is on the second input parameter and sometimes it is on the first. This is also intentional, so we don’t accidentally favor one parameter over the other in the runtime code. With the implementation I have in mind, this shouldn’t be an issue. However, I’m trying to setup the unit test to cover either current implementation or a different approach we could try in the future. Unit tests should act as a contract…if written well and the edges / scenarios are all covered, then we should be able to change the internal logic of the method however we choose and rely on the unit test as our contract that the functionality the method provides is still good.

New Unit Test – int Overflow

@ParameterizedTest
@CsvSource({
        // Edge case - just beyond upper bound
        "2147483647,1",
        "3,2147483646",
        // Edge case - Maximum possible value
        "2147483647,2147483647"
})
void add_whenInvokedForSumOverflow_thenThrowsException(int value1, int value2) {
    // Setup
    String expectedMessage = String.format("int overflow with addends:  %s and %s", value1, value2);

    // Execution
    Executable executable = () -> App.add(value1, value2);

    // Validation
    Exception exception = assertThrows(IllegalArgumentException.class, executable, () -> String.format("%s+%s", value1, value2));
    assertEquals(expectedMessage, exception.getMessage(), "IllegalArgumentException message");
}

In this case, we don’t expect the method to run successfully. Instead, it should say that it is unable to process the given request. In Java, this is often done by throwing an Exception of some type. At that point, the method stops trying to process the request and the raised exception is what is seen by the calling code.

The type of the exception thrown categorizes the type of issue encountered. A message within the thrown exception may offer some details as to what was the problem that occurred.

In this test, our setup work is only to provide the expected message we want to see in the exception thrown.

The execution logic is a little different this time. Here, we are using a lambda expression to define the executable we want to test. Remember, it doesn’t run until later. However, I like this layout when testing exception-throwing methods. That way, I can still easily see what method is under test. JUnit 5 provides an Executable class where we can store the executable we want to test for the next step.

The validation section has 2 steps this time.

Exception exception = assertThrows(IllegalArgumentException.class, executable, () -> String.format("%s+%s", value1, value2));

Here, we are calling a new JUnit 5 assertion, called assertThrows. See assertThrows​(Class<T> expectedType, Executable executable, Supplier<String> messageSupplier). This method takes in:

  1. The expected type of exception (IllegalArgumentException.class) we want to confirm is thrown,
  2. The Executable to invoke when running the test, and
  3. A lambda expression to generate the message to display if the validation fails.

If successful, this method will return an IllegalArgumentException (because that is the type we provided in the first input). However, we are storing this in an Exception type variable. This is called a generic method, as it works generically in the same way for different types of inputs. However, let’s skip that discussion for now.

There isn’t anything specific we need from the lower-level IllegalArgumentException class. Plus, if our expectations change and we instead want an IllegalStateException or something else to be thrown in the future, this saves us a location where we would have to do an additional update. Therefore, we’re going to implicitly cast the IllegalArgumentException returned to a more generic Exception. This is possible because of the way java.lang.IllagalArgumentException is defined.

https://docs.oracle.com/javase/8/docs/api/java/lang/IllegalArgumentException.html

This can be read as:

  • java.lang.IllegaArgumentException is a java.lang.RuntimeException
  • java.lang.RuntimeException is a java.lang.Exception
  • java.lang.Exception is a java.lang.Throwable
  • java.lang.Throwable is a java.lang.Object

We could implicitly cast (AKA just assigning it to a variable of that type without any additional work) IllegalArgumentException to any of its parent types without issue. However, the variable’s defined type will control whether or not Java lets us invoke any given method/etc. later in the code. In this case, all we care about is the getMessage() method, which comes from the java.lang.Throwable parent. However, as we discussed in the prior post, java.lang.Throwable also contains unchecked exceptions. Therefore, I generally stick with the java.lang.Exception for the variable type in this type of unit test.

Once we have the exception thrown by the method, then we need to verify the message:

assertEquals(expectedMessage, exception.getMessage(), "IllegalArgumentException message");

Again, we’re using the familiar assertEquals method. This time, as the expectedMessage variable already contains the two numbers we are adding, I am using a plain old String just to say what step of the verification failed.

New Unit Test – int Underflow

The new unit test for going below the lower bound (minimum) edge case is very similar. Just a the following are modified:

  • CsvSource values
  • Expected message, and
  • Unit Test method name
@ParameterizedTest
@CsvSource({
        // Edge case - just beyond lower bound
        "-2147483648,-1",
        // Swapping large/small order and further beyond lower bound
        "-3,-2147483647",
})
void add_whenInvokedForSumUnderflow_thenThrowsException(int value1, int value2) {
    // Setup
    String expectedMessage = String.format("int underflow with addends:  %s and %s", value1, value2);

    // Execution
    Executable executable = () -> App.add(value1, value2);

    // Validation
    Exception exception = assertThrows(IllegalArgumentException.class, executable, () -> String.format("%s+%s", value1, value2));
    assertEquals(expectedMessage, exception.getMessage(), "IllegalArgumentException message");
}

Application Code Updates – Method Signature

Now that we know what our unit tests expect, we should add the new method’s signature to the codingchica.java101.App class, so our unit tests compile but fail:

/**
 * Add two int values.
 *
 * @param intValue1 The first value to add.
 * @param intValue2 The second value to add.
 * @return An int value representing the two values added together.
 */
public static int add(int intValue1, int intValue2) {
    // TODO implement me
    return 0;
}

Run Tests For Failure

If we run the unit tests or the Maven build at present, we should see something like:

[ERROR] Failures:
[ERROR]   AppTest$AddIntsTest.add_whenInvokedForSumOverflow_thenThrowsException:193 2147483647+1 ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.
[ERROR]   AppTest$AddIntsTest.add_whenInvokedForSumOverflow_thenThrowsException:193 3+2147483646 ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.
[ERROR]   AppTest$AddIntsTest.add_whenInvokedForSumOverflow_thenThrowsException:193 2147483647+2147483647 ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.
[ERROR]   AppTest$AddIntsTest.add_whenInvokedForSumUnderflow_thenThrowsException:213 -2147483648+-1 ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.
[ERROR]   AppTest$AddIntsTest.add_whenInvokedForSumUnderflow_thenThrowsException:213 -3+-2147483647 ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.
[ERROR]   AppTest$AddIntsTest.add_whenInvokedWhereIntResults_thenReturnsExpectedResult:174 1+2=3 ==> expected: <3> but was: <0>
[ERROR]   AppTest$AddIntsTest.add_whenInvokedWhereIntResults_thenReturnsExpectedResult:174 -1+-1=-2 ==> expected: <-2> but was: <0>
[ERROR]   AppTest$AddIntsTest.add_whenInvokedWhereIntResults_thenReturnsExpectedResult:174 2147483646+1=2147483647 ==> expected: <2147483647> but was: <0>
[ERROR]   AppTest$AddIntsTest.add_whenInvokedWhereIntResults_thenReturnsExpectedResult:174 2+2147483645=2147483647 ==> expected: <2147483647> but was: <0>
[ERROR]   AppTest$AddIntsTest.add_whenInvokedWhereIntResults_thenReturnsExpectedResult:174 -2147483647+-1=-2147483648 ==> expected: <-2147483648> but was: <0>
[ERROR]   AppTest$AddIntsTest.add_whenInvokedWhereIntResults_thenReturnsExpectedResult:174 -2+-2147483646=-2147483648 ==> expected: <-2147483648> but was: <0>

I want to point out the failures for the new assertThrows validation step, like:

[ERROR]   AppTest$AddIntsTest.add_whenInvokedForSumOverflow_thenThrowsException:193 2147483647+1 ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.

The assertThrows will fail if either:

  • No exception is thrown
  • The wrong type of exception is thrown

Application Code Updates – Internal Logic

Integer.class is a Java class closely tied to the int primitive. It has MIN_VALUE and MAX_VALUE constants already defined that we can use.

In this snippet, we’re going to use some branching logic to decide which path to take. Either of the following is equivalent, the different is more of personal style / preference:

long sum = (long) intValue1 + (long) intValue2;
if (sum < Integer.MIN_VALUE) {
    throw new IllegalArgumentException(String.format("int underflow with addends:  %s and %s", intValue1, intValue2));
} else if (sum > Integer.MAX_VALUE) {
    throw new IllegalArgumentException(String.format("int overflow with addends:  %s and %s", intValue1, intValue2));
}
return (int) sum;

There are multiple ways that this problem could be handled. In the snippet above, we convert the inputs and sum to long values, which are larger than int values, so that the sum could be compared to the minimum and maximum int values. However, this approach may not be appropriate in a memory-constrained environment. The good news, it that we have solid unit tests to form a solid contract on expected behavior if we decide to change the internal logic for the method itself.

Run Build For Success

Now that we have implemented the internal logic in the method, if we rerun the Maven build (with unit tests), we should now see a BUILD SUCCESS.

Commit

Remember, commit after small, incremental changes. Now is a good time to do so.

Adding ints! This Time With Exceptions!

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.