Throwing Custom Exceptions

8 min read

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 with throws.
  • 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

Throwable
  • – 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 Exception to silence the compiler. Declaring throws Exception on every method satisfies the checked-exception rules but makes the contract meaningless — callers can't tell what actually fails. Throw and declare specific types; reserve throws Exception for genuinely generic boundaries.
  • Custom exceptions that don't extend the right parent. class MyException extends Throwable works but isn't idiomatic and forces callers to catch Throwable. Extend RuntimeException (unchecked) or Exception (checked). Pick based on whether you want to force callers to handle.

🎯 Practice task

Build a fixture loader with custom exceptions. 25-30 minutes.

  1. Create TestDataException.java. public class TestDataException extends RuntimeException with both the one-arg and two-arg constructors that delegate to super(...).
  2. Create FixtureLoader.java. Implement public static String loadJson(String path) that:
    • throws IllegalArgumentException if path is null or blank
    • reads the file with Files.readString(Path.of(path))
    • catches IOException and re-throws as TestDataException("Could not read " + path, e)
  3. In a Demo main, call loadJson(""), then loadJson("missing.json"), then loadJson("real.json") (create the file with one or two real JSON lines first).
  4. Catch each exception type separately and print a different message. Include e.getCause() for the wrapped one.
  5. Add a method static String loadJson(String path) throws IOException (note the throws) that does not wrap — it just lets IOException propagate. Notice what changes for the caller (forced try/catch or its own throws).
  6. Stretch: define a class PageLoadException extends RuntimeException that holds a String url and long waitedMs. Throw one from a fake openPage method 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.

// tip to track lessons you complete and pick up where you left off across devices.