Skip to content

Commit 9e8b5bc

Browse files
committed
Add comments explaining the test runner
1 parent 00294b2 commit 9e8b5bc

File tree

1 file changed

+177
-3
lines changed

1 file changed

+177
-3
lines changed

tests/runtests.py

Lines changed: 177 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,43 @@
11
#!/usr/bin/env python
22
"""
3-
Test runner for JSON-LD.
3+
Test runner for the JSON-LD test-suite.
4+
5+
This module provides a small command-line test harness used to execute the
6+
JSON-LD test manifests that accompany the project and to produce an EARL
7+
(Evaluation and Report Language) report summarizing results.
8+
9+
Behavior and features
10+
- Loads one or more JSON-LD test manifest files (or directories containing a
11+
`manifest.jsonld`) and constructs a `unittest.TestSuite` from the entries.
12+
- Supports running tests from the local test directories bundled with the
13+
repository or from manifests passed on the command line.
14+
- Uses the `pyld` library under test to run each test case (expand, compact,
15+
frame, normalize, to_rdf, etc.), then compares results against expected
16+
outputs. Comparison is order-insensitive where appropriate.
17+
- Can write an EARL report using `--earl <file>` for CI / interoperability
18+
with W3C testing tools.
19+
20+
Usage
21+
- Run the script directly: `python tests/runtests.py [MANIFEST_OR_DIR ...]`
22+
- Common options (see `-h` for full list):
23+
- `-e, --earl <file>` : write an EARL report to `<file>`
24+
- `-b, --bail` : stop at the first failing test
25+
- `-l, --loader` : choose the network loader (`requests` or `aiohttp`)
26+
- `-n, --number` : focus on tests containing the given test identifier
27+
- `-v, --verbose` : print verbose test data
28+
29+
Key classes and functions
30+
- `TestRunner`: command-line entrypoint; builds the root manifest and runs the
31+
test suite.
32+
- `Manifest`: loads a manifest document and converts its entries into
33+
`unittest.TestSuite` / `Test` instances.
34+
- `Test` (subclass of `unittest.TestCase`): encapsulates execution and
35+
verification logic for a single JSON-LD test case.
36+
- Utility helpers: `read_json`, `read_file`, `create_document_loader`, and
37+
`equalUnordered` (used for order-insensitive comparisons).
438
539
.. module:: runtests
6-
:synopsis: Test harness for pyld
40+
:synopsis: Test harness for pyld
741
842
.. moduleauthor:: Dave Longley
943
.. moduleauthor:: Olaf Conradi <olaf@conradi.org>
@@ -35,6 +69,11 @@
3569
'https://github.com/json-ld/normalization/tests'
3670
]
3771

72+
# `LOCAL_BASES` lists remote bases used by the official JSON-LD test
73+
# repositories. When a test refers to a URL starting with one of these
74+
# bases the runner attempts to map that URL to a local file in the
75+
# test-suite tree (when possible) so tests can be run offline.
76+
3877
class TestRunner(unittest.TextTestRunner):
3978
"""
4079
Loads test manifests and runs tests.
@@ -44,7 +83,9 @@ def __init__(self, stream=sys.stderr, descriptions=True, verbosity=1):
4483
unittest.TextTestRunner.__init__(
4584
self, stream, descriptions, verbosity)
4685

47-
# command line args
86+
# The runner uses an ArgumentParser to accept a list of manifests or
87+
# test directories and several runner-specific flags (e.g. which
88+
# document loader to use or whether to bail on failure).
4889
self.options = {}
4990
self.parser = ArgumentParser()
5091

@@ -82,6 +123,11 @@ def main(self):
82123
elif self.options.loader == 'aiohttp':
83124
jsonld._default_document_loader = jsonld.aiohttp_document_loader()
84125

126+
# The document loader drives how remote HTTP documents are fetched.
127+
# Tests can choose to run using the 'requests' based loader or the
128+
# 'aiohttp' async loader; here we select the default based on the
129+
# CLI option.
130+
85131
# config runner
86132
self.failfast = self.options.bail
87133

@@ -143,6 +189,9 @@ def main(self):
143189
global ROOT_MANIFEST_DIR
144190
#ROOT_MANIFEST_DIR = os.path.dirname(root_manifest['filename'])
145191
ROOT_MANIFEST_DIR = root_manifest['filename']
192+
# Build a Manifest object from the root manifest structure. The
193+
# Manifest will recursively load manifests and produce a
194+
# `unittest.TestSuite` containing all discovered tests.
146195
suite = Manifest(root_manifest, root_manifest['filename']).load()
147196

148197
# run tests
@@ -189,16 +238,29 @@ def load(self):
189238
if is_jsonld_type(entry, 'mf:Manifest'):
190239
self.suite = unittest.TestSuite(
191240
[self.suite, Manifest(entry, filename).load()])
241+
# If the entry is itself a manifest, recurse into it and
242+
# append its TestSuite. This mirrors the structure of the
243+
# W3C test manifests where manifests can include other
244+
# manifests via 'entries' or 'sequence'.
192245
# don't add tests that are not focused
193246

194247
# assume entry is a test
195248
elif not ONLY_IDENTIFIER or ONLY_IDENTIFIER in entry['@id']:
196249
self.suite.addTest(Test(self, entry, filename))
197250

251+
# For simple test entries we construct a `Test` object which
252+
# wraps the execution and assertion logic for that test case.
253+
198254
return self.suite
199255

200256

201257
class Test(unittest.TestCase):
258+
"""
259+
# A Test instance stores the manifest and test description (as
260+
# loaded from the JSON-LD manifest). The boolean flags below are
261+
# used to distinguish positive/negative/syntax tests so the
262+
# runner knows whether an exception is the expected outcome.
263+
"""
202264
def __init__(self, manifest, data, filename):
203265
unittest.TestCase.__init__(self)
204266
#self.maxDiff = None
@@ -263,6 +325,10 @@ def setUp(self):
263325
os.path.basename(str.replace(manifest.filename, '.jsonld', '')) + data['@id'])
264326
self.base = self.manifest.data['baseIri'] + data['input']
265327

328+
# When manifests define a `baseIri` the runner patches the test
329+
# `@id` and computes a `base` URL used by the document loader so
330+
# relative references are resolved consistently during testing.
331+
266332
# skip based on id regular expression
267333
skip_id_re = test_info.get('skip', {}).get('idRegex', [])
268334
for regex in skip_id_re:
@@ -300,6 +366,11 @@ def setUp(self):
300366
if re.match(regex, data.get('@id', data.get('id', ''))):
301367
data['runLocal'] = True
302368

369+
# Tests listed under 'runLocal' are forced to use the local file
370+
# loader variant rather than fetching remote URLs. This is useful
371+
# for reproducing the official test-suite behavior without network
372+
# access.
373+
303374
def runTest(self):
304375
data = self.data
305376
global TEST_TYPES
@@ -315,8 +386,20 @@ def runTest(self):
315386
else:
316387
expect = read_test_property(self._get_expect_property())(self)
317388

389+
# The following try/except handles three primary scenarios:
390+
# - Positive tests: compute the result and compare to expected JSON
391+
# (order-insensitive where appropriate).
392+
# - Negative tests: assert that the library raises the expected
393+
# JSON-LD error code.
394+
# - Pending tests: tests expected to fail are marked 'pending' and
395+
# their unexpected success is reported specially.
396+
318397
try:
319398
result = getattr(jsonld, fn)(*params)
399+
# Invoke the tested pyld function (e.g. `expand`, `compact`,
400+
# `normalize`). `fn` is the function name and `params` is a
401+
# list of callables that are invoked with `self` to produce
402+
# the actual arguments (this allows lazy file loading).
320403
if self.is_negative and not self.pending:
321404
raise AssertionError('Expected an error; one was not raised')
322405
if self.is_syntax and not self.pending:
@@ -382,6 +465,11 @@ def runTest(self):
382465

383466
# Compare values with order-insensitive array tests
384467
def equalUnordered(result, expect):
468+
"""
469+
`equalUnordered` implements a simple structural equivalence check that
470+
ignores ordering in lists. It is used to compare JSON-LD results where
471+
arrays are considered unordered by the test-suite semantics.
472+
"""
385473
if isinstance(result, list) and isinstance(expect, list):
386474
return(len(result) == len(expect) and
387475
all(any(equalUnordered(v1, v2) for v2 in expect) for v1 in result))
@@ -391,6 +479,8 @@ def equalUnordered(result, expect):
391479
else:
392480
return(result == expect)
393481

482+
483+
394484
def is_jsonld_type(node, type_):
395485
node_types = []
396486
node_types.extend(get_jsonld_values(node, '@type'))
@@ -400,6 +490,20 @@ def is_jsonld_type(node, type_):
400490

401491

402492
def get_jsonld_values(node, property):
493+
"""
494+
Safely extract a (possibly multi-valued) property from a JSON-LD node.
495+
496+
The JSON-LD manifests sometimes use single values or lists for the same
497+
properties. This helper returns a list in either case so callers can
498+
uniformly iterate over the returned value.
499+
500+
Args:
501+
node: dict-like JSON-LD node.
502+
property: property name to extract (string).
503+
504+
Returns:
505+
A list of values for the property (empty list if property missing).
506+
"""
403507
rval = []
404508
if property in node:
405509
rval = node[property]
@@ -409,6 +513,13 @@ def get_jsonld_values(node, property):
409513

410514

411515
def get_jsonld_error_code(err):
516+
"""
517+
Walk a JsonLdError chain to extract the most specific error `code`.
518+
519+
Many pyld error types wrap a cause. This helper attempts to return the
520+
structured `code` attribute from a `jsonld.JsonLdError` (if present),
521+
otherwise it falls back to stringifying the exception.
522+
"""
412523
if isinstance(err, jsonld.JsonLdError):
413524
if err.code:
414525
return err.code
@@ -418,11 +529,22 @@ def get_jsonld_error_code(err):
418529

419530

420531
def read_json(filename):
532+
"""Read and parse a JSON file from `filename`.
533+
534+
Returns the parsed Python object.
535+
"""
421536
with open(filename) as f:
422537
return json.load(f)
423538

424539

425540
def read_file(filename):
541+
"""Read a file and return its contents as text.
542+
543+
This wrapper ensures consistent text handling across Python 2/3 by
544+
decoding bytes for older Python versions. In the current project we
545+
expect Python 3, but the compatibility guard is kept to match the
546+
original test-runner behavior.
547+
"""
426548
with open(filename) as f:
427549
if sys.version_info[0] >= 3:
428550
return f.read()
@@ -431,6 +553,14 @@ def read_file(filename):
431553

432554

433555
def read_test_url(property):
556+
"""
557+
Return a callable that reads a URL-like property from a test entry.
558+
559+
Some test entries store input locations as relative paths resolved
560+
against the manifest's `baseIri`. This factory returns a function that
561+
accepts a `Test` instance and returns the fully-resolved URL (or
562+
`None` if the property is missing).
563+
"""
434564
def read(test):
435565
if property not in test.data:
436566
return None
@@ -442,6 +572,15 @@ def read(test):
442572

443573

444574
def read_test_property(property):
575+
"""
576+
Return a callable that reads a test-local property and returns either
577+
parsed JSON (for `.jsonld`) or raw text.
578+
579+
The returned function accepts a `Test` instance and resolves the
580+
filename relative to the test's directory. If the file ends with
581+
`.jsonld` it is parsed as JSON; otherwise the raw file contents are
582+
returned.
583+
"""
445584
def read(test):
446585
if property not in test.data:
447586
return None
@@ -454,6 +593,15 @@ def read(test):
454593

455594

456595
def create_test_options(opts=None):
596+
"""
597+
Factory returning a function that builds options for a pyld API call.
598+
599+
The returned callable accepts a `Test` instance and produces the
600+
options dictionary consumed by functions such as `expand`/`compact`.
601+
It merges explicit test `option` values with any additional `opts`
602+
passed to the factory, wires in the test-specific `documentLoader`,
603+
and resolves `expandContext` files when present.
604+
"""
457605
def create(test):
458606
http_options = ['contentType', 'httpLink', 'httpStatus', 'redirectTo']
459607
test_options = test.data.get('option', {})
@@ -471,8 +619,18 @@ def create(test):
471619

472620

473621
def create_document_loader(test):
622+
"""
623+
create_document_loader returns a callable compatible with the JSON-LD
624+
API's document loader interface. The returned `local_loader` will
625+
decide whether to load a URL from the local test-tree (mapping
626+
`LOCAL_BASES` to local files) or delegate to the normal network
627+
loader. This enables deterministic tests without requiring network
628+
access for suite-hosted resources.
629+
"""
474630
loader = jsonld.get_document_loader()
475631

632+
633+
476634
def is_test_suite_url(url):
477635
return any(url.startswith(base) for base in LOCAL_BASES)
478636

@@ -559,6 +717,14 @@ def local_loader(url, headers):
559717

560718

561719
class EarlTestResult(TextTestResult):
720+
"""
721+
A `TextTestResult` subclass that records EARL assertions as tests run.
722+
723+
This result object forwards normal test outcome bookkeeping to the
724+
base `TextTestResult` and additionally records each assertion in an
725+
`EarlReport` instance so a machine-readable report can be emitted at
726+
the end of a test run.
727+
"""
562728
def __init__(self, stream, descriptions, verbosity):
563729
TextTestResult.__init__(self, stream, descriptions, verbosity)
564730
self.report = EarlReport()
@@ -585,11 +751,16 @@ class EarlReport():
585751
"""
586752

587753
def __init__(self):
754+
# Load package metadata (version) from the library's __about__.py
588755
about = {}
589756
with open(os.path.join(
590757
os.path.dirname(__file__), '..', 'lib', 'pyld', '__about__.py')) as fp:
591758
exec(fp.read(), about)
759+
# Timestamp used for test results
592760
self.now = datetime.datetime.utcnow().replace(microsecond=0)
761+
# Build the base EARL report structure. The report is a JSON-LD
762+
# document describing the project and the assertions made about
763+
# test outcomes.
593764
self.report = {
594765
'@context': {
595766
'doap': 'http://usefulinc.com/ns/doap#',
@@ -640,6 +811,8 @@ def __init__(self):
640811
}
641812

642813
def add_assertion(self, test, success):
814+
# Append an EARL assertion describing a single test outcome. The
815+
# `earl:outcome` is either `earl:passed` or `earl:failed`.
643816
self.report['subjectOf'].append({
644817
'@type': 'earl:Assertion',
645818
'earl:assertedBy': self.report['doap:developer']['@id'],
@@ -654,6 +827,7 @@ def add_assertion(self, test, success):
654827
return self
655828

656829
def write(self, filename):
830+
# Serialize the EARL report as pretty-printed JSON-LD.
657831
with open(filename, 'w') as f:
658832
f.write(json.dumps(self.report, indent=2))
659833
f.close()

0 commit comments

Comments
 (0)