Skip to content
SDETRole
SDETRole

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');
}
  • throw can throw any value, but always throw Error objects (or subclasses) so you get a stack trace.
  • finally runs whether or not an exception occurred: use it for cleanup.
  • With async code, await turns a rejected promise into a thrown error, so ordinary try/catch works. Without await, the rejection escapes the try block 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. The else block runs only if no exception occurred.
  • raise ... from e chains exceptions, the equivalent of JavaScript’s cause.
  • Catch the most specific exception you can. A bare except: also catches KeyboardInterrupt and SystemExit; avoid it.
  • with statements (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:

  1. Fetches the URL and returns the parsed JSON body.
  2. Throws a custom HttpError with method, URL, status and the first 200 characters of the body when the status is not 2xx.
  3. Throws an error with a clear message and the original error as cause when 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 cause or chained exception.
  • Java has checked and unchecked exceptions; AssertionError is an Error, so never catch Throwable in tests. Use try-with-resources.
  • Selenium exceptions are mostly synchronisation symptoms; fix waits instead of catching them.
  • Use assert.throws/assert.rejects, assertThrows and pytest.raises to test error paths.

Key terms

Finished this lesson?

Progress is saved in this browser. No account needed.

Type to search across SDETRole.

Full-text search by Pagefind · runs in your browser Open search page →