Tests often verify that code THROWS the right exception. TestNG uses @Test(expectedExceptions = X.class); JUnit 5 uses assertThrows(X.class, () -> ...), which also lets you assert on the exception message.
TestNG: @Test(expectedExceptions = IllegalArgumentException.class) passes if that exception is thrown, and expectedExceptionsMessageRegExp can check the message. JUnit 4 used @Test(expected=...); JUnit 5 prefers assertThrows(IllegalArgumentException.class, () -> service.call(bad)), which RETURNS the thrown exception so you can assert on its message/cause — more precise because it scopes exactly WHICH line should throw (annotation-level expected can't tell which statement threw). Testing negative paths (validation errors, auth failures) is essential; assertThrows is the modern, precise approach.
Verifying validation: assertThrows(ValidationException.class, () -> service.create(invalid)) confirms the exact call throws, then assertEquals('email required', ex.getMessage()) checks the message.
Why is JUnit 5's assertThrows often preferred over the annotation-level 'expected exception' approach?