Previous | Next | |
Rational_ex06 | Rational_ex07 | Rational_ex08 |
Rational: ex07
Exceptions, testing for Exceptions with JUnit
Part of a series of tutorial articles about a Rational class.
Code examples referred to on this page can be found here: https://github.com/UCSB-CS56-pconrad/cs56-rational-ex07
cs56-rational-example/ex07
In ex07, we’ll look at Exceptions in Java by exploring how they can be used to enforce some assumptions we’ve been making about parameters to functions:
- We’ve been “assuming” that folks won’t pass a zero for the denominator of our Rational object. If they do, bad things might happenincluding division by zero.
- To prevent this we can throw a
java.lang.IllegalArgumentException
as soon as we detect that our assumption has been violated. The sooner you let the programmer know that something is wrong, the easier it will be for them to fix it. - We can use the same approach for our
gcd
method, to ensure that it is only used on positive integers (the only ones we have tested it on.) - We’ll look at how to use JUnit with exceptions to test that constructors and methods throw the intended Exception under the intended circumstances.
About Exceptions
In Java, all exceptions derive from java.lang.Exception, in the following inheritance hierarchy:
There are two main categories of Exceptions:
- Checked Exceptions
- Unchecked Exceptions (these all extend java.lang.RuntimeException)
There are signficant differences between Checked Exceptions and Unchecked Exceptions, in terms of:
- The use case (i.e. why they are used by programmers, the circumstances in which they are useful)
- How the code must be written to handle one versus the other.
A complete discussion of that topic can be found in the Head First Java, 2nd Edition textbook, Chapter 11 (“Risky Behavior”). Exceptions are the focus of that entire chapter.
For this example, we’ll defer a larger discussion of Exceptions. We are dealing only with one particular exception, the exception java.lang.IllegalArgumentException
(javadoc here), which happens to be an Unchecked Exception. We can see this in its inheritance hierarchy, which shows that it extends java.lang.RuntimeException:
To throw an exception in Java, we use the java keyword throw
, followed by an instance of the the exception we want to throw. It
is typical to use an inline constructor invocation to create that instance, as in this example.
if (denom == 0) {
throw new IllegalArgumentException("denominator may not be zero");
}
A few things to note about the example above:
- Exceptions typically have a constructor that takes a
java.lang.String
argument; in this string, we can provide some extra helpful information about the reason the exception is being thrown. In writing this string, we need to use good design; the tradeoff is brevity, versus helpfulness to the human that will be reading the message. We should pack as much useful information as possible into the fewest number of characters. Note that an exception typically includes a stack trace, so it may not be necessary to include the name of the constructor; that will be shown anyway. - Throwing an exception typically ends the constructor or method invocation. We don’t need, and should not
write, a return, or any other statement after a
throw
, since it will be unreachable. - If a throw is inside the block of an
if
, it isn’t really necessary to follow it with anelse
. Theelse
is implied by the fact that end the current constructor or method invocation when an exception is thrown. - We don’t have to use the full name
java.lang.IllegalArgumentException
; for any class that is in the packagejava.lang
, we can leave off thejava.lang.
prefix. It is as if we always have aimport java.lang.*
statement at the top of every Java source file.
So that’s what the code will look like that throws an exception. But first, let’s write some tests; TDD says “write the test first, and then write the code”. So let’s follow that practice.
About Annotations
To write a JUnit test for an exception, we use annotations. Annotations are those
things that start with an at sign (@
). We’ve already seen the annotations
@Before
and @Test
in this code from RationalTest.java
(note that the … is
not part of the code, but indicates some code is omitted.)
@Before public void setUp() {
r_5_15 = new Rational(5,15);
...
}
@Test
public void test_getNumerator_20_25() {
assertEquals(4, r_20_25.getNumerator());
}
These annotations come from the import statements:
import org.junit.Test;
import org.junit.Before;
The javadoc for these annotations can be seen here:
Writing JUnit tests for Exceptions
The easiest way to write a JUnit test for an Exception is to pass a parameter to the @Test
annotation,
as shown here.
@Test(expected = IllegalArgumentException.class)
public void test_denom_zero() {
Rational r = new Rational(1,0);
}
This test will pass if an instance of IllegalArgumentException
is thrown, and will fail if it is not thrown.
Some notes:
- This way of testing does not allow you to check whether the message in
the exception is the expected one, i.e. whether it is
"denominator may not be zero"
, which would make sense, or"Go Gauchos!"
, which while an admirable sentiment, is not what we are looking for here. - The wiki associated with the junit-team’s github repo has an article that explains some more sophisticated ways of testing exceptions that do allow us to check whether the message is the right one. We’ll leave creating an example of that kind of testing to a future article, or perhaps as an “exercise for the student”.
- The
.class
at the end ofIllegalArgumentException.class
doesn’t refer to the file extension of a.class
file, though it would be understandable why you might think so. Instead, there it is a public property of every class name that refers to the object that is the class itself. It is an instance of the parameterized classClass<T>
(see javadoc here: [java.lang.Class](http://docs.oracle.com/javase/8/docs/api/java/lang/Class.html), and this [StackOverflow answer](http://stackoverflow.com/questions/15078935/what-does-class-mean-in-java).)
We will add this test to our src/RationalTest.java
file, and then test to see the test fail:
test:
[junit] Testsuite: RationalTest
[junit] Tests run: 16, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.096 sec
[junit]
[junit] Testcase: test_denom_zero(RationalTest): FAILED
[junit] Expected exception: java.lang.IllegalArgumentException
[junit] junit.framework.AssertionFailedError: Expected exception: java.lang.IllegalArgumentException
[junit]
[junit]
[junit] Test RationalTest FAILED
We then add this code into our constructor:
if (denom == 0) {
throw new IllegalArgumentException("denominator may not be zero");
}
And we’ll see the test pass:
test:
[junit] Testsuite: RationalTest
[junit] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.028 sec
[junit]
With that done, we’ll turn our attention to negative numerator and denominator.
Negative num and denom
So, the first thing we do is write some tests that we expect to fail:
@Test
public void test_rational_m10_m5() {
Rational r = new Rational(-10,-5);
assertEquals("2",r.toString());
}
@Test
public void test_rational_m5_6() {
Rational r = new Rational(-5,6);
assertEquals("-5/6",r.toString());
}
@Test
public void test_rational_7_m8() {
Rational r = new Rational(7,-8);
assertEquals("-7/8",r.toString());
}
We run, and whoa! the tests all pass!
So, this leaves us in an interesting position. It appears that the gcd() function may just be taking care of the problem that we anticipated, by factoring out -1, and miraculously, always leaving the negative number in the numerator.
Or, we actually might just have gotten lucky with our choice of tests.
For now, we’ll leave this as an “unsolved mystery”, i.e. do we still have a bug with negative numbers that our tests just haven’t revealed to us? Or can we actually demonstrate, perhaps with a proof, or some other argument, that our code is correct?
Here’s what we can say for sure: if we later discover a bug, the way to proceed is to first add a test that fails because of that bug, i.e.
- FIRST, add a test with the specific values to the Rational constructor that cause us to get an unexpected result.
- THEN, see that test fail.
- THEN, fix the code so that the test passes.
All the while, retaining all of the other tests we already wrote.
What’s next in Rational: ex08?
Next, we’ll show how to write functions to multiply two rational numbers together.
After that, we’ll take on the task of writing a comparison function, with an eye towards being able to sort collections of rational numbers. That will take us into the land of “common denominators”, which is also something we’ll need if we want to be able to add rational numbers.
Previous | Next | |
Rational_ex06 | Rational_ex07 | Rational_ex08 |