When following best practices of defensive programming, we often want to throw Exceptions for incorrect or invalid inputs. In this module, we will look at writing tests that expect and Exception
to be thrown.
–
–
Consider testing our withdraw
function specification.
public void withdraw(double amount) {
//TODO: Stub
}
We want to withdraw amount
from our BankAccount
. If the transaction completes successfully, balance
in BankAccount
will be reduced by amount
. However, amount
cannot be negative, and amount
cannot exceed balance.
In this case, we have two preconditions:
amount
cannot be negativeamount
cannot exceed balance
If either condition is violated, we should throw an Exception. We can write tests to ensure these Exceptions are thrown!
We want to test and implement our equivalence case first, so we write the following test.
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
public class BankAccountTest {
private BankAccount testAccount;
@BeforeEach
public void setup() {
testAccount = new BankAccount(1234, 500);
}
@Test
public void withdrawEquivalance() {
testAccount.withdraw(300);
assertEquals(200, testAccount.getBalance(),1e-4);
}
}
We then implement our equivalence case:
public void withdraw(double amount) {
balance -= amount;
}
We run our test and it passes. Now we can test our Exception cases.
The assertThrows
function is a JUnit 5 assertion that says “the code must throw a particular exception.” For example, let’s test the exception case amount
cannot exceed balance
. I would expect that if I tried to withdraw more money than was in the account, I would get some kind of RuntimeException
. And so I write:
@Test
public void withdrawInsufficientFundsException() {
assertThrows(RuntimeException.class, () -> testAccount.withdraw(600));
}
What this code is saying is that I expect the code testAccount.withdraw(600)
to result in a
RuntimeException
being thrown. The rest of the line (.class
and () ->
) are syntax items. We use RuntimeException.class
because Java needs to know what class
of Exception is returned. The () ->
relates to lambda bodies in Java, and we will discuss this during our Functional Programming unit. However, for now just assume that it needs to be there.
I add the following to the implementation.
public void withdraw(double amount) {
if (amount > balance) {
throw new RuntimeException(getInsufficientFundsMessage(amount));
}
balance -= amount;
}
private String getInsufficientFundsMessage(double amount) {
return "Error: insufficient funds in account #" + id + " - balance: " + balance +
" for transaction amount: " + amount;
}
Here, I am simply using the function getInsufficientFundsMessage(double amount)
to separate throwing the exception from generating the exception message. This keeps withdraw
shorter and more tightly focuses on the mechanisms of withdrawing from a bank account.
Remember that in our Defensive Programming unit, we wanted to make sure that if an exception was throw, we didn’t change the state of the object in question. As such, we want to ensure after the RuntimeException
was thrown, that the value of balance
did not change, since no transaction should have been allowed. As such, we add to our test:
@Test
public void withdrawInsufficientFundsException() {
assertThrows(RuntimeException.class, () -> testAccount.withdraw(600));
assertEquals(500, testAccount.getBalance(),1e-4);
}
We run our test, and it passes.
In our last unit, we talked about creating an Exception called InsufficientFundsException
public class InsufficientFundsException extends RuntimeException {
public InsufficientFundsException(String message) {
super(message);
}
}
The point of this was to ensure that the error message thrown by withdraw
due to incorrect usage provides the best information as to:
As such, we change our generic RuntimeException
in withdraw
as well as withdrawInsufficientFundsException
to the more precise InsufficientFundsException
.
public void withdraw(double amount) {
if (amount > balance) {
throw new InsufficientFundsException(getInsufficientFundsMessage(amount));
}
balance -= amount;
}
@Test
public void withdrawInsufficientFundsException() {
assertThrows(InsufficientFundsException.class, () -> testAccount.withdraw(600));
assertEquals(500, testAccount.getBalance(),1e-4);
}
We can then add our test for the NegativeBalanceException
violation in the same way.
First, we write our test:
@Test
public void withdrawNegativeTransaction() {
assertThrows(NegativeTransactionException.class, () -> testAccount.withdraw(-300));
assertEquals(500, testAccount.getBalance(), 1e-4);
}
And then we add to our existing withdraw
implementation.
public void withdraw(double amount) {
if (amount < 0) {
throw new NegativeTransactionException(getNegativeTransactionMessage("withdraw", amount));
} else if (amount > balance) {
throw new InsufficientFundsException(getInsufficientFundsMessage(amount));
}
balance -= amount;
}
We run all of our tests (our equivalence and our two exception cases), and they pass, so we’re good!
We must always make sure that when exceptions are throw, we do not create any unintended side effects.
Consider the following snippet of a class called VoteTally
and specifically the function addVotesFromPrecinct
public class VoteTally {
private final Map<String, Integer> candidateVotes;
public VoteTally() {
this.candidateVotes = new HashMap<>();
}
protected VoteTally(Map<String, Integer> candidateVotes) {
this.candidateVotes = candidateVotes;
}
public int getNumCandidates() {
return candidateVotes.size();
}
public Set<String> getCandidates() {
return candidateVotes.keySet();
}
public int getVotesForCandidate(String candidate) {
if (!candidateVotes.containsKey(candidate)) {
return 0;
}
return candidateVotes.get(candidate);
}
/**
* Adds a number of votes for each candidate.
* @param precinctResults - the results from a precinct
*/
public void addVotesFromPrecinct(Map<String, Integer> precinctResults) {
for (int newVotes : precinctResults.values()) {
if (newVotes < 0) {
throw new IllegalArgumentException("A precinct cannot report negative votes for a candidate");
}
}
for (var candidate : precinctResults.keySet()) {
var newVotes = precinctResults.get(candidate);
var currentVotes = getVotesForCandidate(candidate);
candidateVotes.put(candidate, newVotes + currentVotes);
}
}
}
An example of a test for addVotesFromPrecinct
could be:
@Test
void addVotes_existingCandidates() {
var testVoteTally = new VoteTally(
new HashMap<>(Map.of("John Smith", 20, "Votey McVoteface", 10)));
testVoteTally.addVotesFromPrecinct(Map.of("John Smith", 10, "Jane Doe", 15));
assertEquals(3, testVoteTally.getNumCandidates());
assertTrue(testVoteTally.getCandidates().contains("John Smith"));
assertTrue(testVoteTally.getCandidates().contains("Jane Doe"));
assertTrue(testVoteTally.getCandidates().contains("Votey McVoteface"));
assertEquals(30, testVoteTally.getVotesForCandidate("John Smith"));
assertEquals(15, testVoteTally.getVotesForCandidate("Jane Doe"));
assertEquals(10, testVoteTally.getVotesForCandidate("Votey McVoteface"));
Let’s also say we have the following Exception
test (this test is insufficient as we’ll show in a second).
@Test
void addVotes_negativeVotes_Exception() {
var testVoteTally = new VoteTally(
new HashMap<>(Map.of("John Smith", 20, "Votey McVoteface", 10)));
assertThrows(IllegalArgumentException.class, () ->
testVoteTally.addVotesFromPrecinct(Map.of("Jane Doe", 5, "John Smith", -10)));
}
Our test (successfully) expects an IllegalArgumentException
to be thrown when a negative vote total for a candidate is found, since this shouldn’t be possible.
Looking at the code for addVotesFromPrecinct
. But let’s say when a junior developer reads the code for the function addVotesFromPrecinct
, they might think the first loop is redundant. They think, “let’s remove that unnecessary, and just check for negative votes in the loop that already exists, so the code is easier to understand.”
And so they change the code to this:
public void addVotesFromPrecinct(Map<String, Integer> precinctResults) {
for (var candidate: precinctResults.keySet()) {
var newVotes = precinctResults.get(candidate);
if (newVotes < 0) {
throw new IllegalArgumentException("A precinct cannot report negative votes for a candidate");
}
var currentVotes = getVotesForCandidate(candidate);
candidateVotes.put(candidate, newVotes + currentVotes);
}
}
They run our test, it passes, so they think, “Job well done!”
They have just created a serious bug! This is because our test doesn’t check the expected post-conditions!.
Specifically, let’s add a print-statement to our test to see what happens:
@Test
void addVotes_negativeVotes_Exception() {
var testVoteTally = new VoteTally(
new HashMap<>(Map.of("John Smith", 20, "Votey McVoteface", 10)));
assertThrows(IllegalArgumentException.class, () ->
testVoteTally.addVotesFromPrecinct(Map.of("Jane Doe", 5, "John Smith", -10)));
System.out.println(testVoteTally);
}
What prints is tells us the candidates have the following votes:
John Smith | 20
Votey McVoteface | 10
Jane Doe | 5
(Note that, even worse, this will only happen some of the time, depending on how Java builds the test Map at runtime! Other times, it will appear to work fine)
Jane Doe was still added! That’s because on the first iteration through our loop, we add Jane Doe, before we find the bad input (John Smith’s -10 votes). This means while we correctly throw an exception and do not add John Smith’s negative 10 votes, we have already erroneously and permanently added Jane Doe’s votes!
This is why the test we had was insufficient. A better test would be:
@Test
void addVotes_negativeVotes_Exception() {
var testVoteTally = new VoteTally(
new HashMap<>(Map.of("John Smith", 20, "Votey McVoteface", 10)));
assertThrows(IllegalArgumentException.class, () ->
testVoteTally.addVotesFromPrecinct(Map.of("Jane Doe", 5, "John Smith", -10)));
assertEquals(2, testVoteTally.getNumCandidates());
assertTrue(testVoteTally.getCandidates().contains("John Smith"));
assertFalse(testVoteTally.getCandidates().contains("Jane Doe"));
assertTrue(testVoteTally.getCandidates().contains("Votey McVoteface"));
assertEquals(20, testVoteTally.getVotesForCandidate("John Smith"));
assertEquals(10, testVoteTally.getVotesForCandidate("Votey McVoteface"));
}
Now, our test will fail if Jane Doe’s votes are erroneously added! But because our test that was in place didn’t check the post-conditions, it just assumed “Exception means pass”, we failed to catch this bug being injected!
This is also why we always want to explicitly check pre-conditions first, and separate that logic entirely from the actual running of the method!
public void addVotesFromPrecinct(Map<String, Integer> precinctResults) {
for (int newVotes : precinctResults.values()) {
if (newVotes < 0) {
throw new IllegalArgumentException("A precinct cannot report negative votes for a candidate");
}
}
for (var candidate: precinctResults.keySet()) {
var newVotes = precinctResults.get(candidate);
var currentVotes = getVotesForCandidate(candidate);
candidateVotes.put(candidate, newVotes + currentVotes);
}
}
This can never produce the bug, because we would have failed before any existing data was changed/mutated.