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 -1raise 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 there →
KeyError,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
TestSetupError— avoidExceptiondirectly
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 intactBare 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 eThe 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:
- One handler can catch them all.
except TestFrameworkError:handles every error your code raises while still letting unrelated exceptions (aKeyErrorfrom insidepytest's plumbing, aKeyboardInterrupt) propagate. - Future subclassing. When you later need
FixtureNotFoundError(TestDataError), callers that already catchTestDataErrorkeep 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
- – 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
Exceptiondirectly. 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.Exceptionshould be the base of your hierarchy, not the leaves. - Forgetting
from ewhen re-raising a wrapped exception. Withoutfrom, the chained traceback message is vague ("During handling of the above exception, another exception occurred"). Withfrom e, it reads as a direct cause and points at the real source. Always usefrom ewhen one exception caused the other. - Catching your own exception too broadly inside the function. Wrapping every
raisein your own helper withtry/except SomeError: passdefeats 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.
- Create
errors.py. Define a base classclass TestFrameworkError(Exception): pass. - Add three subclasses (each with a one-line docstring):
TestDataError,ApiError,ConfigError. - Make
ApiErrorcarry astatus_code: intandmessage: strvia__init__. Pass a rendered message tosuper().__init__(...)sostr(error)is readable. - Create
loader.pythat imports these. Definedef load_config(path: str) -> dict:that:- raises
TestDataErrorif the path doesn't exist (Path(path).exists()) - raises
TestDataErrorfrom the originalJSONDecodeError(withfrom e) if the file is unparsable - raises
ConfigErrorif a required key (base_url) is missing - returns the dict otherwise
- raises
- 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. - Add a catch-all handler
except TestFrameworkError as e:at the end as a safety net. Confirm a deliberateraise TestFrameworkError("oops")lands there. - Stretch: add chaining-aware logging. In one of the exception handlers, print both the wrapped exception and its
__cause__(the original exception attached byraise 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.