QA How-To
Testing HATEOAS links (2026)
Learn testing HATEOAS links with relation contracts, safe URI checks, state transitions, traversal tests, negative cases, and CI-ready Node.js examples.
20 min read | 3,330 words
TL;DR
Testing HATEOAS links requires two layers: validate each advertised relation and URI, then follow selected links through allowed state transitions. The suite should prove that links appear only when actionable, stay within trusted origins, preserve tenant and authorization rules, and lead to responses consistent with their relation.
Key Takeaways
- Assert link relations and affordances, not hard-coded deployment URLs.
- Validate each link's structure, resolved origin, protocol, and resource identity before following it.
- Test link presence and absence across real resource states and user permissions.
- Traverse representative workflows so a syntactically valid link must also work.
- Treat pagination, URI templates, deprecation, and Link headers as separate contracts.
- Keep link validation format-aware because HAL, JSON:API, and custom representations differ.
Testing HATEOAS links is the practice of verifying that a REST representation tells clients what they can do next, using correct and safe hypermedia controls. Effective tests check relation names, URI resolution, resource identity, state-dependent availability, authorization, templates, pagination, and the result of following a link.
A link that looks like valid JSON can still be functionally wrong. It may point to the wrong tenant, advertise an action the current user cannot perform, use an internal host, omit a required URI template variable, or lead to a method that rejects every request. That is why HATEOAS testing needs both representation-level contract checks and workflow traversal.
This guide uses HAL-style examples, but the method applies to JSON:API links, Siren actions, Collection+JSON, HTTP Link fields, and custom media types. Pair it with the API pagination testing guide and API error handling and negative testing when those behaviors are part of the representation.
TL;DR
| Layer | Question | Example assertion |
|---|---|---|
| Syntax | Can a client parse the control? | _links.self.href is a nonempty string |
| Semantics | Does the relation mean the right thing? | cancel identifies cancellation, not deletion |
| State | Is the control available now? | pay exists only for an unpaid payable order |
| Safety | Is the target trusted and scoped? | Resolved URI uses HTTPS and an allowed origin |
| Traversal | Does following it work? | GET self returns the same resource identity |
| Evolution | Can clients tolerate changes? | Unknown relations are ignored, required relations remain |
Do not compare a complete response to a frozen snapshot. Validate stable invariants and relation-specific behavior, because hypermedia documents are designed to evolve.
1. Testing HATEOAS links starts with affordances
HATEOAS, Hypermedia as the Engine of Application State, means a client discovers available transitions from representations rather than constructing every URI from out-of-band rules. The control might be a link to retrieve a related resource or an action that also describes an HTTP method, media type, and input fields. The exact shape depends on the media type.
A plain URL check proves very little. The primary test oracle is the affordance: given this resource state, actor, and representation type, which relations should be advertised, and what should each allow the client to do? For a paid order, self, customer, and receipt may be present while pay and cancel are absent. For an unpaid order, pay may appear only to an authorized owner.
Separate link relations from human labels. A client should key behavior from a stable relation such as self, next, or a documented extension relation, not from display text such as 'Cancel order.' Labels can be localized without changing semantics. If the API uses registered relations, verify their standard meaning. If it uses extension relations, document stable identifiers, preferably absolute relation URIs when the chosen media type expects them.
The test should also know whether a control is safe, idempotent, or state-changing. A HAL link commonly supplies href but does not define an HTTP method by itself, while Siren actions explicitly include methods and fields. Never invent a universal rule that all hypermedia links are GET requests. Test according to the representation's media type and API contract.
2. Inventory representations, states, actors, and relations
Build coverage from the domain state machine, not from a list of endpoints. For each resource representation, record meaningful states, actor roles, available relations, forbidden relations, and expected targets. This produces a compact decision table that product, API, and test engineers can review together.
| Order state | Actor | Required relations | Relations that must be absent |
|---|---|---|---|
| Draft | Owner | self, items, submit |
pay, receipt |
| Submitted | Owner | self, pay, cancel |
submit, receipt |
| Submitted | Other customer | None, or only permitted public relations | pay, cancel, customer |
| Paid | Owner | self, receipt, customer |
pay, submit |
| Cancelled | Owner | self |
pay, cancel, submit |
Include transitions caused by time and policy, not only status fields. A cancel control may disappear after shipment, after a cutoff time, or when a refund is pending. Use an injected clock or seeded state so the case is deterministic. Include feature flags and account entitlements if they alter advertised actions.
Choose a canonical representation for each state, but do not rely exclusively on fixtures. Fixture tests find structural regressions quickly; live component tests prove the application emits the right controls from real domain decisions. The best suite reuses the same validator for both.
Keep expected absence explicit. A security defect often appears as an extra link rather than a missing one. Even if the target later returns 403, advertising an unauthorized transition leaks capability or resource information and misleads clients. Test both representation filtering and server-side authorization because hiding a link is not an access-control boundary.
3. Define relation-level contracts and reference rules
Each important relation needs a narrow contract. self identifies the current resource. collection identifies its containing collection. next advances a cursor or page. A business action such as approve identifies a valid transition and may include method and input metadata. These contracts are more stable than complete JSON snapshots.
| Relation | Stable checks | Checks to avoid |
|---|---|---|
self |
Same logical resource, allowed origin, canonical form if promised | Exact hostname across every environment |
next |
Moves forward, retains filters, no loop | Predicting an opaque cursor value |
prev |
Returns toward prior position | Assuming it exists on the first page |
item |
Target identity matches embedded or listed item | Depending on array order unless contractual |
cancel |
Present only when allowed, action succeeds with correct method | Treating label text as the relation |
describedby |
Documentation or schema media type is correct | Following it in every regression run |
Write relation helpers instead of repeating ad hoc assertions. A self helper can resolve relative references, enforce an allowlist, fetch the target, and compare its resource identifier. A next helper can retain the query's filters and prove progress without decoding an opaque cursor. An action helper can submit a valid payload and verify the next representation no longer advertises an impossible repeat action.
RFC 8288 defines registered and extension link relations for HTTP links. Representation formats can layer additional conventions on top. Record which specification and media type the API follows, including how it expresses arrays, URI templates, titles, language, and media hints. A validator that assumes HAL _links cannot be silently applied to JSON:API's links object.
Keep required and optional relations distinct. Optional observability or help links should not block a release unless they are contractual. Core navigation and safety relations should.
4. Build a runnable structural and safety validator
The following Node.js test uses only stable platform APIs. Save it as hateoas-links.test.mjs and run node --test hateoas-links.test.mjs. It validates a HAL-style fixture, resolves relative references with the standard URL class, and rejects untrusted origins or protocols.
import test from 'node:test';
import assert from 'node:assert/strict';
const apiBase = new URL('https://api.example.test/');
const allowedOrigins = new Set([apiBase.origin]);
function getHalLink(document, relation) {
const value = document?._links?.[relation];
assert.ok(value, `missing relation: ${relation}`);
const link = Array.isArray(value) ? value[0] : value;
assert.equal(typeof link.href, 'string');
assert.ok(link.href.length > 0, `${relation} href is empty`);
return link;
}
function resolveTrusted(link, base = apiBase) {
assert.equal(link.templated ?? false, false, 'expand templates before traversal');
const target = new URL(link.href, base);
assert.equal(target.protocol, 'https:');
assert.ok(allowedOrigins.has(target.origin), `untrusted origin: ${target.origin}`);
assert.equal(target.username, '');
assert.equal(target.password, '');
return target;
}
function assertOrderRepresentation(order) {
assert.match(order.id, /^ord_[a-z0-9]+$/);
const self = resolveTrusted(getHalLink(order, 'self'));
assert.equal(self.pathname, `/orders/${order.id}`);
if (order.status === 'SUBMITTED') {
resolveTrusted(getHalLink(order, 'pay'));
} else {
assert.equal(order._links?.pay, undefined);
}
}
test('submitted order exposes safe, state-aware links', () => {
const order = {
id: 'ord_a17',
status: 'SUBMITTED',
_links: {
self: { href: '/orders/ord_a17' },
pay: { href: '/orders/ord_a17/payment' },
customer: { href: '/customers/cus_9' }
}
};
assertOrderRepresentation(order);
});
test('off-origin action is rejected', () => {
const link = { href: 'https://attacker.example/pay' };
assert.throws(() => resolveTrusted(link), /untrusted origin/);
});
The allowlist is intentionally explicit. In a real suite, configure it per environment and include trusted CDN or documentation origins only for relations that permit them. Do not make the global rule so broad that any subdomain or HTTP scheme passes. If relative links are allowed, always resolve them against the actual response URL, not an assumed root, because path-relative references depend on context.
5. Traverse links to prove state transitions
Structural validation should be fast and broad. Traversal should be selective and deep. Follow self, one related-resource relation, pagination relations, and each important business action through a representative workflow. The target response must satisfy the semantic contract of the relation, not merely return a 2xx status.
For self, fetch the resolved URI with the same actor and media type, assert the returned resource ID matches, and confirm the response includes a consistent canonical self. For customer, assert the target represents the customer referenced by the order and does not expose another tenant. For pay, use the documented method and payload, verify the order becomes paid, and confirm pay disappears while receipt appears.
Avoid walking every link recursively. APIs can contain cycles such as order -> customer -> orders -> order. Unbounded crawlers create duplicate traffic and unstable test duration. Use a maximum depth, a visited set keyed by canonical URI plus representation type, and a relation allowlist. Keep read-only link checks separate from state-changing actions.
A traversal test should send the advertised media type in Accept, retain authentication, and avoid forwarding credentials to a different origin. If a relation intentionally targets another trusted service, define a relation-specific credential policy. Generic HTTP clients often forward headers more broadly than a security review would allow.
When state changes, use an idempotency key if the API supports one and create isolated data. After the action, retrieve the resource again rather than trusting only the action response. This confirms persistence and the new set of affordances. The API test data management guide covers unique fixtures and cleanup patterns for these workflows.
6. Test malformed links, hostile targets, and authorization boundaries
Negative testing should treat links as untrusted input consumed by clients. Test missing href, empty strings, wrong types, malformed percent encoding, fragments where prohibited, unexpected schemes, embedded credentials, control characters, excessively long values, duplicate singleton relations, and off-origin targets. The expected outcome is a documented validation error or safe refusal, never an accidental request to an arbitrary destination.
Security tests should include host confusion and tenant crossover. A URI such as https://api.example.test.attacker.invalid/ does not share the trusted origin. A valid-looking path may still reference another customer's resource. Resolve with a standards-compliant URL parser and compare normalized origins, then verify resource ownership at the target. String prefix checks are insufficient.
Test authorization twice. First, assert the representation omits actions the actor cannot use. Second, call the underlying target directly and confirm the server rejects the actor. HATEOAS improves discoverability but does not replace authorization. Repeat with expired credentials and a downgraded role to catch cached representations that expose stale controls.
For state-changing actions, verify method confusion is rejected. A link intended for a safe GET must not mutate state. An action documented as POST should reject an unsupported method with the API's defined response. Include CSRF protections when browser credentials are involved, and never follow a state-changing control automatically in a generic crawler.
Keep redacted evidence: source resource ID, relation, normalized target origin and path, actor role, status, and correlation ID. Do not place bearer tokens or sensitive query values in reports.
7. Validate URI templates, query parameters, and encoding
Some formats mark links as URI templates, commonly following RFC 6570. A templated reference is not directly traversable. The test must verify the templated indicator, supported variable names, required versus optional variables, expansion, and encoding of values containing spaces, slashes, Unicode, plus signs, ampersands, or percent characters. Use a conforming URI-template library if expansion is part of the client; do not implement template syntax with string replacement.
A search template such as /orders{?status,cursor} should expand without emitting the braces. Test no variables, one variable, and all documented variables. Unknown variables should follow the library and contract behavior. Verify a user-supplied value stays data rather than becoming a new query parameter. For example, a status value containing &admin=true must be encoded.
For ordinary non-templated links, assert important query context. A next link should usually preserve filters, sorting, fields, locale, and page size while changing the cursor. Do not assert the literal order of query parameters unless the API signs the exact URI. Parse them through URLSearchParams and compare meaning.
Relative resolution needs targeted cases: root-relative /orders/1, path-relative items/2, parent-relative ../orders, and query-only ?cursor=x. Resolve against the actual response URL. A base ending in /orders/1 behaves differently from /orders/1/, so write down which representation locations are valid and avoid accidental directory semantics.
If the API uses signed links, test expiry and tamper detection without snapshotting the signature. Assert that an unmodified link works within its validity window, a changed path or protected parameter fails, and an expired link returns the documented response. Use a controllable clock where possible.
8. Test pagination and collection navigation without decoding cursors
Hypermedia is especially valuable for pagination because clients should follow next and prev instead of constructing pages. Create a dataset with stable unique IDs, request the first collection, and walk next until it disappears. Assert no ID is duplicated, every expected ID appears under the test's consistency model, and traversal terminates within a safe page limit.
Opaque cursors are intentionally opaque. Do not base64-decode them or predict their literal value. Validate progress through returned items and links. If the API promises stable snapshot pagination, insert or delete records during traversal and verify the documented snapshot behavior. If it offers live pagination, write assertions compatible with that model and focus on prohibited loss or duplication.
Boundary cases include an empty collection, exactly one page, one item beyond a page, the last page, and an invalid or expired cursor. The first page should not advertise a misleading prev; the last should not advertise next. A repeated next URI is a loop and should fail quickly.
Preserve filters and authorization during traversal. A next link that drops status=open may return valid JSON but violate the original query. A link that changes tenant context is a security defect. Compare normalized parameters that are meant to persist and verify every returned item remains in scope.
When total counts are eventually consistent, do not force an exact total unless contractual. Separate link navigation from count metadata. This keeps the test aligned with observable guarantees rather than implementation detail.
9. Cover media types, HTTP Link fields, caching, and versions
Ask for each supported representation with an explicit Accept header. A HAL response should use its documented media type and _links conventions. JSON:API has different top-level and resource-level link shapes. A bare JSON response may use a custom contract. The same business resource can expose different controls in different media types, so the validator must dispatch by Content-Type.
HTTP Link response fields can carry relations such as next, prev, canonical, or describedby. Test parsing with a standards-aware library when values include quoted parameters or multiple links. Splitting the field on commas is unsafe because quoted parameter values can contain punctuation. If the API duplicates a relation in both a header and representation, define whether they must be equivalent.
Caching can make affordances stale. If link availability depends on authorization or resource state, verify Vary, cache controls, and invalidation behavior prevent one actor or old state from receiving another's actions. Run a state transition through the same cache path clients use and confirm the refreshed representation changes controls.
Versioning tests should focus on compatibility. Clients must ignore unknown optional relations. Servers should retain required relations for a supported representation version or signal a media-type version change. Removing or changing a relation can be more disruptive than adding a JSON property because it removes navigation. Run consumer contract tests using at least one supported older client expectation.
Schema tools can validate shape, but OpenAPI alone may not express every runtime state rule. Combine generated structural checks with state-machine tests. The generating API tests from OpenAPI with AI guide can help with baseline cases, while hand-authored relation invariants remain essential.
10. Scale testing HATEOAS links in CI
Organize checks into three groups. Representation contract tests validate many fixtures or component responses in milliseconds. Traversal tests create a small number of domain workflows and follow critical relations. Deployed smoke tests verify public origins, gateway rewriting, media negotiation, TLS, and authorization in the target environment.
Create a reusable link assertion library with media-type adapters and relation-specific helpers. Feed it the actual response URL, because correct relative resolution needs context. Configuration should contain allowed origins, supported schemes, and relation exceptions. Keep security defaults strict and make each exception visible in review.
Avoid full-response snapshots as the main oracle. They fail on harmless added links and encourage reviewers to approve large diffs without understanding state semantics. Snapshot a compact normalized relation map only if it adds value, then keep explicit assertions for required, forbidden, and security-sensitive controls.
In CI reports, show the source URI, relation, resolved target, state, actor, and failed invariant. For a traversal failure, attach the relation path such as orders -> next -> next or order -> pay -> receipt. This is more useful than a generic JSON mismatch.
Run the broad structural suite on every change. Run stateful traversal after component deployment. Schedule larger graph scans against controlled data, with depth and request budgets. A hypermedia test should be safe to stop, easy to reproduce, and incapable of accidentally invoking destructive actions outside its allowlist.
Interview Questions and Answers
Q: What exactly do you assert when testing a HATEOAS link?
I assert that the relation is appropriate for the resource state and actor, the link shape follows the media type, and the URI resolves to a trusted target. For important relations I follow it and validate the target's semantic identity or state transition.
Q: Why is a 200 response from every link not enough?
A wrong target can still return 200. The response must represent the resource or action implied by the relation, preserve tenant and query scope, and produce the expected next affordances. State-changing controls also require persistence checks.
Q: How do you test links that change by user role?
I request the same resource and state as multiple actors, then compare required and forbidden relations. I also call protected targets directly to prove server authorization, because omitting a link is not an access-control mechanism.
Q: Should HATEOAS tests hard-code URLs?
They should avoid environment-specific absolute URLs but still assert stable semantics. Resolve relative references against the actual response URL, enforce allowed origins and schemes, and assert resource identity or path patterns where those are contractual.
Q: How do you avoid infinite traversal?
Use a visited set, relation allowlist, maximum depth, and request budget. Separate safe read traversal from state-changing actions. Fail early on repeated pagination links or a path that exceeds the expected workflow.
Q: How do you test URI templates?
Verify the template marker and variables, expand with a conforming library, and cover empty, normal, reserved, and Unicode values. Then assert the expanded URI contains no template syntax and does not allow a value to inject new path or query structure.
Q: What is the most important security check for hypermedia?
Treat every target as untrusted until parsed and normalized. Enforce scheme and origin policies, prevent credential forwarding across origins, verify tenant ownership, and independently authorize every target operation.
Common Mistakes
- Freezing the entire response in snapshots instead of asserting stable relation invariants.
- Checking only that
hrefis a string and never following business-critical links. - Comparing origins with string prefixes, which can accept attacker-controlled hostnames.
- Assuming every link uses GET even when the media type describes actions separately.
- Advertising unauthorized actions and relying on the target's later
403as the only protection. - Resolving relative references against a guessed API root instead of the response URL.
- Decoding or predicting opaque pagination cursors.
- Following an unbounded graph and creating loops, excessive traffic, or destructive side effects.
- Applying HAL assumptions to JSON:API, Siren, or custom media types.
- Forwarding authorization headers to off-origin links.
- Ignoring stale links introduced by caches, state changes, or role changes.
Conclusion
Testing HATEOAS links is contract and workflow testing combined. Prove that each representation advertises the right controls for its state and actor, that every target is structurally safe, and that selected relations deliver the resource or transition their semantics promise.
Begin with a state-relation decision table and a shared validator. Then automate one read-only navigation path, one paginated collection, and one state-changing action. That small set provides a strong base for evolving a hypermedia API without trapping clients in hard-coded assumptions.
Interview Questions and Answers
How would you explain HATEOAS testing in an interview?
I describe it as validating the runtime navigation contract of a REST API. I check that representations advertise only valid transitions for the current state and actor, that targets are safe and correctly scoped, and that following important relations produces the promised resource or state change.
How do you choose which hypermedia links to traverse?
I traverse core navigation, pagination, related-resource identity, and high-risk business actions. Broad structural checks cover the rest. I avoid recursive traversal of every relation because graphs contain cycles and state-changing actions need explicit control.
How do you test state-dependent links?
I build a decision table across domain state, actor, entitlement, and time policy. Seed each state deterministically, assert required and forbidden controls, perform the transition, and retrieve the resource again to verify the next affordance set.
What defects can a syntactically valid HATEOAS link contain?
It can target the wrong resource or tenant, expose an unauthorized capability, use an internal or hostile origin, drop filters, contain a stale cursor, or describe an action that always fails. That is why I pair shape validation with semantic traversal.
How would you make a hypermedia crawler safe?
I use allowed origins and relations, a visited set, a depth and request budget, and a default read-only policy. State-changing actions require explicit scenarios, isolated data, and relation-specific methods. Credentials are never forwarded to a different origin by default.
How do you test backward compatibility for HATEOAS?
I ensure supported clients tolerate new optional relations and that the server retains required relations for each supported media-type version. Consumer contract tests focus on stable semantics rather than a frozen full response. Removal or changed meaning receives the same scrutiny as an endpoint breaking change.
What would you log when a HATEOAS test fails?
I log the source resource and state, actor role, relation, normalized target, media type, traversal path, response status, and failed invariant. I redact credentials and sensitive query data. This evidence usually identifies whether the defect is representation, routing, authorization, or target behavior.
Frequently Asked Questions
What should a HATEOAS link test verify?
It should verify relation semantics, media-type structure, URI resolution, allowed origin and scheme, resource scope, and state-dependent presence. Important relations should also be followed to confirm the target identity or action result.
Should HATEOAS links be absolute or relative?
Either can be valid if the API contract and media type permit it. Tests should resolve relative references against the actual response URL and should avoid hard-coding an environment hostname unless the hostname itself is contractual.
How do I test HATEOAS links for different roles?
Request the same resource state as each role and assert both required and forbidden relations. Then call protected targets directly and confirm server-side authorization, because link omission alone does not enforce access control.
Can OpenAPI validate HATEOAS links?
OpenAPI can validate structural parts of a response and document links, but it may not capture every state, role, and runtime target rule. Use schema checks as a baseline, then add relation-specific and traversal tests.
How do I test paginated HATEOAS links?
Follow `next` until it disappears, enforce a page limit, and track resource IDs and visited links. Assert progress, filter retention, scope, no prohibited duplicates, and correct first-page and last-page relation behavior without decoding opaque cursors.
Are HATEOAS link tests security tests?
They include important security checks, especially origin validation, tenant scope, credential forwarding, authorization, and method safety. They do not replace a full API security assessment, but they should treat advertised targets as untrusted input.
Should I snapshot the `_links` object?
A compact normalized snapshot can help detect changes, but it should not be the main oracle. Explicitly assert required, forbidden, optional, and security-sensitive relations so reviewers understand the behavior being protected.