Skip to content
Ameen Abozaid
PublishedBEG-045-L04

Testing exceptions

  • Lesson 4 of 7
  • 10 min read
  • Beginner
On This Lesson 11 topics

Lesson Overview

Good code does two jobs: it gives the right answer for good input, and it refuses bad input in the right way. In the exceptions module you made Account throw IllegalArgumentException for a bad amount and InsufficientFundsException for an overdraft. But a rule that nobody tests can be deleted by accident, and nobody will notice until a customer withdraws money that is not there.

This lesson teaches the JUnit 5 assertion for that job: assertThrows. You will prove that bad input is rejected, check the exception's message, and see exactly how the test fails when the code does not behave. Our inspector's checklist now gets a second column: "what must happen when the input is wrong".

We use the Account and InsufficientFundsException classes from the exceptions module. Lambdas, which assertThrows needs, come from the functional-programming module. All output below is from JDK 21 and JUnit 5.12.2.

Learning Objectives

By the end of this lesson you will be able to:

  • Explain why a test should prove that bad input is rejected.
  • Write a test with assertThrows(Type.class, () -> code).
  • Check an exception's message or data using the value that assertThrows returns.
  • Predict when an assertThrows test fails: nothing thrown, or the wrong type.
  • Test a checked exception without extra try/catch code.
  • Check that a rejected operation left the object unchanged.

Written Explanation

Why test the unhappy path

Imagine Account.withdraw without its overdraft check. All of your "happy" tests (deposit, normal withdraw) still pass, and the bank now lets customers spend money they do not have. A test for the rejection is the only thing that notices when that check disappears. For each rule that says "must be rejected", write a test that proves it.

assertThrows

java
@Test
void withdrawOfNegativeAmountIsRejected() {
    Account account = new Account("Sam", 100);
    assertThrows(IllegalArgumentException.class, () -> account.withdraw(-1));
}

assertThrows(ExceptionType.class, code) runs code and passes only if that code throws an exception of the given type. The second argument is a lambda: () -> account.withdraw(-1) means "a piece of code with no inputs, to be run later by assertThrows". The lambda is needed because the call must happen inside assertThrows, where JUnit can catch the exception. If you wrote account.withdraw(-1) as a normal statement before it, the exception would escape and the test would fail with an error instead.

Notice that the test still follows the AAA shape: Arrange (the account), then Act and Assert together, because the call and the check are one statement.

The test fails in two ways

assertThrows fails if:

  1. No exception is thrown. Real output when we asked for IllegalArgumentException from a valid deposit(5):
text
org.opentest4j.AssertionFailedError: Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.
  1. A different type is thrown. Real output when we expected InsufficientFundsException from withdraw(-1) (which really throws IllegalArgumentException):
text
org.opentest4j.AssertionFailedError: Unexpected exception type thrown, expected: <InsufficientFundsException> but was: <java.lang.IllegalArgumentException>

Both failures are informative: they say what you expected and what happened.

A subtlety: a subtype is accepted. In our run, assertThrows(RuntimeException.class, () -> account.withdraw(-1)) passed, because IllegalArgumentException is a kind of RuntimeException. That makes a too-general type a weak test. Name the most specific type your code promises. (JUnit also has assertThrowsExactly for an exact match, which we do not need yet.)

Checked exceptions need no extra code

InsufficientFundsException is checked, and withdraw declares it. You might expect to wrap the call in try/catch. You do not: the lambda that assertThrows accepts is allowed to throw anything. Inside assertThrows(InsufficientFundsException.class, () -> account.withdraw(150)) the code compiles as written.

If you call withdraw directly in a test (for a normal, allowed withdrawal), the compiler does require you to deal with the checked exception. The simplest way is to declare throws InsufficientFundsException on the test method:

java
@Test
void withdrawSubtractsFromBalance() throws InsufficientFundsException {
    Account account = new Account("Sam", 100);
    account.withdraw(40);
    assertEquals(60, account.getBalance());
}

If the exception is thrown unexpectedly, JUnit reports the test as failed with that exception, which is what you want.

Checking the message and data

assertThrows returns the exception that was thrown, so you can look at it:

java
InsufficientFundsException e = assertThrows(InsufficientFundsException.class,
        () -> account.withdraw(150));
assertEquals("Cannot withdraw 150.00: balance is only 100.00", e.getMessage());
assertEquals(50, e.getShortfall(), 0.001);

Use this when the message or data is part of the promise. Do not check the exact message text just for decoration, since it makes the test break whenever someone improves the wording. (Also note: the course's exception builds its message with String.format("%.2f"), which on a computer with certain regional settings may print a comma instead of a decimal point, so a test that compares the whole message text can fail on such a machine. The shortfall check has no such risk.)

Reject, and leave things unchanged

A refused operation should not half-succeed. After the rejection, check the state:

java
@Test
void rejectedWithdrawLeavesBalanceUnchanged() {
    Account account = new Account("Sam", 100);
    assertThrows(InsufficientFundsException.class, () -> account.withdraw(150));
    assertEquals(100, account.getBalance());
}

Here the Act-and-Assert of the exception is followed by a second assertion about the same behavior ("a rejected withdrawal changes nothing"). This is one reason to fail, as lesson 2 allows.

One bad case per test

Write a separate test for each bad input: a negative amount, a zero amount, an overdraft. If you put three assertThrows calls in one test, the first failure hides the others, and the name no longer says which case broke.

A quick recipe

For any "must be rejected" rule, follow four steps: (1) write the rule as a sentence ("withdrawing zero is rejected"); (2) turn the sentence into a method name; (3) arrange the smallest object that makes the rule matter (here, an account with 100); (4) put the one call that should fail inside the lambda. Then deliberately remove the check from the code, run the test, and confirm it goes red. A test you have never seen fail is a test you have not yet shown to work.

Examples

Example 1: one rejection (simple)

java
@Test
void depositOfNegativeAmountIsRejected() {
    Account account = new Account("Sam", 100);
    assertThrows(IllegalArgumentException.class, () -> account.deposit(-5));
}

It passed against the correct Account. Read it as a sentence: "depositing a negative amount is rejected with an IllegalArgumentException".

Example 2: constructor and overdraft (practical)

java
@Test
void negativeOpeningBalanceIsRejected() {
    assertThrows(IllegalArgumentException.class, () -> new Account("Sam", -1));
}

@Test
void withdrawMoreThanBalanceIsRejected() {
    Account account = new Account("Sam", 100);
    InsufficientFundsException e = assertThrows(InsufficientFundsException.class,
            () -> account.withdraw(150));
    assertEquals(50, e.getShortfall(), 0.001);
}

Both passed. The first shows that a constructor call can be the code in the lambda, with no object arranged beforehand. The second uses the returned exception to check the data it carries (a shortfall of 50).

Example 3: the test that catches a deleted rule (realistic)

Suppose someone "simplifies" withdraw by deleting the amount > balance check. The happy-path tests still pass: a deposit works, a small withdraw works. The test from the Written Explanation does not:

java
assertThrows(InsufficientFundsException.class, () -> account.withdraw(150));

It would fail with Expected InsufficientFundsException to be thrown, but nothing was thrown. (the same message format we saw above for another type), and the author would see it before the change was shared. We demonstrated that message with a different method; we did not run this exact deletion here, but the failure form is the one shown.

Backend Insight

A backend service rejects bad input all day: a negative quantity, a missing field, an unknown product. Each rejection rule is a business rule that someone designed, and each deserves a test, because changes to validation code are common and a lost check often goes unnoticed until it causes harm (money, data, or security). In later modules, a rejection becomes an HTTP error response; the test you write now for "the method throws" becomes the test for "the endpoint returns a 4xx error".

The same advice about specificity applies there: tests should check that the right kind of failure occurs, not merely that something went wrong.

Common Mistakes

Mistake 1: Calling the code outside the lambda

  • Incorrect: account.withdraw(-1); assertThrows(...): the exception escapes before assertThrows runs.
  • Correct: put the call inside the lambda: () -> account.withdraw(-1).

Mistake 2: Expecting too general a type

assertThrows(Exception.class, ...) passes for almost any failure, including a bug you did not intend (such as a NullPointerException). Use the specific type.

Mistake 3: Using try/catch and forgetting fail

  • Incorrect: try { account.withdraw(-1); } catch (IllegalArgumentException e) { }: if nothing is thrown, the test still passes.
  • Correct: assertThrows, which fails when nothing is thrown.

Mistake 4: Testing several bad inputs in one test

When the second assertThrows never runs because the first failed, you learn about one bug at a time. One test per bad case.

Exercises

Level A: Foundation

A1. What does assertThrows(IllegalArgumentException.class, () -> a.deposit(0)) check?

Show solution A1

A1. That depositing 0 throws an IllegalArgumentException (or a subtype of it). It fails if nothing is thrown or another type is thrown.

A2. Name the two ways an assertThrows test can fail.

Show solution A2

A2. No exception is thrown; or an exception of a different type is thrown (one that is not the expected type or a subtype).

A3. Why is the code wrapped in () -> ... and not written as a normal statement?

Show solution A3

A3. The call must run inside assertThrows, where JUnit can catch the exception. A normal statement would throw before assertThrows could handle it.

Level B: Application

B1. Write a test proving that new Account("Sam", -50) throws IllegalArgumentException.

Show solution B1

B1.

java
@Test
void negativeOpeningBalanceIsRejected() {
    assertThrows(IllegalArgumentException.class, () -> new Account("Sam", -50));
}

B2. Write a test proving that withdraw(0) is rejected with IllegalArgumentException, and that the balance of a 100-account is still 100 afterwards.

Show solution B2

B2.

java
@Test
void withdrawOfZeroIsRejectedAndBalanceUnchanged() {
    Account account = new Account("Sam", 100);
    assertThrows(IllegalArgumentException.class, () -> account.withdraw(0));
    assertEquals(100, account.getBalance());
}

B3. Write a test that an overdraft of 130 from a balance of 100 reports a shortfall of 30.

Show solution B3

B3.

java
@Test
void overdraftReportsShortfall() {
    Account account = new Account("Sam", 100);
    InsufficientFundsException e = assertThrows(InsufficientFundsException.class,
            () -> account.withdraw(130));
    assertEquals(30, e.getShortfall(), 0.001);
}

We compiled and ran all three solutions; they passed.

Level C: Challenge

C1. Someone replaces assertThrows(IllegalArgumentException.class, ...) with assertThrows(Exception.class, ...) "to make it pass". Explain what the test no longer protects against.

Show solution C1

C1. Exception accepts every exception type, so the test now passes even when the code throws something unrelated, such as a NullPointerException from a bug. It no longer proves that the rejection is the intended IllegalArgumentException.

C2. The test try { account.withdraw(-1); } catch (IllegalArgumentException e) { assertEquals("Withdrawal must be positive, was -1.0", e.getMessage()); } passes even if withdraw(-1) does nothing at all. Why? Rewrite it with assertThrows so it fails in that case.

Show solution C2

C2. If nothing is thrown, the catch block never runs, so no assertion runs and the test passes. Rewrite:

java
@Test
void withdrawOfNegativeAmountReportsTheAmount() {
    Account account = new Account("Sam", 100);
    IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
            () -> account.withdraw(-1));
    assertEquals("Withdrawal must be positive, was -1.0", e.getMessage());
}

Mini Challenge

Take a small class of your own that validates input (for example, a Temperature class that rejects values below absolute zero, or a Person class that rejects a negative age). Write four tests: one valid case, and three different bad inputs, each with assertThrows and the most specific exception type. For one of them, check the message or a data field.

Check your work: each bad case is a separate test with a behavior name; you replace the validation with nothing and confirm each rejection test fails with "nothing was thrown"; and you can say why try/catch would not have caught that.

Summary & Next Lesson

Key takeaways

  • Write a test for every "must be rejected" rule, or a lost check goes unnoticed.
  • assertThrows(Type.class, () -> code) passes only if the code throws that type (or a subtype).
  • It fails if nothing is thrown or if the wrong type is thrown, with a message that says which.
  • It returns the exception, so you can check the message or data. Do not over-specify wording.
  • One bad case per test; also check that a rejected operation changed nothing.

Next lesson

The next lesson, "Boundaries and equivalence classes", is about choosing which inputs to test, so that a small number of tests finds the bugs that matter.

Self-check

  1. When does assertThrows fail?
  2. Why is the call written as a lambda?
  3. Why is assertThrows(Exception.class, ...) a weak test?
  4. What does assertThrows return, and how can you use it?
  5. After a rejected withdrawal, what extra thing should you check?
Show answers
  1. When no exception is thrown, or the exception is not of the expected type (or a subtype).
  2. So the code runs inside assertThrows, where JUnit can catch the exception.
  3. It passes for any exception, so it cannot tell the intended rejection from an unrelated bug.
  4. The thrown exception; you can check its message or data.
  5. That the object's state is unchanged (for example, the balance).

References

  • JUnit documentation, Writing Tests: Assertions. Supports that assertThrows returns the thrown exception so it can be inspected (its example checks an exception's message).
  • JUnit 5 Assertions Javadoc. Lists the assertThrows overloads and assertThrowsExactly. The page text I could read did not describe the failure conditions, so the statements that the test fails for "nothing thrown" and "wrong type", and that a subtype passes, come from running JUnit 5.12.2 myself (the messages above are real output), not from the documentation.
  • Code and output were compiled and run on JDK 21 with the Account and InsufficientFundsException classes from the exceptions module (BEG-029). The Example 3 deletion scenario was not run.

Practice

Apply this lesson with the module's lab, exercises and quiz.

Lab and exercises Quiz

Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.

Continue to next lesson

Type to search levels, phases, modules and lessons.