Lesson 6 of 9 · Programming for Testers
Exception Handling
How exceptions work in JavaScript/TypeScript, Java and Python, the exceptions automation code meets most, and the rules for catching, rethrowing and testing error paths without hiding failures.
intermediate 8 min read Updated
You will learn to
- Use try/catch/finally correctly in JavaScript, Java and Python
- Distinguish checked and unchecked exceptions in Java and use try-with-resources
- Throw custom errors with context that makes test failures easy to diagnose
- Assert that code throws with Node assert, JUnit 5 and pytest
Prerequisites
- JavaScript Essentials
- OOP for Test Automation
Test code lives with failure. Elements are missing, APIs return 500, files are absent, timeouts expire, and assertions fail on purpose. How your code handles those situations decides whether a red test tells you exactly what went wrong or leaves you digging through logs. Worse, careless exception handling can turn a real failure into a green test.
This lesson covers exception mechanics in the three main automation languages and, more importantly, the rules for handling them in test code.
What an exception is
An exception (called an error in JavaScript) is an object that represents an abnormal situation. When code throws one, normal execution stops and the runtime looks up the call stack for a handler. If none is found, the program (or the test) fails with the exception’s message and stack trace.
In test frameworks, a failed assertion is itself an exception: AssertionError in Java and Python, an error thrown by expect in JavaScript frameworks. The runner catches it and marks the test as failed. That is why a stray catch in test code can make a failure disappear.
JavaScript and TypeScript
function parsePrice(text) {
const value = Number(text.replace(/[^0-9.]/g, ''));
if (Number.isNaN(value)) {
throw new Error(`Cannot parse price from "${text}"`);
}
return value;
}
try {
parsePrice('N/A');
} catch (err) {
console.error(err.message); // Cannot parse price from "N/A"
} finally {
console.log('always runs');
}
throwcan throw any value, but always throwErrorobjects (or subclasses) so you get a stack trace.finallyruns whether or not an exception occurred: use it for cleanup.- With
asynccode,awaitturns a rejected promise into a thrown error, so ordinarytry/catchworks. Withoutawait, the rejection escapes thetryblock entirely. Async Programming covers this in detail.
Typing caught errors in TypeScript
In TypeScript with strict mode, the caught value is typed unknown, because anything can be thrown. Narrow it before use:
try {
await createOrder();
} catch (err: unknown) {
if (err instanceof Error) {
throw new Error(`Order setup failed: ${err.message}`, { cause: err });
}
throw err;
}
The cause option (supported in Node 16.9+ and modern browsers) keeps the original error attached, so you get context without losing the root cause.
Custom error classes
export class ApiError extends Error {
constructor(
readonly method: string,
readonly url: string,
readonly status: number,
readonly body: string,
) {
super(`${method} ${url} returned ${status}: ${body.slice(0, 200)}`);
this.name = 'ApiError';
}
}
A custom class lets callers distinguish error kinds with instanceof and carries structured data for reports.
Java
Java distinguishes two families of exceptions:
| Kind | Superclass | Must be declared or caught? | Examples |
|---|---|---|---|
| Checked | Exception (not RuntimeException) |
Yes, the compiler enforces it | IOException, InterruptedException |
| Unchecked | RuntimeException |
No | NullPointerException, IllegalArgumentException, all Selenium exceptions |
| Errors | Error |
No; do not catch | OutOfMemoryError, AssertionError |
A method that can throw a checked exception must either catch it or declare it with throws:
public String readFixture(String name) throws IOException {
return Files.readString(Path.of("src/test/resources", name));
}
AssertionError, thrown by failed JUnit and TestNG assertions, extends Error, not Exception. So catch (Exception e) does not swallow failed assertions, but catch (Throwable t) does. Never catch Throwable in tests.
try-with-resources
Anything implementing AutoCloseable can be closed automatically, even if an exception is thrown:
try (BufferedReader reader = Files.newBufferedReader(Path.of("users.csv"))) {
String header = reader.readLine();
// ...
} // reader.close() is called here automatically
This replaces error-prone finally blocks for files, database connections and streams.
Selenium exceptions you will meet
All extend WebDriverException, which is unchecked.
| Exception | Usually means | Typical fix |
|---|---|---|
NoSuchElementException |
Locator matched nothing at that moment | Fix the locator, or wait with WebDriverWait |
TimeoutException |
An explicit wait’s condition never became true | Check the condition and the app; do not just raise the timeout |
StaleElementReferenceException |
The element was removed or re-rendered after you found it | Find the element again just before using it |
ElementClickInterceptedException |
Another element (overlay, banner) would receive the click | Wait for the overlay to disappear or close it |
ElementNotInteractableException |
Element exists but is hidden or disabled | Wait for visibility or the right state |
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement banner = wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("status")));
Waits are the main fix for most of these; catching them and retrying blindly hides real synchronisation problems. Practise in the Selenium Lab.
Playwright handles much of this for you: locators are re-resolved on each action and actions auto-wait for elements to be actionable. When a wait does expire, Playwright throws a TimeoutError whose message includes the locator and a call log, which is usually enough to diagnose the problem.
Python
import json
from pathlib import Path
class FixtureError(Exception):
"""Raised when test fixture data is missing or invalid."""
def load_fixture(name: str) -> dict:
path = Path("fixtures") / name
try:
text = path.read_text(encoding="utf-8")
data = json.loads(text)
except FileNotFoundError as e:
raise FixtureError(f"Fixture not found: {path}") from e
except json.JSONDecodeError as e:
raise FixtureError(f"Invalid JSON in {path} at line {e.lineno}") from e
else:
return data
finally:
pass # cleanup would go here
- Python uses
try/except/else/finally. Theelseblock runs only if no exception occurred. raise ... from echains exceptions, the equivalent of JavaScript’scause.- Catch the most specific exception you can. A bare
except:also catchesKeyboardInterruptandSystemExit; avoid it. withstatements (context managers) are Python’s equivalent of try-with-resources.
Rules for exception handling in test code
1. Do not swallow failures
// BAD: the test passes even if the save fails
try {
await saveSettings(page);
} catch (e) {
console.log('save failed', e);
}
If you catch, you must either handle the situation fully or rethrow.
2. Catch only to add context or handle an expected case
Good reasons to catch: wrapping a low-level error with business context (“Failed to create test user for checkout test”), cleaning up resources, or handling a genuinely optional situation such as an occasional cookie banner. Even then, prefer a condition check over catching where possible.
3. Throw early with a precise message
A message should say what was expected, what happened and with which input:
| Weak | Strong |
|---|---|
Error: failed |
Expected 201 from POST /orders, got 422: "quantity must be positive" |
NullPointerException |
No test user configured for role "admin" in env "staging" |
4. Clean up in finally (or fixtures)
Delete created data, close connections and stop servers in finally blocks, afterEach hooks, or framework fixtures, so that one failing test does not poison the next.
5. Do not use exceptions for control flow
Checking if (await banner.isVisible()) is clearer and cheaper than trying a click and catching the failure.
Testing that code throws
Sometimes the exception is the expected behaviour: a helper should reject invalid input. Each framework has a dedicated assertion, which is better than a hand-written try/catch with a fail().
import assert from 'node:assert/strict';
import { test } from 'node:test';
test('parsePrice rejects non-numeric text', () => {
assert.throws(() => parsePrice('N/A'), /Cannot parse price/);
});
test('fetchOrder rejects for unknown id', async () => {
await assert.rejects(fetchOrder('missing'), { name: 'ApiError' });
});
import static org.junit.jupiter.api.Assertions.*;
@Test
void rejectsNegativeQuantity() {
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> new OrderLine("A1", -1)
);
assertTrue(ex.getMessage().contains("quantity"));
}
import pytest
def test_load_fixture_missing():
with pytest.raises(FixtureError, match="Fixture not found"):
load_fixture("does-not-exist.json")
For HTTP APIs, error paths usually do not throw in the client at all: a 404 or 422 is a normal response. Assert on the status code and error body instead, which is covered in the API testing path.
Practice exercise
Write a JavaScript (Node 22) function getJson(url) that:
- Fetches the URL and returns the parsed JSON body.
- Throws a custom
HttpErrorwith method, URL, status and the first 200 characters of the body when the status is not 2xx. - Throws an error with a clear message and the original error as
causewhen the body is not valid JSON.
Then write two tests with node:test and assert.rejects for the error paths. You can run it in the JavaScript Lab.
Show a model answer
export class HttpError extends Error {
constructor(method, url, status, body) {
super(`${method} ${url} returned ${status}: ${body.slice(0, 200)}`);
this.name = 'HttpError';
this.status = status;
}
}
export async function getJson(url) {
const res = await fetch(url);
const text = await res.text();
if (!res.ok) {
throw new HttpError('GET', url, res.status, text);
}
try {
return JSON.parse(text);
} catch (err) {
throw new Error(`GET ${url} returned invalid JSON: ${text.slice(0, 100)}`, { cause: err });
}
}import assert from 'node:assert/strict';
import { test } from 'node:test';
import { getJson, HttpError } from './getJson.js';
test('non-2xx becomes HttpError', async () => {
await assert.rejects(
getJson('https://jsonplaceholder.typicode.com/posts/999999'),
(err) => err instanceof HttpError && err.status === 404,
);
});
test('invalid JSON is reported with a cause', async () => {
await assert.rejects(getJson('https://example.com/'), (err) => {
assert.match(err.message, /invalid JSON/);
assert.ok(err.cause instanceof SyntaxError);
return true;
});
});Reading the body as text first means you can include it in both error messages; calling res.json() directly would lose the raw body when parsing fails. The validation function passed to assert.rejects returns true when the error is acceptable. These tests depend on public sites; in a real suite, point them at a local stub server.
Key takeaways
- An exception stops normal flow and travels up the stack; test runners treat uncaught exceptions and failed assertions as test failures.
- Never swallow errors in tests: handle fully or rethrow, ideally with added context and the original as
causeor chained exception. - Java has checked and unchecked exceptions;
AssertionErroris anError, so never catchThrowablein tests. Use try-with-resources. - Selenium exceptions are mostly synchronisation symptoms; fix waits instead of catching them.
- Use
assert.throws/assert.rejects,assertThrowsandpytest.raisesto test error paths.
Key terms
Finished this lesson?
Progress is saved in this browser. No account needed.
Practise it
Related interview questions
- Checked vs unchecked exceptions: how should a test framework handle exceptions?
- Which TypeScript utility types do you use most, and how do they help in test code?
- Compare Promise.all, Promise.allSettled, Promise.race and Promise.any. When would you use each in test code?
- What is the GIL, and how do you run Python tests or I/O work in parallel?