Hero Background

Power Your Software Testing with AI Agents and Cloud

The Native AI-Agentic Cloud Platform to Supercharge Quality Engineering. Test Intelligently and Ship Faster.

Testing

JUnit Exception Testing: assertThrows, Expected Exceptions

Test exceptions in JUnit 5 and 6 with assertThrows, assertThrowsExactly, and assertDoesNotThrow, check messages and causes, and migrate JUnit 4 @Test(expected).

Published on:

Java is still the language of 29.6% of professional developers in the Stack Overflow Developer Survey 2025, and every one of those codebases throws exceptions on purpose: a zero divisor, a missing record, an expired token. A test that only covers the happy path leaves those branches unverified. This chapter covers JUnit exception testing in JUnit 5 and 6: assertThrows, assertThrowsExactly, and assertDoesNotThrow, what the framework does with an exception you did not expect, how JUnit 4 did it, and what the failures look like when the code under test gets it wrong.

The JUnit Assertions chapter covers the rest of the Assertions class; this one goes deep on the three exception methods.

TL;DR

To test an exception in JUnit 5 or 6, wrap the call in assertThrows(Type.class, () -> ...), keep the returned exception, and assert on its message or cause. assertThrowsExactly rejects subclasses, assertDoesNotThrow guards the happy path, and any exception a test does not catch simply fails the test.

  • assertThrows - passes when the lambda throws the expected type or a subclass of it, and returns the exception for further assertions.
  • assertThrowsExactly - the same contract but the thrown class must match exactly, which catches a subclass that signals a different bug.
  • assertDoesNotThrow - fails with the exception's details if anything is thrown, and returns the lambda's value otherwise.
  • JUnit 4 migration - @Test(expected = ...) and the ExpectedException rule map to assertThrows; JUnit 4.13 already ships Assert.assertThrows.
  • At scale - exception tests are cheap to run but expensive to triage; TestMu AI HyperExecute groups failures by error type so the same message across fifty tests reads as one problem.

How Do You Test Exceptions in JUnit?

The JUnit user guide's exception handling page documents three assertions for the job, all static methods on org.junit.jupiter.api.Assertions: assertThrows for an expected type or its subclasses, assertThrowsExactly for an exact type, and assertDoesNotThrow for code that must not fail. Each takes an Executable, a lambda that is allowed to throw anything, so checked exceptions need no try block in the test.

The code under test in every example below is a two-method Calculator whose divide() throws IllegalArgumentException("divisor must not be zero") for a zero divisor. Which assertion you reach for depends on what the test is protecting:

You want to prove thatUseWhy
The method rejects bad input with a specific exceptionassertThrows + assertEquals on getMessage()Checks the type and the message the caller will see
A subclass would mean a different bugassertThrowsExactlyIllegalArgumentException passes; a NumberFormatException thrown by accident fails
A refactor did not introduce a new throwassertDoesNotThrowReports the unexpected exception with its stack trace instead of a bare failure
A wrapped exception carries the original causeassertThrows, then assertInstanceOf on getCause()Verifies the chain, not just the outer type
Several inputs all fail the same way@ParameterizedTest around assertThrowsOne method covers every row of bad input

What Does assertThrows Check and Return?

assertThrows(Class, Executable) runs the lambda, fails the test if nothing is thrown or if the thrown type is not assignable to the expected class, and returns the exception typed as the expected class. That return value is the point: it lets one test check the type and the message, which the JUnit 4 annotation could never do.

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

class ExceptionsTest {

    private final Calculator calculator = new Calculator();

    @Test
    @DisplayName("divide() throws IllegalArgumentException for a zero divisor")
    void throwsForZeroDivisor() {
        IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> calculator.divide(10, 0));
        assertEquals("divisor must not be zero", error.getMessage());
    }

    @Test
    @DisplayName("assertThrows accepts a subclass of the expected type")
    void acceptsSubclass() {
        // RuntimeException is a supertype of IllegalArgumentException, so this passes.
        RuntimeException error = assertThrows(RuntimeException.class, () -> calculator.divide(10, 0));
        assertTrue(error instanceof IllegalArgumentException);
    }
}

These habits keep the tests honest. Put only the call that should throw inside the lambda; if setup code sits in there too, a bug in the setup can throw the expected type and the test passes for the wrong reason. And assert on the message or cause when the type is generic: a bare assertThrows(IllegalArgumentException.class, ...) says nothing about which argument was rejected.

When Should You Use assertThrowsExactly?

assertThrowsExactly has the same signature and the same return value, but the thrown class must equal the expected class; a subclass fails. The second test above passes with assertThrows because IllegalArgumentException extends RuntimeException. Change it to assertThrowsExactly(RuntimeException.class, ...) and it fails, which is exactly what you want when the production code wraps exceptions and a subclass leaking out would be a regression.

@Test
@DisplayName("assertThrowsExactly rejects subclasses")
void exactTypeOnly() {
    // Passes: divide() throws IllegalArgumentException itself, not a subclass of it.
    assertThrowsExactly(IllegalArgumentException.class, () -> calculator.divide(10, 0));
}

Reach for the exact form when the method's contract names a specific exception class and callers catch that class, or when a hierarchy such as NumberFormatException extends IllegalArgumentException makes the loose check too forgiving. Everywhere else assertThrows is the better default, because a future refinement of the exception type should not break the test.

How Do You Assert That No Exception Is Thrown?

A test method that throws fails anyway; what assertDoesNotThrow changes is the report. Without it, an unexpected exception shows up as an error with a stack trace; with it, the failure names the assertion and the exception together, and the method returns the lambda's value so the happy path can continue in the same test.

@Test
@DisplayName("assertDoesNotThrow documents the happy path")
void happyPathDoesNotThrow() {
    int result = assertDoesNotThrow(() -> calculator.divide(10, 2));
    assertEquals(5, result);
}

Use it when the whole point of the test is "this input used to throw and no longer does", which is the shape of most regression tests written after an exception bug. For a plain value check, a direct assertEquals reads better.

Test across 3000+ browser and OS environments with TestMu AI

What Happens When a Test Throws an Unexpected Exception?

The user guide states the rule in one sentence: if an exception is thrown from a test method, a lifecycle method, or an extension and not caught there, the framework marks the test or test class as failed. The consequences are worth spelling out, because they explain several confusing reports:

  • A failed assertion is an exception - assertEquals throws AssertionFailedError from the opentest4j library, so "failure" and "error" are the same mechanism; Surefire reports assertion failures under Failures and other exceptions under Errors.
  • @BeforeEach that throws fails every test in the class - each test's setup runs and throws, so the report shows one failure per test, all with the same stack trace. Fix the setup, not the tests.
  • @BeforeAll that throws fails the class once - no test runs; the class appears with a single error. The JUnit Annotations chapter covers the lifecycle order these methods run in.
  • @AfterEach still runs after a failure - the test's exception is recorded first, then teardown executes; if teardown also throws, both exceptions are reported.
  • Checked exceptions need no try block - a test method may declare throws Exception, and the Executable passed to the assertions is declared to throw Throwable, so wrapping in try/catch only hides the stack trace.

How Did JUnit 4 Test Expected Exceptions?

JUnit 4 offered two mechanisms, and both still show up in suites being migrated. The JUnit 4 @Test javadoc describes the first: an optional expected element that causes the test to succeed if and only if an exception of the specified class is thrown by the method.

// JUnit 4: passes only if IllegalArgumentException is thrown somewhere in the method
@Test(expected = IllegalArgumentException.class)
public void rejectsZeroDivisor() {
    calculator.divide(10, 0);
}

// JUnit 4: the ExpectedException rule, which can also check the message
@Rule
public ExpectedException thrown = ExpectedException.none();

@Test
public void rejectsZeroDivisorWithMessage() {
    thrown.expect(IllegalArgumentException.class);
    thrown.expectMessage("divisor must not be zero");
    calculator.divide(10, 0);
}

The annotation form has a design flaw: it cannot tell which statement threw, so a NullPointerException in a setup line a few statements earlier is a "pass" if the expected type happens to match. The ExpectedException rule fixed the message problem but not that one, and its javadoc now notes that since 4.13 Assert.assertThrows can be used to verify that your code throws a specific exception, with ExpectedException.none() marked deprecated. Migration is therefore mechanical: move the call into an assertThrows lambda, turn expectMessage into an assertEquals on the returned exception, and delete the rule. The JUnit 4 vs JUnit 5 chapter lists the other annotation changes that travel with it.

How Do You Test Exceptions With Mockito and Parameterized Tests?

Most real exception tests follow one of two patterns. The first drives several bad inputs through one assertion with @ParameterizedTest, so the test reads as a table of rejected values rather than three near-identical methods; the JUnit Parameterized Tests chapter covers the other argument sources.

@ParameterizedTest(name = "divide({0}, 0) throws")
@ValueSource(ints = { 1, -1, Integer.MAX_VALUE })
void everyDividendRejectsZero(int dividend) {
    assertThrows(IllegalArgumentException.class, () -> calculator.divide(dividend, 0));
}

The second tests how a service reacts when a dependency fails, which means making a mock throw. With Mockito, when(...).thenThrow(...) stubs a method that returns a value and doThrow(...).when(mock).method() stubs a void one; the service call then goes inside assertThrows as usual.

// OrderService.load() wraps any repository failure in OrderLookupException
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock private OrderRepository repository;
    @InjectMocks private OrderService service;

    @Test
    void wrapsRepositoryFailure() {
        when(repository.findById(42L)).thenThrow(new IllegalStateException("connection lost"));

        OrderLookupException error = assertThrows(OrderLookupException.class, () -> service.load(42L));

        assertEquals("connection lost", error.getCause().getMessage());
        verify(repository).findById(42L);
    }
}

If the project already uses AssertJ, its fluent form reads well for the same checks: the AssertJ documentation (3.27.7) shows assertThatThrownBy(...).isInstanceOf(Exception.class).hasMessageContaining("boom"), assertThatExceptionOfType(IOException.class).isThrownBy(...) with withMessage and withNoCause, and assertThatCode(...).doesNotThrowAnyException() as the counterpart of assertDoesNotThrow. Under the hood they catch the Throwable and assert on it, so mixing them with JUnit's own assertions in one class is fine. The Mockito with JUnit chapter explains the extension and the annotations in the Mockito example. When a test must report several exception-related failures at once instead of stopping at the first, JUnit's assertAll groups them; the JUnit 4 JUnit ErrorCollector chapter shows the older rule-based equivalent.

What Do Exception Test Failures Look Like?

Reading the failure message quickly is half the skill. I ran the tests above (seven, counting the three parameterized cases) and a second class with three deliberately wrong assertions on a TestMu AI HyperExecute Linux VM (job d4a48594, Java 17, Surefire 3.6.0) to capture the exact text JUnit 6.1.3 prints. The passing class first:

$ mvn -B test -Dtest=ExceptionsTest
[INFO] Running demo.ExceptionsTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.139 s -- in demo.ExceptionsTest
[INFO] BUILD SUCCESS

The failing class has one test that expects an exception from a call that does not throw, one that expects the wrong type, and one that uses assertThrowsExactly with a supertype. JUnit distinguishes the three cases in the message, so you rarely need the stack trace:

$ mvn -B test -Dtest=ExceptionFailureDemoTest
[ERROR] Tests run: 3, Failures: 3, Errors: 0, Skipped: 0, Time elapsed: 0.083 s <<< FAILURE! -- in demo.ExceptionFailureDemoTest

[ERROR] demo.ExceptionFailureDemoTest.nothingThrown -- Time elapsed: 0.039 s <<< FAILURE!
org.opentest4j.AssertionFailedError: Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.
	at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3234)
	at demo.ExceptionFailureDemoTest.nothingThrown(ExceptionFailureDemoTest.java:15)

[ERROR] demo.ExceptionFailureDemoTest.wrongType -- Time elapsed: 0.010 s <<< FAILURE!
org.opentest4j.AssertionFailedError: Unexpected exception type thrown, expected: <java.lang.IllegalStateException> but was: <java.lang.IllegalArgumentException>

[ERROR] demo.ExceptionFailureDemoTest.subclassRejectedByExactly -- Time elapsed: 0.005 s <<< FAILURE!
org.opentest4j.AssertionFailedError: Unexpected exception type thrown, expected: <java.lang.RuntimeException> but was: <java.lang.IllegalArgumentException>
	at org.junit.jupiter.api.Assertions.assertThrowsExactly(Assertions.java:3166)

[ERROR] Tests run: 3, Failures: 3, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE

All three are reported as Failures, not Errors, because assertThrows raises AssertionFailedError like any other assertion. The "but was" clause names the real type, so the fix for wrongType is visible without opening the code. And the third message is identical to the second even though the thrown exception is a subclass of the expected one; only the assertThrowsExactly frame in the trace tells you the strict check caused it, which is a good reason to use the exact form sparingly and comment why.

Best Practices for JUnit Exception Tests

  • Keep the lambda to one call - setup belongs before assertThrows, so the only statement that can throw is the one under test.
  • Assert the message or cause, not just the type - the type proves a branch was taken; the message proves it was the right branch.
  • Prefer assertThrows over assertThrowsExactly by default - switch to the exact form only when a subclass would be a bug.
  • Name the test after the rule being enforced - rejectsZeroDivisor tells the reader more than testDivideException.
  • Never catch and fail by hand - try { call(); fail(); } catch (Exception e) {} swallows the stack trace and passes on the wrong exception; the assertions exist to replace it.
  • Migrate @Test(expected) first - it is the pattern most likely to hide a bug, so convert it before the rest of a JUnit 4 suite.

Exception tests multiply fast, and fifty of them failing with the same message after a refactor is a triage problem more than a testing one. HyperExecute helps on that side: it reorders previously failing tests to run first, retries only commands that exit non-zero so a genuine assertion failure is never masked, and produces an error-categorized report that groups failures by message, so one root cause reads as one line rather than fifty. The setup is a single YAML file next to the pom, documented in the HyperExecute getting started guide.

Note

Note: Run the same JUnit project on TestMu AI's cloud when the tests drive a browser, across 3,000+ browser and OS combinations. Try TestMu AI free!

Conclusion

Find one @Test(expected = ...) or one hand-rolled try/fail/catch in your suite and rewrite it as assertThrows with an assertion on the message; that single change shows the difference. From there the rules of exception testing in JUnit are short: assertThrows by default, assertThrowsExactly when subclasses matter, assertDoesNotThrow for regression tests, and the lambda holds one call.

The next chapter, JUnit Ignore Test, covers @Disabled and the conditional annotations that skip a test instead of failing it. TestNG readers will find the same ground covered from that framework's side in TestNG Exception Tests.

Author

...

Himanshu Sheth

Blogs: 142

  • Twitter
  • Linkedin

Himanshu Sheth is the Director of Marketing (Technical Content) at TestMu AI, with over 8 years of hands-on experience in Selenium, Cypress, and other test automation frameworks. He has authored more than 130 technical blogs for TestMu AI, covering software testing, automation strategy, and CI/CD. At TestMu AI, he leads the technical content efforts across blogs, YouTube, and social media, while closely collaborating with contributors to enhance content quality and product feedback loops. He has done his graduation with a B.E. in Computer Engineering from Mumbai University. Before TestMu AI, Himanshu led engineering teams in embedded software domains at companies like Samsung Research, Motorola, and NXP Semiconductors. He is a core member of DZone and has been a speaker at several unconferences focused on technical writing and software quality.

Reviewer

...

Srinivasan Sekar

Reviewer

  • Linkedin

Srinivasan Sekar is Director of Engineering at TestMu AI (formerly LambdaTest), where he leads engineering and open-source initiatives behind the Selenium and Appium automation grid and owns TestMu AI's MCP Server. A committer to Appium and a contributor to Selenium, WebdriverIO, Taiko, and AppiumTestDistribution, he brings over 15 years of experience in quality engineering and open-source technologies. He is the author of the Apress book 'The MCP Standard: A Developer's Guide to Building Universal AI Tools with the Model Context Protocol,' a Certified Kubernetes and Cloud Native Associate, and an international conference speaker. Before TestMu AI he spent over eight years at Thoughtworks as a Principal Consultant and Quality Architect. Srinivasan holds a B.Tech in Information Technology from Anna University.

JUnit Exception Testing FAQs

Did you find this page helpful?

More Related Learning Hubs

TestMu AI forEnterprise

Get access to solutions built on Enterprise
grade security, privacy, & compliance

  • Advanced access controls
  • Advanced data retention rules
  • Advanced Local Testing
  • Premium Support options
  • Early access to beta features
  • Private Slack Channel
  • Unlimited Manual Accessibility DevTools Tests