JavaScript / Intermediate: modules and asynchronous contracts / Lesson 10
Error contracts and meaningful tests
Design validation and error propagation as part of an API. Build small deterministic tests that check independent expected values, rejection behavior, and state rather than only executed lines.
On this page
Lesson goals
- Distinguish domain rejection from programming defects.
- Preserve causes and avoid swallowed errors.
- Write independent success and failure oracles.
Invalid data needs a defined outcome
A parser can return a structured result for expected user mistakes, while an impossible internal condition may throw. The distinction follows the API contract, not a universal rule that errors must always be exceptions or always be return values. Use Error objects for thrown failures so type, message, stack, and optional cause can carry context. Avoid exposing raw internal details to users. Keep the original cause when adding domain context, and assign one layer responsibility for user messaging so every layer does not log the same failure as separate incidents.
Test values at the actual boundary
A quantity parser accepting trimmed decimal digits should make emptiness and allowed syntax explicit before numeric conversion. Number alone accepts representations that may be outside the form contract, including an empty string becoming zero. The example deliberately accepts whole decimal digits from one through twenty and returns a distinct failure result otherwise. It is not a general international number parser. Unicode digits, signs, and localized formatting need a separate agreed policy. Testing the boundary reveals those assumptions earlier than a calculator test that receives already valid numbers.
function parseQuantity(text) {
if (typeof text !== "string" || !/^\d+$/.test(text.trim())) {
return { ok: false, error: "quantity format" };
}
const value = Number(text.trim());
return Number.isSafeInteger(value) && value >= 1 && value <= 20
? { ok: true, value } : { ok: false, error: "quantity range" };
}
console.log(JSON.stringify(parseQuantity(" 3 ")));
console.log(JSON.stringify(parseQuantity("3x")));
{"ok":true,"value":3}
{"ok":false,"error":"quantity format"}
The format check prevents partial acceptance. Range and safe-integer checks are separate from syntax.
An assertion needs an independent expectation
A test has arrangement, an action, and an observation compared with an oracle. Computing expected using the same function as actual repeats its bug instead of detecting it. For a refund rule, write reviewed boundary examples and check both the returned amount and relevant state. Coverage tells you what ran within measured scope, not whether assertions would notice a wrong result. Test no-match, missing input, invalid type, and important boundaries. A deliberate wrong local variant can challenge a weak assertion, but do not ship broken variants with application code.
A tiny harness can expose the intended signal
The self-contained assertion helper below is sufficient to demonstrate an oracle without requiring a test package. It compares a known expected result and checks that invalid input throws the intended error type. A real project framework adds isolation, discovery, reporting, and asynchronous lifecycle support, but cannot choose the oracle for you. Do not catch every exception and declare a test passed: an unrelated ReferenceError could then masquerade as correct validation. For rejected promises, await the rejection assertion or return it to the runner so the test cannot finish before the failure is observed.
function assertEqual(actual, expected) {
if (!Object.is(actual, expected)) throw new Error("assertion failed");
}
function double(value) {
if (!Number.isFinite(value)) throw new TypeError("finite number required");
return value * 2;
}
assertEqual(double(4), 8);
let rejected = false;
try { double("4"); } catch (error) { rejected = error instanceof TypeError; }
assertEqual(rejected, true);
console.log("2 checks passed");
2 checks passed
The expected eight is reviewed independently. The negative case accepts only the intended TypeError classification.
Control dependencies and inspect side effects
Inject time, randomness, and data adapters when they influence the rule, so tests can produce exact inputs and repeat failures. Use fresh objects for each case and do not rely on execution order. A fake adapter can record calls or return a controlled error, but it does not establish the real browser or service contract. Preserve a small integration case for that boundary. When a failure occurs, report the input, expected outcome, actual observation, and relevant environment. Avoid replacing an informative assertion with a broad snapshot merely to make a changing implementation pass.
Common errors and fixes
A catch hides a programmer error as empty results
- Why it happens
- All failure types are treated as recoverable absence.
- How to fix it
- Handle expected errors narrowly and propagate unexpected defects.
- Prevent it next time
- Define error categories at each boundary.
A test passes before asynchronous work finishes
- Why it happens
- Its promise is not awaited or returned.
- How to fix it
- Connect completion and rejection to the test lifecycle.
- Prevent it next time
- Verify a controlled rejecting dependency is detected.
Expected and actual share the same bug
- Why it happens
- The production calculation is reused as the oracle.
- How to fix it
- Use reviewed constants or independently derived rules.
- Prevent it next time
- Ask which wrong implementation each assertion rejects.
Check your understanding
1. Design cases for the one-to-twenty quantity parser.
Show worked solution
Accept "1", "20", and trimmed " 3 "; reject "0", "21", "3x", "", and null. Assert the intended format or range category, not merely any failure. These cases cover syntax, type, and numeric boundaries with unrelated conditions held valid.
2. An async save test checks only that save was called. Improve it.
Show worked solution
Await completion, verify the saved identifier and intended stored value through an owned fake or integration read, and assert no success message on a controlled rejection. A call count alone cannot distinguish the wrong value, a detached promise, or an incorrect confirmation.
Key takeaways
- Errors are part of the API contract and need owners.
- A meaningful test rejects a specific harmful behavior.