Catching exceptions is half the story. Throwing them is the other half — refusing to construct an invalid object, surfacing a missing fixture, signalling that an API response didn't conform. Java has two keywords for the throwing side: throw (the verb — "raise this exception now") and throws (the contract — "this method might raise these kinds of exceptions"). Together with custom exception classes, they turn vague test failures into specific, structured errors that carry useful context to whoever reads the report.
throw — raise an exception
You've already seen throw in earlier lessons. The full picture:
public class RetryConfig {
private int retries;
public void setRetries(int retries) {
if (retries < 0) {
throw new IllegalArgumentException("retries cannot be negative: " + retries);
}
this.retries = retries;
}
public static void main(String[] args) {
RetryConfig cfg = new RetryConfig();
cfg.setRetries(3); // ok
try {
cfg.setRetries(-1);
} catch (IllegalArgumentException e) {
System.out.println("Refused: " + e.getMessage());
}
}
}Output:
Refused: retries cannot be negative: -1
throw new ExceptionType(message) does two things: builds a new exception object (with the given message and a stack trace captured automatically) and unwinds the call stack until something catches it. The exception class can be any subclass of Throwable — built-in (IllegalArgumentException, IllegalStateException, etc.) or your own.
throws — declare which checked exceptions a method might raise
For checked exceptions (lesson 1), Java forces the calling code to handle them. You declare the method's possible failures with throws ExceptionType after the parameter list:
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class FileHelpers {
public static String readConfig(String path) throws IOException {
return Files.readString(Path.of(path));
}
}Callers must either catch the IOException or declare it themselves:
public static void main(String[] args) {
try {
String content = FileHelpers.readConfig("config.json");
System.out.println(content);
} catch (IOException e) {
System.out.println("Could not read config: " + e.getMessage());
}
}Multiple checked exceptions are comma-separated: throws IOException, SQLException. You don't need throws for unchecked exceptions (RuntimeException and its subclasses) — those propagate freely.
Custom exception classes
Built-in exception types describe what went wrong; sometimes you want to describe the domain. A TestDataException reads more clearly in a stack trace than RuntimeException ever will — and you can catch it specifically without accidentally catching unrelated runtime errors.
public class TestDataException extends RuntimeException {
public TestDataException(String message) {
super(message);
}
public TestDataException(String message, Throwable cause) {
super(message, cause);
}
}Two constructors are the standard pair: one with just a message (for cases where you're inventing the error), one with a cause (for wrapping a lower-level exception while keeping its stack trace). The two-arg version is what makes "could not load fixture: users.json — caused by FileNotFoundException" possible in a single chain.
Using it:
public class FixtureLoader {
public static List<String> loadUsers(String path) {
try {
return Files.readAllLines(Path.of(path));
} catch (IOException e) {
throw new TestDataException("Failed to load fixture: " + path, e); // wrap with cause
}
}
public static void main(String[] args) {
try {
loadUsers("missing.txt");
} catch (TestDataException e) {
System.out.println("DOMAIN: " + e.getMessage());
System.out.println("CAUSE: " + e.getCause());
}
}
}Output:
DOMAIN: Failed to load fixture: missing.txt
CAUSE: java.nio.file.NoSuchFileException: missing.txt
The framework caller sees TestDataException — a concept they understand. The getCause() chain still has the original IOException for anyone who needs to debug deeper. That's the value of wrapping: you change the type without losing the trace.
Checked vs unchecked — which to extend
Two parents to choose from:
- Extend
RuntimeException(or any of its subclasses) — your custom exception is unchecked. Callers don't have to declare or catch it. This is the right default for "the test framework refused to do something" exceptions:TestDataException,PageLoadException,ConfigurationException. Most modern Java code prefers unchecked exceptions because they don't pollute every method signature withthrows. - Extend
Exception(directly) — your custom exception is checked. Callers must catch or declare. Use this when you really want the compiler to enforce handling — typically for I/O-style failures the caller can recover from. In test frameworks this is rare; runtime exceptions are usually right.
A safe rule: extend RuntimeException unless you have a specific reason to make callers handle it.
A real custom exception — PageLoadException
public class PageLoadException extends RuntimeException {
private final String url;
private final long waitedMs;
public PageLoadException(String url, long waitedMs, Throwable cause) {
super("Page failed to load: " + url + " (waited " + waitedMs + "ms)", cause);
this.url = url;
this.waitedMs = waitedMs;
}
public String getUrl() { return url; }
public long getWaitedMs() { return waitedMs; }
}A custom exception can hold fields, not just a message. The page URL and the wait duration are now first-class on the exception — a downstream test reporter can read them and produce a structured failure record without parsing strings:
try {
loginPage.open();
} catch (PageLoadException e) {
report.recordFailure(e.getUrl(), e.getWaitedMs(), e.getCause());
}This is the difference between exception as error message and exception as data structure. The former is fine; the latter is what real test frameworks ship.
The exception family tree
- – OutOfMemoryError
- – StackOverflowError
- – (don't catch)
- – IOException
- – SQLException
- – InterruptedException
- – must catch or declare
- – NullPointerException
- – IllegalArgumentException
- – NumberFormatException
- – your custom test exceptions
- TestDataException –
- PageLoadException –
- ConfigurationException –
Read top down: Throwable is the root; Error is the no-go zone; Exception splits into checked and unchecked (RuntimeException); your domain types extend RuntimeException for unchecked, or Exception for checked. Put a sticky note over your monitor with this tree until it sits in muscle memory.
A small framework that uses both
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class TestDataException extends RuntimeException {
public TestDataException(String msg) { super(msg); }
public TestDataException(String msg, Throwable c) { super(msg, c); }
}
public class FixtureLoader {
public static String loadJson(String path) {
if (path == null || path.isBlank()) {
throw new IllegalArgumentException("path must be non-empty"); // built-in, programmer error
}
try {
return Files.readString(Path.of(path));
} catch (IOException e) {
throw new TestDataException("Could not read " + path, e); // domain, with cause
}
}
public static void main(String[] args) {
try { loadJson(""); }
catch (IllegalArgumentException e) { System.out.println("ARG: " + e.getMessage()); }
try { loadJson("missing.json"); }
catch (TestDataException e) {
System.out.println("DOMAIN: " + e.getMessage());
System.out.println("CAUSE: " + e.getCause().getClass().getSimpleName());
}
}
}Output:
ARG: path must be non-empty
DOMAIN: Could not read missing.json
CAUSE: NoSuchFileException
Two flavours of failure, two different exception types, two different responses. Built-in IllegalArgumentException for "you called this method wrongly"; custom TestDataException for "the test fixture is broken." A top-level catch on IllegalArgumentException would flag programmer bugs; a catch on TestDataException would mark a missing-data failure. Distinct types let downstream reporting be precise.
⚠️ Common mistakes
- Throwing the cause and losing the trace.
throw new RuntimeException("boom");inside a catch obliterates the original exception's stack. Always pass the cause:throw new RuntimeException("boom", e);. The two-arg constructor is the difference between a useful test report and a useless one. - Using
throws Exceptionto silence the compiler. Declaringthrows Exceptionon every method satisfies the checked-exception rules but makes the contract meaningless — callers can't tell what actually fails. Throw and declare specific types; reservethrows Exceptionfor genuinely generic boundaries. - Custom exceptions that don't extend the right parent.
class MyException extends Throwableworks but isn't idiomatic and forces callers to catchThrowable. ExtendRuntimeException(unchecked) orException(checked). Pick based on whether you want to force callers to handle.
🎯 Practice task
Build a fixture loader with custom exceptions. 25-30 minutes.
- Create
TestDataException.java.public class TestDataException extends RuntimeExceptionwith both the one-arg and two-arg constructors that delegate tosuper(...). - Create
FixtureLoader.java. Implementpublic static String loadJson(String path)that:- throws
IllegalArgumentExceptionifpathis null or blank - reads the file with
Files.readString(Path.of(path)) - catches
IOExceptionand re-throws asTestDataException("Could not read " + path, e)
- throws
- In a
Demomain, callloadJson(""), thenloadJson("missing.json"), thenloadJson("real.json")(create the file with one or two real JSON lines first). - Catch each exception type separately and print a different message. Include
e.getCause()for the wrapped one. - Add a method
static String loadJson(String path) throws IOException(note thethrows) that does not wrap — it just letsIOExceptionpropagate. Notice what changes for the caller (forced try/catch or its ownthrows). - Stretch: define a
class PageLoadException extends RuntimeExceptionthat holds aString urlandlong waitedMs. Throw one from a fakeopenPagemethod when a wait is exceeded. In the catch, read the structured fields and print them. That's the difference between exception-as-string and exception-as-data.
Lesson 3 puts these tools to work for real: reading and writing files, including the try-with-resources pattern that makes resource cleanup automatic.