diff --git a/CLAUDE.md b/CLAUDE.md index 81da77e..be2dcac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ Package: `htest.dev` | Repo: https://github.com/htest-dev/htest | Site: https:// | `npm run dev` | Eleventy dev server for docs site | | `npm run release` | Publish via release-it | -No Prettier, no EditorConfig — ESLint only (`eslint.config.js`). +ESLint (`eslint.config.js`) + Prettier. Run Prettier on JS files only — not on markdown. ## Architecture @@ -56,11 +56,13 @@ src/ These cascade from parent to child (set in `Test.js` constructor): ``` -beforeEach run afterEach map check getName getData args expect getExpect throws maxTime maxTimeAsync skip +beforeEach run afterEach map check args expect throws maxTime maxTimeAsync skip ``` NOT inherited: `beforeAll`, `afterAll`, `name`. `data` inherits via prototype chain (child sees parent's data; own properties shadow parent's). +`name`, `data`, and `expect` support getter syntax (`get name()`, `get data()`, `get expect()`). `name` and `data` also support function shorthand (`name()`, `data()`); `expect` does not (it can be a function value). Getter/shorthand forms are inherited, unlike literal values. `getName` and `getExpect` are legacy (shipped in v0.0.25) — prefer getter syntax. `getData` is internal only (never shipped publicly). + ## Self-hosted testing hTest tests itself. All test files in `tests/` use hTest's own declarative format. diff --git a/SKILL.md b/SKILL.md index 2a5443b..99cde53 100644 --- a/SKILL.md +++ b/SKILL.md @@ -69,20 +69,19 @@ All properties are optional and inherit from parent to child. | `run` | Function to execute. Called via `run.apply(testInstance, args)`. Inherited from parent — define once, never repeat. If omitted, result defaults to `args[0]` | | `arg` | Single argument passed to `run`. Can be any value | | `args` | Array of arguments passed to `run`. Non-arrays auto-wrapped. `arg` takes precedence | -| `expect` | Expected result. Deep equality by default | -| `getExpect` | Function to generate expected value dynamically. Called like `run`: `getExpect.apply(test, args)`. Inherited. `expect` takes precedence if both are set. If the getter throws, falls through to default (`args[0]`) | +| `expect` | Expected result. Deep equality by default. Inherited. Can be a getter (`get expect () { ... }`) to compute lazily. No function shorthand — `expect` can be a function value | +| `getExpect` | **(Legacy — prefer `get expect()`)** Function to generate expected value dynamically. Called like `run`: `getExpect.apply(test, args)`. Inherited. `expect` takes precedence if both are set. If the getter throws, falls through to default (`args[0]`) | | `throws` | `true` (any error), `false` (asserts no error thrown), Error subclass (`TypeError`), or predicate `e => e.code === "ENOENT"`. Inherited | ### Structure | Property | Description | | ------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `name` | Test/group label. Also accessible via `this.name` and `this.parent.name` in `run`. If a function, it's used as `getName` instead | -| `getName` | Function to generate names dynamically. Called like `run`: `getName.apply(test, args)`. Inherited (unlike `name`). If the getter throws, falls through to default (first arg or "(No args)") | +| `name` | Test/group label. Also accessible via `this.name` and `this.parent.name` in `run`. Can be a getter (`get name () { ... }`) or function shorthand (`name () { ... }`) to compute lazily. Literal names are not inherited, but getter/shorthand names are | +| `getName` | **(Legacy — prefer `get name()` / `name()`)** Function to generate names dynamically. Called like `run`: `getName.apply(test, args)`. Inherited (unlike literal `name`). If the getter throws, falls through to default (first arg or "(No args)") | | `description` | Human-readable explanation of the test's intent or edge case. Ignored by the runner | | `tests` | Array of child tests. If present, this is a group (parent); if absent, a leaf test | -| `data` | Inherited object accessible via `this.data`. Child inherits parent's data via prototype chain; own properties shadow parent's. If a function, it's used as `getData` instead | -| `getData` | Function to generate data dynamically. Called like `run`: `getData.apply(test, args)`. Inherited. `data` (literal) takes precedence if both are set. If the getter throws, falls through to empty data | +| `data` | Inherited object accessible via `this.data`. Child inherits parent's data via prototype chain; own properties shadow parent's. Can be a getter (`get data () { ... }`) or function shorthand (`data () { ... }`) for fresh per-test data | | `skip` | Any truthy value to skip. Can be an expression evaluated at load time, e.g. `skip: !globalThis.structuredClone`. Inherited — setting on a parent skips all children | ### Comparison @@ -103,7 +102,7 @@ Pass an object instead of a function to configure built-in comparison behavior: | `subset` | `false` | Only check properties present in `expect` — extra properties in the result are ignored | | `epsilon` | `0` | Numeric tolerance: passes if `Math.abs(actual - expect) <= epsilon` | | `looseTypes` | `false` | Use `==` instead of `===` at leaf level | -| `deep` | `true` | Recurse into objects/arrays. Set `false` for shallow-only | +| `deep` | `false` | Recurse into objects/arrays | **`subset: true` is the most useful option.** Use it when results may contain extra fields you don't want to validate: @@ -121,9 +120,9 @@ Pass an object instead of a function to configure built-in comparison behavior: Combine options freely: ```js -check: { epsilon: 0.005 } // numeric tolerance, deep (default) -check: { subset: true, epsilon: 0.0001 } // partial match + tolerance -check: { deep: false, looseTypes: true } // shallow loose equality +check: { epsilon: 0.005 } // numeric tolerance, shallow +check: { subset: true, deep: true } // partial match + deep +check: { looseTypes: true } // shallow loose equality ``` For a custom comparison, use an inline function — no import needed: @@ -179,14 +178,58 @@ If a hook throws, the test is **skipped** (not failed). Hooks are infrastructure | `afterEach` throws | Already ran | — | Test **skipped** | | `afterAll` throws | Already ran | — | Test results **unaffected** | +### Accessor Support + +Any test property supports native JS getter syntax (`get prop () { ... }`). Most properties also support function shorthand (`prop () { ... }`), except those whose value can be a function: `arg`, `expect`, `run`, hooks, `check`, `map`, `throws`. + +Function shorthands receive the same arguments as `run`, so you can use them to compute values based on test args: + +```js +{ + skip (x) { return x > 100; }, // skip tests where arg > 100 + run (x) { return transform(x); }, + tests: [ + { arg: 5 }, + { arg: 200 }, // skipped + ], +} +``` + +Getters and function shorthands are inherited by children (literal values are not, except `expect` — both literal and getter `expect` are inherited). + +```js +{ + get name () { + return `Level ${this.level}: ${this.arg}`; + }, + get data () { + return { items: [] }; // fresh per test + }, + get expect () { + return this.arg.toUpperCase(); + }, + run (input) { + this.data.items.push(input); + return this.data.items[0].toUpperCase(); + }, + tests: [ + { arg: "foo" }, + { arg: "bar" }, + ], +} +``` + +The legacy function properties `getName` and `getExpect` (shipped in v0.0.25) still work but getter syntax is preferred. + ## `this` Inside `run` `run` is called with `this` set to the Test instance. Available properties: -- `this.args` — argument array +- `this.arg` — the original `arg` value (if defined). Useful in getters; in `run`, prefer the parameter list +- `this.args` — argument array. Prefer `this.arg` over `this.args[0]` when the test uses `arg:` - `this.data` — inherited data object - `this.name` — test name -- `this.level` — nesting depth (root = 0). Useful in `getName` for depth-aware labels +- `this.level` — nesting depth (root = 0). Useful in name getters/shorthands for depth-aware labels - `this.parent` — parent test/group. Useful for extending the parent's `run` in a child: call `this.parent.run(...args)` first, then transform the result - `this.expect` — expected value @@ -335,13 +378,13 @@ export default { }; ``` -**Good use: `getData` for fresh per-test data** +**Good use: getter/shorthand for fresh per-test data** -When each test needs its own fresh data (e.g., an empty array to push into), use `getData` instead of `beforeEach`: +When each test needs its own fresh data (e.g., an empty array to push into), use a `get data()` getter or `data()` shorthand instead of `beforeEach`: ```js export default { - getData () { + get data () { return { items: [] }; }, run () { diff --git a/docs/README.md b/docs/README.md index 58c292a..7512e81 100644 --- a/docs/README.md +++ b/docs/README.md @@ -143,12 +143,12 @@ export default { data: { // custom inherited data arr: [1, 2, 3] }, - getName () { + get name () { if (this.level === 1) { return "#" + this.data.method + "()"; } - return `should return ${this.expect} when the value is ${this.args[0]}`; + return `should return ${this.expect} when the value is ${this.arg}`; } tests: [ { @@ -197,8 +197,8 @@ export default { ``` Here we moved the commonalities to test parents and used inherited data to pass the array and code to be tested to the tests. -We are also only specifying a name when it's non-obvious, and using the `getName` method to generate the name for us -(even with no `getName` method, hTest will generate a name for us based on the test parameters). +We are also only specifying a name when it's non-obvious, and using a `get name()` getter to generate the name for us +(even with no name getter, hTest will generate a name for us based on the test parameters). Notice that there is a spectrum between how much you want to abstract away and how much you want to specify in each test. It’s up to you where your tests would be in that spectrum. diff --git a/docs/define/README.md b/docs/define/README.md index 72c77e6..6320f02 100644 --- a/docs/define/README.md +++ b/docs/define/README.md @@ -25,13 +25,12 @@ Tests at the same nesting level run **in parallel**, so don't rely on execution | [`beforeAll`](#setup-teardown) | Function | Code to run before all tests in the group. | | [`afterAll`](#setup-teardown) | Function | Code to run after all tests in the group. | | [`data`](#data) | Object | Data that will be accessible to the running function as `this.data`. | -| [`getData`](#data) | Function | A function that generates data dynamically. | | [`name`](#name) | String or Function | A string that describes the test. | -| [`getName`](#name) | Function | A function that generates the test name dynamically. | +| [`getName`](#name) | Function | **(Legacy)** A function that generates the test name dynamically. | | [`description`](#description) | String | A longer description of the test or group of tests. | | [`id`](#id) | String | A unique identifier for the test. | | [`expect`](#expect) | Any | The expected result. | -| [`getExpect`](#expect) | Function | A function that generates the expected result dynamically. | +| [`getExpect`](#expect) | Function | **(Legacy)** A function that generates the expected result dynamically. | | [`throws`](#throws) | Boolean, Error subclass, or Function | Whether an error is expected to be thrown. | | [`maxTime`](#maxtime) | Number | The maximum time (in ms) that the test should take to run. | | [`maxTimeAsync`](#maxtime) | Number | The maximum time (in ms) that the test should take to resolve. | @@ -40,6 +39,14 @@ Tests at the same nesting level run **in parallel**, so don't rely on execution | [`skip`](#skip) | Any | Any truthy value skips the test(s). | +## Accessor support { #accessors } + +Any test property supports native JS getter syntax (e.g. `get skip () { ... }`). +Most properties also support function shorthand to compute the property value dynamically (e.g. `skip () { return !cond; }`) — except those whose value can be a function: `arg`, `expect`, `run`, hooks, `check`, `map`, `throws`. + +Function shorthands are called like `run` — with the same `this` context and arguments — so you can compute values based on test args. +Getters and function shorthands are inherited by children; literal values are generally not (see individual property docs for exceptions). + ## Defining the test ### Defining the code to be tested (`run`) { #run } @@ -98,24 +105,21 @@ If a hook throws, the test is **skipped** — not failed. You can define a single `beforeEach` or `afterEach` on a parent or ancestor and differentiate child tests via [`args`](#args) and [`data`](#data). -### Context parameters (`data` and `getData`) { #data } +### Context parameters (`data`) { #data } `data` is an optional object with data that will be accessible to the running function as `this.data`. A child’s data inherits from its parent’s, so you can define common data at a higher level and override it where needed. It is useful for differentiating the behavior of `run()` across groups of tests without having to redefine it or pass repetitive arguments. -`data` can also be a *data generator* function. -It is called with the same context and arguments as `run()` and returns an object whose properties are merged onto `this.data`. -You can also explicitly provide a function, via `getData`. -In fact, if `data` is a function, it gets rewritten as `getData` internally. +To compute data dynamically, use a [getter or function shorthand](#accessors). +The returned object's properties are merged onto `this.data`. +Unlike literal `data` objects (which inherit via the prototype chain), getter/shorthand data is inherited by children — define once on a parent, and every child gets a fresh copy. -If both `data` (as a literal object) and `getData` are defined, `data` wins. - -`getData` is useful for providing fresh per-test data without `beforeEach()`: +This is useful for providing fresh per-test data without `beforeEach()`: ```js { - getData () { return { items: [] }; }, + get data () { return { items: [] }; }, run () { this.data.items.push(1); return this.data.items.length; @@ -129,26 +133,35 @@ If both `data` (as a literal object) and `getData` are defined, `data` wins. ## Describing the test -### Names and name generators (`name` and `getName()`) { #name } +### Names and name generators (`name`) { #name } `name` is a string that describes the test. It is optional, but recommended, as it makes it easier to identify the test in the results. -`name` can also be a *name generator* function. -It is called with the same context and arguments as `run()` and returns the name as a string. -You can also explicitly provide a function, via `getName`. -This can be useful if you want to specify a name for the root of tests, as well as a name generator for child tests. -In fact, if `name` is a function, it gets rewritten as `getName` internally. +To compute names dynamically, use a [getter or function shorthand](#accessors): -Single names are not inherited, but name generator functions are. +```js +{ + get name () { + if (this.level === 1) { + return "#" + this.data.method + "()"; + } + return `should return ${this.expect} for ${this.arg}`; + }, + tests: [...] +} +``` -Name generators are useful for providing a default name for tests, that you can override on a case by case basis via `name`. -You may find `this.level` useful in the name generator, as it tells you how deep in the hierarchy the test is, allowing you to provide depth-sensitive name patterns. +Literal names are not inherited, but getter/shorthand names are. +This makes them useful for providing a default name for tests, that you can override on a case by case basis via a literal `name`. +You may find `this.level` useful in the getter/shorthand, as it tells you how deep in the hierarchy the test is, allowing you to provide depth-sensitive name patterns. If no name is provided, it defaults to the first argument passed to `run`, if any. -If `getName()` throws an error (e.g. by accessing `this.run` on a group with no `run`), the error is caught and the name falls through to its default. -This allows defining a `getName` that only works in certain contexts without crashing the test tree. +If the name getter throws an error (e.g. by accessing `this.run` on a group with no `run`), the error is caught and the name falls through to its default. +This allows defining a name getter that only works in certain contexts without crashing the test tree. + +**Legacy:** `getName` (a function property called like `run`) still works but getter/shorthand syntax is preferred. ### Description (`description`) { #description } @@ -167,17 +180,31 @@ E.g. you can use `maxTime` and `maxTimeAsync` together, but not with `expect` or If you specify multiple criteria, nothing will break, but you will get a warning. -### Result-based criteria (`expect` and `getExpect()`) { #expect } +### Result-based criteria (`expect`) { #expect } `expect` defines the expected result, so you'll be using it the most. If `expect` is *not defined*, it defaults to the first argument passed to `run()`, i.e. `this.args[0]`. -The expected result can also be generated dynamically via `getExpect`. -It is called with the same context and arguments as `run()` and returns the expected result. +To compute expected values dynamically, use a [getter](#accessors) (`expect` does not support function shorthand — it can legitimately be a function value): + +```js +{ + get expect () { + return this.arg.toUpperCase(); + }, + run: toUpperCase, + tests: [ + { arg: "foo" }, + { arg: "bar" }, + ], +} +``` + +Both literal and getter `expect` are inherited by children. -If both `expect` and `getExpect` are defined, `expect` wins. +If the expect getter throws an error, the error is caught and `expect` falls through to its default (`args[0]`). -If `getExpect()` throws an error, the error is caught and `expect` falls through to its default (`args[0]`). +**Legacy:** `getExpect` (a function property called like `run`) still works but getter syntax is preferred. ### Error-based criteria (`throws`) { #throws } diff --git a/src/classes/Test.js b/src/classes/Test.js index 0da162b..cb4c27b 100644 --- a/src/classes/Test.js +++ b/src/classes/Test.js @@ -1,5 +1,5 @@ import * as check from "../check.js"; -import { stringify } from "../util.js"; +import { stringify, defineLazyProperty } from "../util.js"; const INHERITED_PROPS = [ "beforeEach", @@ -7,11 +7,8 @@ const INHERITED_PROPS = [ "afterEach", "map", "check", - "getName", - "getData", "args", "expect", - "getExpect", "throws", "maxTime", "maxTimeAsync", @@ -19,12 +16,24 @@ const INHERITED_PROPS = [ "file", ]; +// Properties whose value can be a function — function shorthand not converted to getXXX +const NO_SHORTHAND = [ + "arg", + "expect", + "run", + "beforeEach", + "afterEach", + "beforeAll", + "afterAll", + "check", + "map", + "throws", +]; + /** * Represents a single test or a group of tests */ export default class Test { - data = {}; - constructor (test, parent) { if (!test) { console.warn("Empty test: ", test); @@ -63,45 +72,75 @@ export default class Test { } Object.defineProperties(this, descriptors); - if (typeof this.data === "function") { - this.getData = this.data; - this.data = {}; - } - - this.data = Object.create( - this.parent?.data ?? null, - Object.getOwnPropertyDescriptors(this.data), - ); - this.originalName = this.name; + // Convert getters and function shorthands to getXXX + // Getters are always converted; function shorthands only for non-function-valued props + let converted = new Set(); + for (let key of Object.keys(descriptors)) { + // getXXX are already getter functions (legacy API or from parent conversion) + if (/^get[A-Z]/.test(key)) { + continue; + } - if (typeof this.name === "function") { - this.getName = this.name; - delete this.name; + let accessor = + Object.getOwnPropertyDescriptor(this, key)?.get ?? + (!NO_SHORTHAND.includes(key) && typeof this[key] === "function" && this[key]); + if (accessor) { + let prop = "get" + key[0].toUpperCase() + key.slice(1); + this[prop] = accessor; + delete this[key]; + converted.add(key); + } } // Inherit properties from parent // This works recursively because the parent constructor runs before its children if (this.parent) { for (let prop of INHERITED_PROPS) { - if (!(prop in this) && prop in this.parent) { - this[prop] = this.parent[prop]; + // Conversion deletes the original prop; without this guard + // the parent's value would be inherited back over the child's getter + if (!(prop in this) && prop in this.parent && !converted.has(prop)) { + Object.defineProperty( + this, + prop, + Object.getOwnPropertyDescriptor(this.parent, prop), + ); } } - } - if (!this.check) { - this.check = check.equals; - } - else if (typeof this.check === "object") { - let { deep, ...options } = this.check; - let shallowEquals = check.shallowEquals(options); - this.check = deep ? check.deep(shallowEquals) : shallowEquals; + // Inherit getXXX properties (from getter/shorthand conversion) + for (let key of Object.keys(this.parent)) { + if (/^get[A-Z]/.test(key) && !(key in this)) { + Object.defineProperty( + this, + key, + Object.getOwnPropertyDescriptor(this.parent, key), + ); + } + } } - if ("arg" in this) { + // Lazy args (arg takes precedence over inherited args) + if (this.getArg) { + converted.delete("arg"); + defineLazyProperty(this, "arg", function () { + return this.getArg(); + }); + // Can't fall through to `this.args = [this.arg]` below — that would eagerly trigger the getter + defineLazyProperty(this, "args", function () { + return [this.arg]; + }); + } + else if ("arg" in this) { // Single argument this.args = [this.arg]; } + else if (this.getArgs) { + converted.delete("args"); + defineLazyProperty(this, "args", function () { + let args = this.getArgs(); + return Array.isArray(args) ? args : [args]; + }); + } else if ("args" in this) { // Single args don't need to be wrapped in an array if (!Array.isArray(this.args)) { @@ -113,25 +152,66 @@ export default class Test { this.args = []; } - if (this.getData && Object.keys(this.data).length === 0) { - try { - let data = this.getData.apply(this, this.args); - Object.defineProperties(this.data, Object.getOwnPropertyDescriptors(data)); - } - catch {} + if (this.getCheck) { + converted.delete("check"); + defineLazyProperty(this, "check", function () { + let value; + try { + value = this.getCheck(); + } + catch {} + + if (value && typeof value === "object") { + let { deep, ...options } = value; + let shallowEquals = check.shallowEquals(options); + return deep ? check.deep(shallowEquals) : shallowEquals; + } + // Falsy or non-callable values (e.g. getter threw) fall back to default + return typeof value === "function" ? value : check.equals; + }); + } + else if (!("check" in this)) { + this.check = check.equals; + } + else if (typeof this.check === "object") { + let { deep, ...options } = this.check; + let shallowEquals = check.shallowEquals(options); + this.check = deep ? check.deep(shallowEquals) : shallowEquals; + } + // Falsy or non-callable values (e.g. check: false) fall back to default + else if (typeof this.check !== "function") { + this.check = check.equals; } - if (!this.name) { - if (this.getName) { + // Prototype chain deferred — avoids triggering parent getters; data getters already converted to getData above + let ownData = this.data ?? {}; + converted.delete("data"); + defineLazyProperty(this, "data", function () { + let data = Object.create( + this.parent?.data ?? null, + Object.getOwnPropertyDescriptors(ownData), + ); + + if (this.getData && Object.keys(data).length === 0) { try { - this.name = this.getName.apply(this, this.args); + let computed = this.getData.apply(this, this.args); + Object.defineProperties(data, Object.getOwnPropertyDescriptors(computed)); } catch {} } - if (!this.name && this.isTest) { - this.name = this.args.length > 0 ? stringify(this.args[0]) : "(No args)"; - } + return data; + }); + + // Generic lazy resolution for converted properties without custom resolvers + // name/expect are handled below (they have fallback logic) + converted.delete("name"); + converted.delete("expect"); + for (let prop of converted) { + let key = "get" + prop[0].toUpperCase() + prop.slice(1); + defineLazyProperty(this, prop, function () { + return this[key].apply(this, this.args); + }); } if (this.isGroup) { @@ -140,17 +220,32 @@ export default class Test { .map(t => (t instanceof Test ? t : new Test(t, this))); } - if (!("expect" in this)) { - if (this.getExpect) { + if (!this.name && (this.getName || this.isTest)) { + defineLazyProperty(this, "name", function () { + let name; try { - this.expect = this.getExpect.apply(this, this.args); + name = this.getName?.apply(this, this.args); } catch {} - } - if (!("expect" in this)) { - this.expect = this.args[0]; - } + if (!name && this.isTest) { + name = this.args.length > 0 ? stringify(this.args[0]) : "(No args)"; + } + + return name; + }); + } + + if (!("expect" in this)) { + defineLazyProperty(this, "expect", function () { + if (this.getExpect) { + try { + return this.getExpect.apply(this, this.args); + } + catch {} + } + return this.args[0]; + }); } } diff --git a/src/util.js b/src/util.js index 37b56cf..d3c0cae 100644 --- a/src/util.js +++ b/src/util.js @@ -247,3 +247,34 @@ export async function interceptConsole (fn) { export function pluralize (n, singular, plural) { return n === 1 ? singular : plural; } + +/** + * Define a property that resolves on first access and caches the result. + * @param {object} obj Object to define the property on + * @param {string} prop Property name + * @param {function} resolve Called with `this` bound to the accessing object. Must not read `obj[prop]` (would recurse) + */ +export function defineLazyProperty (obj, prop, resolve) { + Object.defineProperty(obj, prop, { + get () { + let value = resolve.call(this); + Object.defineProperty(this, prop, { + value, + writable: true, + enumerable: true, + configurable: true, + }); + return value; + }, + set (value) { + Object.defineProperty(this, prop, { + value, + writable: true, + enumerable: true, + configurable: true, + }); + }, + enumerable: true, + configurable: true, + }); +} diff --git a/tests/accessors.js b/tests/accessors.js new file mode 100644 index 0000000..4f13dcf --- /dev/null +++ b/tests/accessors.js @@ -0,0 +1,361 @@ +import Test from "../src/classes/Test.js"; + +let add = (a, b) => a + b; + +export default { + name: "Accessors", + tests: [ + { + name: "Getters do not fire during Test construction", + run () { + let log = []; + new Test({ + get arg () { + log.push("arg"); + return 42; + }, + get expect () { + log.push("expect"); + return 84; + }, + get name () { + log.push("name"); + return "test"; + }, + get check () { + log.push("check"); + return (a, b) => a === b; + }, + get data () { + log.push("data"); + return { x: 1 }; + }, + run (x) { + return x * 2; + }, + }); + return log; + }, + expect: [], + }, + { + name: "Getters resolve to correct values on access", + run () { + let test = new Test({ + get arg () { + return 42; + }, + get expect () { + return 84; + }, + run (x) { + return x * 2; + }, + }); + return { args: test.args, expect: test.expect }; + }, + expect: { args: [42], expect: 84 }, + }, + { + name: "get arg() — children inherit resolved value", + run () { + let test = new Test({ + get arg () { + return 42; + }, + run (x) { + return x; + }, + tests: [{}], + }); + return test.tests[0].args; + }, + expect: [42], + }, + { + name: "Parent getters do not fire during child construction", + run () { + let log = []; + new Test({ + get data () { + log.push("parent-data"); + return { x: 1 }; + }, + get name () { + log.push("parent-name"); + return "parent"; + }, + run (x) { + return x; + }, + tests: [{ arg: 1 }, { arg: 2 }], + }); + return log; + }, + expect: [], + }, + { + name: "get name() — inherited by children", + run () { + let test = new Test({ + get name () { + return "computed " + this.arg; + }, + run (x) { + return x; + }, + tests: [{ arg: 7 }], + }); + return test.tests[0].name; + }, + expect: "computed 7", + }, + { + name: "get name() — own getter wins over inherited getName", + run () { + let test = new Test({ + getName () { + return "parent"; + }, + run (x) { + return x; + }, + tests: [ + { + get name () { + return "child"; + }, + arg: 1, + }, + ], + }); + return test.tests[0].name; + }, + expect: "child", + }, + { + name: "get name() — failure falls through to default", + run () { + let test = new Test({ + get name () { + throw new Error(); + }, + arg: 42, + }); + return test.name; + }, + expect: "42", + }, + { + name: "get name() — fails on group without run, works on children", + run () { + let test = new Test({ + get name () { + return this.run.name + "()"; + }, + tests: [{ + run: function foo () { + return 42; + }, + arg: "foo", + }], + }); + return test.tests[0].name; + }, + expect: "foo()", + }, + { + name: "get expect() — accesses test args", + get expect () { + return this.arg * 2; + }, + run (x) { + return x * 2; + }, + arg: 5, + }, + { + name: "get arg() — lazy arg property resolves on access", + run () { + let test = new Test({ + get arg () { + return 42; + }, + }); + return test.arg; + }, + expect: 42, + }, + { + name: "get expect() — explicit expect wins over inherited getter", + get expect () { + return "generated"; + }, + run () { + return "explicit"; + }, + tests: [{ arg: "foo", expect: "explicit" }], + }, + { + name: "get expect() — own getter wins over inherited plain expect", + run () { + let test = new Test({ + expect: "parent", + run () { + return "child"; + }, + tests: [ + { + get expect () { + return "child"; + }, + }, + ], + }); + return test.tests[0].expect; + }, + expect: "child", + }, + { + name: "get expect() — failure falls through to args[0]", + get expect () { + throw new Error(); + }, + run (x) { + return x; + }, + arg: "foo", + }, + { + name: "get data() — fresh per test", + description: "Each sibling gets its own data object, mutations don't leak", + run () { + let test = new Test({ + get data () { + return { items: [] }; + }, + run () { + return this.data; + }, + tests: [{}, {}], + }); + test.tests[0].data.items.push("a"); + return { first: test.tests[0].data.items, second: test.tests[1].data.items }; + }, + expect: { first: ["a"], second: [] }, + }, + { + name: "get data() — merges with parent data", + run () { + let test = new Test({ + data: { parent: 1 }, + tests: [ + { + get data () { + return { child: 2 }; + }, + run () { + return this.data; + }, + tests: [{}], + }, + ], + }); + let data = test.tests[0].data; + return { parent: data.parent, child: data.child }; + }, + expect: { parent: 1, child: 2 }, + }, + { + name: "name() shorthand — inherited by children", + run () { + let test = new Test({ + name () { + return "group " + this.level; + }, + tests: [{ arg: 42 }], + }); + return { parent: test.name, child: test.tests[0].name }; + }, + expect: { parent: "group 0", child: "group 1" }, + }, + { + name: "data() shorthand — provides fresh data per test", + run () { + let test = new Test({ + data () { + return { x: 42 }; + }, + run () { + return this.data.x; + }, + tests: [{}, {}], + }); + return { first: test.tests[0].data.x, second: test.tests[1].data.x }; + }, + expect: { first: 42, second: 42 }, + }, + { + name: "get data() — failure falls through to empty data", + run () { + let test = new Test({ + get data () { + throw new Error(); + }, + }); + return Object.keys(test.data); + }, + expect: [], + }, + { + name: "get check() — getter return value is used as check function", + run () { + let custom = () => "custom"; + let test = new Test({ + get check () { + return custom; + }, + }); + return test.check(); + }, + expect: "custom", + }, + { + name: "get check() — throwing getter falls back to equality", + run () { + let test = new Test({ + get check () { + throw new Error(); + }, + }); + return { same: test.check(1, 1), diff: test.check(1, 2) }; + }, + expect: { same: true, diff: false }, + }, + { + name: "Function shorthand receives test args", + run () { + let test = new Test({ + skip (x) { + return x > 100; + }, + run (x) { + return x; + }, + tests: [{ arg: 5 }, { arg: 200 }], + }); + return [test.tests[0].skip, test.tests[1].skip]; + }, + expect: [false, true], + }, + { + name: "Function-valued expect is not called as a factory", + description: "expect can be a function value (the expected result *is* a function)", + run () { + let test = new Test({ expect: add }); + return test.expect; + }, + expect: add, + }, + ], +}; diff --git a/tests/getters.js b/tests/getters.js index a9d9b80..0191af4 100644 --- a/tests/getters.js +++ b/tests/getters.js @@ -30,19 +30,6 @@ export default { }, tests: [{ arg: 42, expect: "42" }], }, - { - name: "name() function shorthand", - run () { - let t = new Test({ - name () { - return "group " + this.level; - }, - tests: [{ arg: 42 }], - }); - return { parent: t.name, child: t.tests[0].name }; - }, - expect: { parent: "group 0", child: "group 1" }, - }, { name: "Failure on group with no run() does not crash children (issue #119)", run () { diff --git a/tests/properties.js b/tests/properties.js new file mode 100644 index 0000000..e69983f --- /dev/null +++ b/tests/properties.js @@ -0,0 +1,79 @@ +import Test from "../src/classes/Test.js"; + +export default { + name: "Defaults and inheritance", + tests: [ + { + name: "No name → auto-generated from args", + run () { + let test = new Test({ arg: 42 }); + return test.name; + }, + expect: "42", + }, + { + name: "No expect → defaults to args[0]", + run () { + let test = new Test({ arg: 42 }); + return test.expect; + }, + expect: 42, + }, + { + name: "expect inherits from parent", + run () { + let test = new Test({ + expect: "foo", + run () { + return "foo"; + }, + tests: [{}], + }); + return test.tests[0].expect; + }, + expect: "foo", + }, + { + name: "name string does not inherit", + run () { + let test = new Test({ + name: "Parent Group", + run (x) { + return x; + }, + tests: [{ arg: 42 }], + }); + return test.tests[0].name; + }, + expect: "42", + }, + { + name: "arg inherits via args", + run () { + let test = new Test({ + arg: 1, + run (x) { + return x; + }, + tests: [{}], + }); + return test.tests[0].args; + }, + expect: [1], + }, + { + name: "arg overrides inherited args", + run () { + let test = new Test({ + args: [1, 2], + run (x) { + return x; + }, + tests: [{ arg: 5 }], + }); + return test.tests[0].args; + }, + expect: [5], + }, + ], +};