Raising and Creating Custom Exceptions

8 min read

The previous lesson covered catching exceptions other code raised. This one covers throwing your own — both the built-in classes (raise ValueError(...)) and the custom exception types every real test framework eventually grows. By the end you'll know when to raise, what to raise, how to chain exceptions to preserve a useful traceback, and how to design a small exception hierarchy for a test project.

The raise statement

You raise an exception by writing raise followed by an exception instance:

def set_timeout(seconds: int) -> int:
    if seconds <= 0:
        raise ValueError(f"Timeout must be positive, got {seconds}")
    return seconds
 
set_timeout(5)         # 5
set_timeout(-1)        # ValueError: Timeout must be positive, got -1

raise immediately stops the function and unwinds the call stack until something excepts it. If nothing catches it, the script crashes with the exception's traceback.

The exception object itself can be any class derived from BaseException. In practice you raise either a built-in (ValueError, TypeError, RuntimeError, KeyError) or one of your own custom classes.

When to raise — and what

A practical guide for QA helpers:

  • Bad argument value (right type, wrong content) → ValueError
  • Bad argument type (wrong type altogether) → TypeError
  • Looked up something that wasn't thereKeyError, IndexError, or a custom *NotFound class
  • State doesn't allow this operation (e.g. login required) → custom class like AuthRequired
  • Generic "something went wrong" → custom class like TestSetupErroravoid Exception directly

Raising a built-in is fine for low-level argument checks. For higher-level conditions ("test data file is missing", "API contract violated"), define a custom class so callers can except that specific failure mode.

Custom exception classes — the minimum

A custom exception is just a class that inherits from Exception:

class TestDataError(Exception):
    """Raised when test data is invalid or missing."""

That's a complete, working class. The docstring is optional but recommended — it shows up in help(TestDataError) and IDE tooltips.

Using it:

from pathlib import Path
import json
 
def load_fixture(path: str) -> dict:
    p = Path(path)
    if not p.exists():
        raise TestDataError(f"Fixture not found: {path}")
    return json.loads(p.read_text())
 
try:
    users = load_fixture("fixtures/missing.json")
except TestDataError as e:
    print(f"Skipping test: {e}")

Callers can now catch TestDataError specifically, separate from a generic Exception. That's the whole reason custom classes exist.

Adding fields — richer error context

A bare raise SomeError("message") is fine for simple cases. When the caller might want structured data (a status code, a path, a retry count), add an __init__:

class ApiError(Exception):
    def __init__(self, status_code: int, message: str):
        self.status_code = status_code
        self.message = message
        super().__init__(f"API error {status_code}: {message}")
 
try:
    raise ApiError(503, "service unavailable")
except ApiError as e:
    print(e.status_code)        # 503
    print(e.message)            # "service unavailable"
    if e.status_code >= 500:
        print("Will retry")

super().__init__(...) passes the rendered message to Exception's built-in __init__ so str(e) and the traceback show something readable. The extra attributes (status_code, message) are yours to design — anything the handler might want.

Re-raising — handle what you can, propagate the rest

Sometimes you want to log or note something, then let the exception keep propagating:

try:
    response = api.fetch_user(7)
except ApiError as e:
    logger.warning(f"fetch_user failed with {e.status_code}")
    raise        # re-raises the same exception, traceback intact

Bare raise (with no argument) inside an except block re-raises the current exception. You don't lose the original traceback — the caller still sees exactly where the problem started.

Exception chaining — raise X from Y

When you catch one exception and raise a different (usually higher-level) one, chain them so the traceback shows both:

try:
    response.raise_for_status()
except requests.HTTPError as e:
    raise TestSetupError("could not fetch fixture data") from e

The traceback becomes:

requests.exceptions.HTTPError: 503 Server Error: ...

The above exception was the direct cause of the following exception:

TestSetupError: could not fetch fixture data

The from e is what produces the direct cause line. Without it, Python still shows both, but with a less specific phrasing ("During handling of the above exception, another exception occurred"). Use from e when one exception is because of another, which is almost always.

To explicitly suppress chaining: raise NewError(...) from None.

Designing a small exception hierarchy

For a real test framework you usually want a base exception for the project plus a few specific subclasses:

class TestFrameworkError(Exception):
    """Base class for all errors this framework raises."""
 
 
class TestDataError(TestFrameworkError):
    """The fixture file is missing, malformed, or doesn't validate."""
 
 
class ApiError(TestFrameworkError):
    """A backend call failed in a way the test cares about."""
 
    def __init__(self, status_code: int, message: str):
        self.status_code = status_code
        super().__init__(f"API error {status_code}: {message}")
 
 
class ConfigError(TestFrameworkError):
    """The test config is missing a required value or has an invalid one."""

Why a base class? Two reasons:

  1. One handler can catch them all. except TestFrameworkError: handles every error your code raises while still letting unrelated exceptions (a KeyError from inside pytest's plumbing, a KeyboardInterrupt) propagate.
  2. Future subclassing. When you later need FixtureNotFoundError(TestDataError), callers that already catch TestDataError keep working.

The Python community calls this the "library root exception" pattern. Most production libraries (requests, django, boto3) follow it.

A QA framework's exception map

TestFrameworkError (base)
  • – FixtureNotFound
  • – FixtureSchemaError
  • – Auth required
  • – 5xx server error
  • – Contract mismatch
  • – Missing setting
  • – Invalid value
  • Selector not found –
  • Title mismatch –

A handful of specific subclasses, all anchored on one base. Tests that want fine-grained recovery catch the specific class; tests that just want to skip on any framework problem catch the base. The structure is small enough to fit in your head and rich enough to express what failed.

Re-raise vs raise-from-original — when each fits

Two patterns often confused:

  • Re-raise (raise) — same exception, same traceback. "I logged this, but it's still your problem."
  • Raise from (raise New(...) from old) — new, usually higher-level exception, chained for traceback. "This is what happened; here's the lower-level cause."

A rule of thumb: if the new exception is what callers should react to, use from. If you don't want to change the type, just re-raise.

A worked example — fixture loader

from pathlib import Path
import json
 
class TestFrameworkError(Exception):
    pass
 
class FixtureNotFoundError(TestFrameworkError):
    def __init__(self, path: str):
        self.path = path
        super().__init__(f"fixture not found: {path}")
 
class FixtureSchemaError(TestFrameworkError):
    def __init__(self, path: str, missing_fields: list[str]):
        self.path = path
        self.missing_fields = missing_fields
        super().__init__(
            f"fixture {path} missing required fields: {', '.join(missing_fields)}"
        )
 
 
def load_user_fixture(path: str) -> dict:
    p = Path(path)
    if not p.exists():
        raise FixtureNotFoundError(path)
 
    try:
        data = json.loads(p.read_text(encoding="utf-8"))
    except json.JSONDecodeError as e:
        raise TestFrameworkError(f"fixture {path} is not valid JSON") from e
 
    required = ["name", "email", "role"]
    missing = [f for f in required if f not in data]
    if missing:
        raise FixtureSchemaError(path, missing)
 
    return data
 
 
try:
    user = load_user_fixture("fixtures/admin.json")
except FixtureNotFoundError as e:
    print(f"Skipping test — {e}")
except FixtureSchemaError as e:
    print(f"Bad fixture — missing {e.missing_fields}")
except TestFrameworkError as e:
    print(f"Other framework error — {e}")

Three failure modes, three specific handlers, one shared base if you want to swallow them all. That's the shape every mature test framework converges on.

⚠️ Common mistakes

  • Raising Exception directly. It's catchable but tells the caller nothing about what failed. Define a small class — TestDataError, ConfigError, ApiError — even three lines of class is enough. Exception should be the base of your hierarchy, not the leaves.
  • Forgetting from e when re-raising a wrapped exception. Without from, the chained traceback message is vague ("During handling of the above exception, another exception occurred"). With from e, it reads as a direct cause and points at the real source. Always use from e when one exception caused the other.
  • Catching your own exception too broadly inside the function. Wrapping every raise in your own helper with try/except SomeError: pass defeats the whole point of raising. Let exceptions flow up to the caller — that's where the recovery decision lives.

🎯 Practice task

Build a small exception hierarchy for a test framework. 25-30 minutes.

  1. Create errors.py. Define a base class class TestFrameworkError(Exception): pass.
  2. Add three subclasses (each with a one-line docstring): TestDataError, ApiError, ConfigError.
  3. Make ApiError carry a status_code: int and message: str via __init__. Pass a rendered message to super().__init__(...) so str(error) is readable.
  4. Create loader.py that imports these. Define def load_config(path: str) -> dict: that:
    • raises TestDataError if the path doesn't exist (Path(path).exists())
    • raises TestDataError from the original JSONDecodeError (with from e) if the file is unparsable
    • raises ConfigError if a required key (base_url) is missing
    • returns the dict otherwise
  5. Test each path: a missing file, a malformed JSON file, a JSON file missing base_url, and a valid file. Catch each specific exception and print the message.
  6. Add a catch-all handler except TestFrameworkError as e: at the end as a safety net. Confirm a deliberate raise TestFrameworkError("oops") lands there.
  7. Stretch: add chaining-aware logging. In one of the exception handlers, print both the wrapped exception and its __cause__ (the original exception attached by raise X from Y). Confirm you see both messages, with the original cause shown.

You can now design and raise the exceptions a test framework needs. The next lesson zooms out to organising code across multiple files: modules, packages, imports, and the __init__.py that wires them together.

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