Common CQL Artifacts for FHIR (US-Based)
2.0.0 - Informative 2 United States of America flag

Common CQL Artifacts for FHIR (US-Based), published by HL7 International / Clinical Decision Support. This guide is not an authorized publication; it is the continuous build for version 2.0.0 built by the FHIR (HL7® FHIR® Standard) CI Build. This version is based on the current content of https://github.com/HL7/us-cql-ig/ and changes regularly. See the Directory of published versions

Unit Testing Proposal

Page standards status: Informative

This page is a proposal for a refined approach to unit testing shareable CQL assets. It responds to balloter feedback that the initial test-case model described on the Testing page is too limited to support a common asset library. It is presented for discussion; the assertion model described here is not yet normative, and tooling support is under development. Feedback is welcome via the Propose a Change process.

Motivation

The Testing page describes a straightforward unit-testing pattern: for each test case, a folder of FHIR resources represents a single patient's data, and an expected-results Parameters resource records the expected value of each expression by name. Values are compared for exact equality. This model is easy to author in the VSCode CQL Plugin and works well for static, deterministic checks (e.g., "does Patient Name return Peter James Chalmers").

However, exact-equality-against-static-data does not scale to a suite of tests that must validate CQL intended for reuse across many implementations and against real, messy EHR data. Four problems in particular were raised in balloting, and this proposal is organized around addressing them:

  1. Relative timing. Example resources carry static instants (e.g., Observation.effectiveDateTime = "2026-01-01T00:00:00Z"), but clinically the CQL usually asks a relative question ("was this observation resulted in the last 7 days?"). The answer depends on when the test is evaluated. Without a way to pin the evaluation time, the expected result is either non-deterministic or silently wrong.
  2. Robustness to data variation. Shared assets must tolerate a variety of valid representations — different units, different choice-type serializations (effectiveDateTime vs. effectivePeriod), missing optional elements. Tests need to exercise these variants rather than assume one canonical shape.
  3. Floating-point and unit sensitivity. Any computation (unit conversion, CalculateBSA, ratios) can produce 6.01 mg/dL where a naive author wrote 6. Exact decimal comparison makes such a test brittle and, worse, encourages authors to hard-code an engine's rounding behavior.
  4. Partial matches over complex results. When an expression returns a list or a structured object, the point of the test is often that some relevant element is included or excluded — not that the entire result matches a fixture byte-for-byte.

The balloter's summary requirement is that assertions must consider: (1) a data type together with comparisons that are meaningful for that type; (2) both positive and negated assertions; (3) relative timing; and (4) partial matching of a complex object or list. This proposal adopts those four criteria directly.

Review of Existing Testing Architectures

Rather than invent a vocabulary from scratch, this proposal draws on established unit-testing frameworks and existing CQL/FHIR testing tooling.

Architecture Relevant ideas we borrow
Assertion libraries (e.g., Chai, JUnit/Hamcrest, pytest) A matcher vocabulary that is organized by the type of value under test: expect(x).to.equal(y), expect(x).to.be.greaterThan(y), expect(x).to.contain(y), expect(x).to.have.property(p).with.lengthOf(n), expect(x).to.not.equal(y), expect(x).to.be.closeTo(y, delta). Every matcher has a negated form (.not), and matchers are only offered where they type-check.
CQL Testing Framework A YAML test-case format layered over Mocha/Chai that already supports fixing the execution date and asserting expression results. Demonstrates that a human-authorable, non-programmer-friendly surface syntax over a rich matcher set is workable for CQL.
FHIR TestScript/TestReport A FHIR-native way to express assertions (assert.operator = equals, greaterThan, contains, in, notEquals, …) and to record pass/fail as a resource. Confirms that an operator-plus-expected-value model is idiomatic in the FHIR ecosystem.
CRMI test packaging How test cases travel with content artifacts as bundles, and how expected results are conveyed as Parameters. This proposal keeps that packaging so existing tooling and the Downloads distribution continue to work.

The consistent lesson across these is that a robust assertion is a triple: (value under test, operator, expected value) — optionally with a tolerance — where the available operators are constrained by the type of the value under test, and every operator has a negation.

Proposed Test-Case Model

The proposal extends the existing Parameters-based expected-results file in two ways, both backward compatible:

  1. A test context section that fixes the evaluation timestamp (and any input parameters) for the test case, so relative timing is deterministic.
  2. An assertion representation that replaces the bare name/value pair with a name (the expression under test), an operator, an expected value, and optional tolerance/path parts.

A plain name/value pair (today's format) continues to be interpreted as operator = equals, so all existing test cases remain valid.

1. Test context and relative timing

Each test case gains an optional evaluation context. The engine evaluates Now() and Today() as the supplied evaluationDateTime instead of the wall clock, making every relative expression deterministic regardless of when the suite runs.

{
  "resourceType": "Parameters",
  "id": "test-context",
  "parameter": [
    { "name": "evaluationDateTime", "valueDateTime": "2026-01-08T12:00:00Z" }
  ]
}

With the evaluation time pinned to 2026-01-08, an Observation with effectiveDateTime = "2026-01-01" is exactly 7 days old, so a test of "resulted in the last 7 days" is stable. Fixtures should be authored relative to a documented anchor date, and the anchor is declared here — not left implicit in the resource files. Where a test intends to probe a boundary (7 days vs. 8 days), authors provide two fixtures that straddle it and pin the evaluation time so the boundary falls between them.

This directly enables tests of the timing-sensitive US Core elements in this IG. For example, USCoreElements.ageInDays() is defined in terms of Now()/Today():

Given a Patient with birthDate = "2026-01-01" and an evaluation time of 2026-01-08, ageInDays() equals 7.

Because the evaluation time is fixed by the test context rather than the clock, this assertion is reproducible.

2. Type-aware, negatable assertions

An assertion names the expression under test, an operator, and an expected value:

{
  "name": "Age In Days",
  "part": [
    { "name": "operator", "valueCode": "equals" },
    { "name": "expected", "valueInteger": 7 }
  ]
}

The operator vocabulary is organized by the type of the value under test. Tooling should only offer (and only accept) operators that are valid for the result type of the expression, satisfying criterion (1). Every operator is negatable via a negated part (valueBoolean: true), satisfying criterion (2).

Applies to Operators Chai analogue
Any type equals, isNull, exists .equal, .be.null, .exist
Numeric, Quantity, Date/Time (ordered) greaterThan, greaterOrEqual, lessThan, lessOrEqual, between .be.greaterThan, .be.at.least, .be.within
Decimal / Quantity (inexact) approximately (requires tolerance) .be.closeTo
String matches (regex), startsWith, contains (substring) .match, .include
List / collection includes, containsAll, containsAny, isEmpty, lengthOf, countGreaterThan .include, .have.members, .be.empty, .have.lengthOf
Tuple / FHIR structure hasProperty (with nested assertion via path), matchesPartial .have.property(p)

Because operators are gated by type, a test cannot ask whether a String is greaterThan a list, nor whether an Integer includes a value — mirroring how Chai matchers are only meaningful for the value's type. Negation lets a test assert, for example, that an expression does not equal a sentinel, or that a list does not include a resource that ought to have been filtered out.

3. Tolerance for floating-point and units

For any computed decimal or Quantity, the approximately operator compares within an explicit tolerance rather than by exact equality, satisfying criterion (3). Authors declare the precision the test actually cares about instead of transcribing an engine's rounding.

This is exactly what USCoreElements.CalculateBSA needs — a Mosteller BSA is a square root of a ratio and will not land on a round decimal:

{
  "name": "Body Surface Area (Mosteller)",
  "part": [
    { "name": "operator",  "valueCode": "approximately" },
    { "name": "expected",  "valueQuantity": { "value": 1.9, "unit": "m2" } },
    { "name": "tolerance", "valueQuantity": { "value": 0.01, "unit": "m2" } }
  ]
}

Given a patient with Height = 43.9 [in_i] and Weight, CalculateBSA('Mosteller', Height, Weight) is approximately 1.9 m2 within 0.01 m2.

Because the expected value and the result carry units, the comparison is unit-aware: the tooling normalizes to comparable UCUM units before applying the tolerance, so a result expressed in cm2 still matches an expectation in m2. This also addresses the "different units" aspect of criterion (2): a fixture may legitimately record height in inches or centimeters, and the same assertion holds.

4. Partial matching over lists and complex objects

When an expression returns a list, the meaningful assertion is frequently about membership, not equality of the whole list. The includes/containsAll/containsAny operators take an expected element described by a partial template — only the discriminating elements are specified, and a result element matches if it contains at least those, satisfying criterion (4).

Consider the balloter's example: given a patient whose renal function is normal (or not), an expression returns the set of MedicationRequests that should be included. A good test asserts that the output includes the request that must be present and excludes the one that must be filtered — without asserting anything about the rest of the set:

[
  {
    "name": "Included Medication Requests",
    "part": [
      { "name": "operator", "valueCode": "includes" },
      { "name": "expected", "resource": {
          "resourceType": "MedicationRequest",
          "identifier": [{ "system": "http://example.org/rx", "value": "nizatidine-continue" }]
      } }
    ]
  },
  {
    "name": "Included Medication Requests",
    "part": [
      { "name": "operator", "valueCode": "includes" },
      { "name": "negated",  "valueBoolean": true },
      { "name": "expected", "resource": {
          "resourceType": "MedicationRequest",
          "identifier": [{ "system": "http://example.org/rx", "value": "contraindicated-renal" }]
      } }
    ]
  }
]

Here the two assertions together say "the result includes the continuing therapy and does not include the renally-contraindicated request," while remaining silent about any other members — so the test does not break when unrelated requests are added to the fixture. hasProperty with a path provides the same partial-match capability for a single structured result (assert one property of a returned Tuple/resource, leaving the rest unconstrained), which is the CQL analogue of expect(x).to.have.property(y).with.lengthOf(z).

Worked Example: US Core Elements

Bringing the pieces together for the IG's own artifacts, a refined test case for USCoreElements reads as a set of natural-language expectations that each map to one assertion:

  • Given a patient born 2026-01-01, evaluated as of 2026-01-08ageInDays() equals 7; ageInYears() equals 0.
  • Given the same patient → ageInDays() is not null.
  • Given height 43.9 [in_i] and a body-weight ObservationCalculateBSA('Mosteller', Height, Weight) is approximately 1.9 m2 ± 0.01.
  • Given height recorded in cm instead of [in_i] → the same BSA assertion still holds (unit-aware).
  • Given a patient with three active medications, one contraindicated → "Included Medications" includes the two safe requests and does not include the contraindicated one.

Each bullet is one (expression, operator, expected) triple; together they exercise relative timing, tolerance, unit variation, and partial list matching without depending on the wall-clock or on an engine's rounding.

Concrete drafts of these test cases are available in the source repository as a starting point for discussion and for the enhanced runner:

  • input/tests/library/USCoreElementsTests/refined-age-in-days — relative timing (ageInDays/ageInYears), with a pinned evaluationDateTime
  • input/tests/library/USCoreElementsTests/refined-bsa — tolerant, unit-aware floating-point comparison (CalculateBSA, Mosteller and DuBois)
  • input/tests/library/USCoreElementsTests/refined-active-medications — partial list include/exclude (Active Medication Orders)
  • input/tests/library/USCoreCommonTests/refined-demographics — type-aware equality, negation, and partial Coding matching (birthSex, race)

The test expressions these cases assert against are declared in USCoreElementsTests.cql and USCoreCommonTests.cql under the "Refined unit test demonstration" headings. See the refined-tests README for the per-case expected values and how each maps to a proposal criterion.

Backward Compatibility and Next Steps

  • Compatibility. A name/value parameter with no operator part continues to mean equals, so every existing expected-results file remains a valid test case. The test-context resource is optional; test cases that do not use relative timing are unaffected.
  • Packaging. Test cases continue to travel as resource folders and packaged bundles per CRMI, distributed via the Downloads page, so the distribution model on the Testing page is unchanged.
  • Concrete drafts. The refined-* test cases referenced above are provided as illustrative drafts of the format. They are not yet executed by the current test runner (which performs exact-equality on name/value pairs); they seed the enhanced runner and give the working group concrete material to react to.
  • Authoring surface. The Parameters representation above is the on-disk/interchange form. A more concise human-authoring surface (a Chai-style fluent form, or the YAML used by the CQL Testing Framework) can compile to it, and is the expected day-to-day authoring experience in the VSCode CQL Plugin.
  • Reporting. Results should be emitted as a machine-readable report (aligned with FHIR TestReport) so that CI can gate on pass/fail per assertion.
  • Open questions for discussion. (a) The exact operator code system and whether to reuse TestScript assertion operators; (b) how matchesPartial semantics should treat list-valued elements within a template (any-match vs. all-match); (c) whether tolerance should also support relative (percentage) tolerance in addition to absolute.