Expected Exceptions
Testing that your code correctly throws exceptions when it should is an important part of comprehensive testing. TestNG provides several ways to test exception handling in your code.
Using expectedExceptions Attribute
The simplest way to test for exceptions in TestNG is to use the expectedExceptions attribute of the @Test annotation:
import org.testng.annotations.Test;
public class BasicExceptionTest {
@Test(expectedExceptions = ArithmeticException.class)
public void testDivisionByZero() {
int result = 10 / 0; // This should throw ArithmeticException
}
@Test(expectedExceptions = NullPointerException.class)
public void testNullReference() {
String str = null;
int length = str.length(); // This should throw NullPointerException
}
@Test(expectedExceptions = IllegalArgumentException.class)
public void testIllegalArgument() {
validateAge(-5); // This should throw IllegalArgumentException
}
private void validateAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("Age cannot be negative");
}
}
}
In these examples, the test will pass if the expected exception is thrown, and fail if either no exception is thrown or a different exception is thrown.
Testing Multiple Exception Types
You can specify multiple exception types in the expectedExceptions attribute:
import org.testng.annotations.Test;
public class MultipleExceptionTypesTest {
@Test(expectedExceptions = {ArithmeticException.class, NullPointerException.class})
public void testMultipleExceptionTypes() {
// This test will pass if either ArithmeticException or NullPointerException is thrown
boolean throwArithmeticException = Math.random() > 0.5;
if (throwArithmeticException) {
int result = 10 / 0; // Throws ArithmeticException
} else {
String str = null;
int length = str.length(); // Throws NullPointerException
}
}
}
Exception Message Validation
Sometimes it's not enough to verify that an exception is thrown; you may also want to validate the exception message to ensure it contains the expected information.
Using expectThrows
TestNG provides the Assert.expectThrows method, which allows you to capture the thrown exception and then perform assertions on it:
import org.testng.Assert;
import org.testng.annotations.Test;
public class ExceptionMessageTest {
@Test
public void testExceptionMessage() {
IllegalArgumentException exception = Assert.expectThrows(
IllegalArgumentException.class,
() -> validateAge(-5)
);
String expectedMessage = "Age cannot be negative";
String actualMessage = exception.getMessage();
Assert.assertEquals(actualMessage, expectedMessage, "Exception message should match");
}
private void validateAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("Age cannot be negative");
}
}
}
Using try-catch Blocks
You can also use traditional try-catch blocks to test exceptions and their messages:
import org.testng.Assert;
import org.testng.annotations.Test;
public class TryCatchExceptionTest {
@Test
public void testExceptionWithTryCatch() {
try {
validateUsername("");
Assert.fail("Expected IllegalArgumentException was not thrown");
} catch (IllegalArgumentException e) {
Assert.assertEquals(e.getMessage(), "Username cannot be empty",
"Exception message should match");
}
}
private void validateUsername(String username) {
if (username == null || username.isEmpty()) {
throw new IllegalArgumentException("Username cannot be empty");
}
}
}
Testing with Java 21 Pattern Matching for Exceptions
Java 21's enhanced pattern matching capabilities can be used to create more expressive exception tests.
Using Pattern Matching in Catch Blocks
Java 21 allows for more precise exception handling using pattern matching in catch blocks:
import org.testng.Assert;
import org.testng.annotations.Test;
import java.io.FileNotFoundException;
import java.io.IOException;
public class PatternMatchingExceptionTest {
@Test
public void testExceptionPatternMatching() {
try {
readFile("nonexistent.txt");
Assert.fail("Expected IOException was not thrown");
} catch (IOException e) {
// Using pattern matching to handle specific exception types
switch (e) {
case FileNotFoundException fnf -> {
Assert.assertTrue(fnf.getMessage().contains("nonexistent.txt"),
"Exception message should contain the filename");
}
case IOException io when io.getMessage().contains("Permission denied") -> {
Assert.fail("Unexpected permission error: " + io.getMessage());
}
default -> {
Assert.fail("Unexpected IOException: " + e.getMessage());
}
}
}
}
private void readFile(String filename) throws IOException {
throw new FileNotFoundException("File not found: " + filename);
}
}
Using Record Patterns with Exceptions
Java 21's record patterns can be combined with exception handling for more structured testing:
import org.testng.Assert;
import org.testng.annotations.Test;
public class RecordPatternExceptionTest {
// Define a record to represent validation errors
record ValidationError(String field, String message) {}
// Custom exception that uses the record
class ValidationException extends Exception {
private final ValidationError error;
public ValidationException(ValidationError error) {
super(error.field() + ": " + error.message());
this.error = error;
}
public ValidationError getError() {
return error;
}
}
@Test
public void testValidationException() {
try {
validateUser("", "password123");
Assert.fail("Expected ValidationException was not thrown");
} catch (ValidationException e) {
// Using record pattern to destructure the validation error
if (e.getError() instanceof ValidationError(String field, String message)) {
Assert.assertEquals(field, "username", "Error field should be 'username'");
Assert.assertEquals(message, "Username cannot be empty",
"Error message should match");
} else {
Assert.fail("Record pattern matching failed");
}
}
}
private void validateUser(String username, String password) throws ValidationException {
if (username == null || username.isEmpty()) {
throw new ValidationException(
new ValidationError("username", "Username cannot be empty")
);
}
if (password == null || password.isEmpty()) {
throw new ValidationException(
new ValidationError("password", "Password cannot be empty")
);
}
}
}
Testing Exception Hierarchies
In real-world applications, you often have hierarchies of exceptions. TestNG allows you to test these hierarchies effectively.
Testing Parent and Child Exceptions
You can test that a method throws an exception that is a subclass of an expected parent exception:
import org.testng.Assert;
import org.testng.annotations.Test;
import java.io.FileNotFoundException;
import java.io.IOException;
public class ExceptionHierarchyTest {
@Test(expectedExceptions = IOException.class)
public void testParentException() {
// This will pass because FileNotFoundException is a subclass of IOException
throw new FileNotFoundException("File not found");
}
@Test
public void testSpecificChildException() {
IOException exception = Assert.expectThrows(
IOException.class,
() -> { throw new FileNotFoundException("File not found"); }
);
// Check if the exception is of the specific child type
Assert.assertTrue(exception instanceof FileNotFoundException,
"Exception should be FileNotFoundException");
if (exception instanceof FileNotFoundException) {
FileNotFoundException fnf = (FileNotFoundException) exception;
Assert.assertTrue(fnf.getMessage().contains("not found"),
"Exception message should contain 'not found'");
}
}
}
Using Java 21's Pattern Matching for instanceof
Java 21's enhanced pattern matching for instanceof can simplify exception hierarchy testing:
import org.testng.Assert;
import org.testng.annotations.Test;
import java.io.FileNotFoundException;
import java.io.IOException;
public class PatternMatchingInstanceofTest {
@Test
public void testExceptionHierarchyWithPatternMatching() {
IOException exception = Assert.expectThrows(
IOException.class,
() -> { throw new FileNotFoundException("File not found: config.xml"); }
);
// Using pattern matching for instanceof
if (exception instanceof FileNotFoundException fnf) {
// Direct access to the casted variable
Assert.assertTrue(fnf.getMessage().contains("config.xml"),
"Exception message should contain the filename");
} else {
Assert.fail("Exception should be FileNotFoundException");
}
}
}
Testing Custom Exceptions
In most real-world applications, you'll define custom exceptions specific to your domain. TestNG works seamlessly with custom exceptions.
Defining and Testing Custom Exceptions
import org.testng.Assert;
import org.testng.annotations.Test;
public class CustomExceptionTest {
// Custom exception class
class InsufficientFundsException extends Exception {
private final double requested;
private final double available;
public InsufficientFundsException(double requested, double available) {
super("Insufficient funds: requested $" + requested +
" but only $" + available + " available");
this.requested = requested;
this.available = available;
}
public double getRequested() {
return requested;
}
public double getAvailable() {
return available;
}
}
class BankAccount {
private double balance;
public BankAccount(double initialBalance) {
this.balance = initialBalance;
}
public void withdraw(double amount) throws InsufficientFundsException {
if (amount > balance) {
throw new InsufficientFundsException(amount, balance);
}
balance -= amount;
}
public double getBalance() {
return balance;
}
}
@Test(expectedExceptions = InsufficientFundsException.class)
public void testInsufficientFunds() throws InsufficientFundsException {
BankAccount account = new BankAccount(100.0);
account.withdraw(150.0); // Should throw InsufficientFundsException
}
@Test
public void testExceptionDetails() {
BankAccount account = new BankAccount(100.0);
InsufficientFundsException exception = Assert.expectThrows(
InsufficientFundsException.class,
() -> account.withdraw(150.0)
);
Assert.assertEquals(exception.getRequested(), 150.0,
"Requested amount should be 150.0");
Assert.assertEquals(exception.getAvailable(), 100.0,
"Available amount should be 100.0");
Assert.assertTrue(exception.getMessage().contains("Insufficient funds"),
"Exception message should contain 'Insufficient funds'");
}
}
Using Java 21 Records for Custom Exceptions
Java 21's records can simplify custom exception creation and testing:
import org.testng.Assert;
import org.testng.annotations.Test;
public class RecordBasedExceptionTest {
// Using a record to represent the exception data
record FundsData(double requested, double available) {}
// Custom exception using the record
class InsufficientFundsException extends Exception {
private final FundsData data;
public InsufficientFundsException(FundsData data) {
super("Insufficient funds: requested $" + data.requested() +
" but only $" + data.available() + " available");
this.data = data;
}
public FundsData getData() {
return data;
}
}
class BankAccount {
private double balance;
public BankAccount(double initialBalance) {
this.balance = initialBalance;
}
public void withdraw(double amount) throws InsufficientFundsException {
if (amount > balance) {
throw new InsufficientFundsException(
new FundsData(amount, balance)
);
}
balance -= amount;
}
}
@Test
public void testRecordBasedException() {
BankAccount account = new BankAccount(100.0);
InsufficientFundsException exception = Assert.expectThrows(
InsufficientFundsException.class,
() -> account.withdraw(150.0)
);
// Using record pattern matching to destructure the exception data
if (exception.getData() instanceof FundsData(double requested, double available)) {
Assert.assertEquals(requested, 150.0, "Requested amount should be 150.0");
Assert.assertEquals(available, 100.0, "Available amount should be 100.0");
} else {
Assert.fail("Record pattern matching failed");
}
}
}
Testing Exception Timing
In some cases, you might want to test not just that an exception is thrown, but when it's thrown. TestNG's timeout feature can be combined with exception testing for this purpose.
Combining Timeouts with Exception Testing
import org.testng.annotations.Test;
public class ExceptionTimingTest {
@Test(expectedExceptions = RuntimeException.class, timeOut = 1000)
public void testExceptionWithinTimeout() {
// This test will pass if RuntimeException is thrown within 1 second
throw new RuntimeException("Immediate exception");
}
@Test(expectedExceptions = RuntimeException.class, timeOut = 1000)
public void testDelayedExceptionWithinTimeout() throws InterruptedException {
// This test will pass if RuntimeException is thrown within 1 second
Thread.sleep(500); // Wait for 500ms
throw new RuntimeException("Delayed exception");
}
@Test(expectedExceptions = RuntimeException.class, timeOut = 1000,
expectedExceptionsMessageRegExp = ".*timeout.*")
public void testExceptionMessageWithTimeout() {
// This test will pass if RuntimeException with message containing "timeout"
// is thrown within 1 second
throw new RuntimeException("Operation timed out");
}
}