« get me outta code hell

data steps: basic custom mocking function support - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-03-27 12:47:04 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-03-27 12:47:04 -0300
commitc6f1011722dc6fe50afb3a63ee414c70dbfd6abf (patch)
treed13235a4b37e8264a1fbccdfad12009f7a3a4f6d
parentcb13d591c6965dc52d89ec4d1e10558e6b22456b (diff)
data steps: basic custom mocking function support
I checked out a few libraries but none really behaved
the way I needed, and coding it myself means much lower-
level access, which makes certain options a lot easier
(e.g. excluding one argument of a mocked function from
assertion while matching the rest against a pattern).
-rw-r--r--package-lock.json82
-rw-r--r--package.json5
-rw-r--r--src/content/dependencies/index.js67
-rw-r--r--test/lib/content-function.js93
-rw-r--r--test/lib/generic-mock.js262
-rw-r--r--test/snapshot/linkArtist.js5
-rw-r--r--test/snapshot/linkTemplate.js5
-rw-r--r--test/unit/content/dependencies/linkArtist.js31
8 files changed, 508 insertions, 42 deletions
diff --git a/package-lock.json b/package-lock.json
index e53ee60a..99b64643 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,7 +21,8 @@
             "devDependencies": {
                 "chokidar": "^3.5.3",
                 "eslint": "^8.18.0",
-                "tap": "^16.3.4"
+                "tap": "^16.3.4",
+                "tcompare": "^6.0.0"
             }
         },
         "node_modules/@ampproject/remapping": {
@@ -1877,6 +1878,18 @@
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
+        "node_modules/libtap/node_modules/tcompare": {
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz",
+            "integrity": "sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==",
+            "dev": true,
+            "dependencies": {
+                "diff": "^4.0.2"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
         "node_modules/locate-path": {
             "version": "5.0.0",
             "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -4250,6 +4263,18 @@
                 "yaml": "^1.10.2"
             }
         },
+        "node_modules/tap/node_modules/tcompare": {
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz",
+            "integrity": "sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==",
+            "dev": true,
+            "dependencies": {
+                "diff": "^4.0.2"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
         "node_modules/tap/node_modules/to-fast-properties": {
             "version": "2.0.0",
             "dev": true,
@@ -4502,15 +4527,24 @@
             }
         },
         "node_modules/tcompare": {
-            "version": "5.0.7",
-            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz",
-            "integrity": "sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==",
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-6.0.0.tgz",
+            "integrity": "sha512-JeX89lSVkxTzYND0LxzFCGrXm/TqGEQ0heu1JTwplnpaYQNky6hIaO4lQBOrs+/P787i3CoK9T/O3/oEcnJXvA==",
             "dev": true,
             "dependencies": {
-                "diff": "^4.0.2"
+                "diff": "^5.1.0"
             },
             "engines": {
-                "node": ">=10"
+                "node": ">=16"
+            }
+        },
+        "node_modules/tcompare/node_modules/diff": {
+            "version": "5.1.0",
+            "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
+            "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.3.1"
             }
         },
         "node_modules/test-exclude": {
@@ -6185,6 +6219,17 @@
                 "tap-yaml": "^1.0.0",
                 "tcompare": "^5.0.6",
                 "trivial-deferred": "^1.0.1"
+            },
+            "dependencies": {
+                "tcompare": {
+                    "version": "5.0.7",
+                    "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz",
+                    "integrity": "sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==",
+                    "dev": true,
+                    "requires": {
+                        "diff": "^4.0.2"
+                    }
+                }
             }
         },
         "locate-path": {
@@ -7762,6 +7807,15 @@
                         "yaml": "^1.10.2"
                     }
                 },
+                "tcompare": {
+                    "version": "5.0.7",
+                    "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz",
+                    "integrity": "sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==",
+                    "dev": true,
+                    "requires": {
+                        "diff": "^4.0.2"
+                    }
+                },
                 "to-fast-properties": {
                     "version": "2.0.0",
                     "bundled": true,
@@ -7973,12 +8027,20 @@
             }
         },
         "tcompare": {
-            "version": "5.0.7",
-            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz",
-            "integrity": "sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==",
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-6.0.0.tgz",
+            "integrity": "sha512-JeX89lSVkxTzYND0LxzFCGrXm/TqGEQ0heu1JTwplnpaYQNky6hIaO4lQBOrs+/P787i3CoK9T/O3/oEcnJXvA==",
             "dev": true,
             "requires": {
-                "diff": "^4.0.2"
+                "diff": "^5.1.0"
+            },
+            "dependencies": {
+                "diff": {
+                    "version": "5.1.0",
+                    "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
+                    "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==",
+                    "dev": true
+                }
             }
         },
         "test-exclude": {
diff --git a/package.json b/package.json
index 7f8e32e6..9d9a2cf4 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
         "hsmusic": "./src/upd8.js"
     },
     "scripts": {
-        "test": "tap test/snapshot/*.js test/unit/**/*.js",
+        "test": "tap 'test/snapshot/*.js' 'test/unit/**/*.js'",
         "dev": "eslint src && node src/upd8.js"
     },
     "dependencies": {
@@ -22,7 +22,8 @@
     "devDependencies": {
         "chokidar": "^3.5.3",
         "eslint": "^8.18.0",
-        "tap": "^16.3.4"
+        "tap": "^16.3.4",
+        "tcompare": "^6.0.0"
     },
     "tap": {
         "coverage": false,
diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js
index 7f86abb1..767828ad 100644
--- a/src/content/dependencies/index.js
+++ b/src/content/dependencies/index.js
@@ -8,6 +8,7 @@ import {color, logWarn} from '../../util/cli.js';
 import {annotateFunction} from '../../util/sugar.js';
 
 export function watchContentDependencies({
+  mock = null,
   logging = true,
 } = {}) {
   const events = new EventEmitter();
@@ -46,6 +47,23 @@ export function watchContentDependencies({
     checkReadyConditions();
   });
 
+  if (mock) {
+    const errors = [];
+    for (const [functionName, spec] of Object.entries(mock)) {
+      try {
+        const fn = processFunctionSpec(functionName, spec);
+        contentDependencies[functionName] = fn;
+      } catch (error) {
+        error.message = `(${functionName}) ${error.message}`;
+        errors.push(error);
+      }
+    }
+    if (errors.length) {
+      throw new AggregateError(errors, `Errors processing mocked content functions`);
+    }
+    checkReadyConditions();
+  }
+
   return events;
 
   async function close() {
@@ -81,13 +99,21 @@ export function watchContentDependencies({
     return functionName;
   }
 
+  function isMocked(functionName) {
+    return !!mock && Object.keys(mock).includes(functionName);
+  }
+
   async function handlePathRemoved(filePath) {
     const functionName = getFunctionName(filePath);
+    if (isMocked(functionName)) return;
+
     delete contentDependencies[functionName];
   }
 
   async function handlePathUpdated(filePath) {
     const functionName = getFunctionName(filePath);
+    if (isMocked(functionName)) return;
+
     let error = null;
 
     main: {
@@ -100,26 +126,11 @@ export function watchContentDependencies({
         break main;
       }
 
-      try {
-        if (typeof spec.data === 'function') {
-          annotateFunction(spec.data, {name: functionName, description: 'data'});
-        }
-
-        if (typeof spec.generate === 'function') {
-          annotateFunction(spec.generate, {name: functionName});
-        }
-      } catch (caughtError) {
-        error = caughtError;
-        error.message = `Error annotating functions: ${error.message}`;
-        break main;
-      }
-
       let fn;
       try {
-        fn = contentFunction(spec);
+        fn = processFunctionSpec(functionName, spec);
       } catch (caughtError) {
         error = caughtError;
-        error.message = `Error loading spec: ${error.message}`;
         break main;
       }
 
@@ -155,11 +166,31 @@ export function watchContentDependencies({
 
     return false;
   }
+
+  function processFunctionSpec(functionName, spec) {
+    if (typeof spec.data === 'function') {
+      annotateFunction(spec.data, {name: functionName, description: 'data'});
+    }
+
+    if (typeof spec.generate === 'function') {
+      annotateFunction(spec.generate, {name: functionName});
+    }
+
+    let fn;
+    try {
+      fn = contentFunction(spec);
+    } catch (error) {
+      error.message = `Error loading spec: ${error.message}`;
+      throw error;
+    }
+
+    return fn;
+  }
 }
 
-export function quickLoadContentDependencies() {
+export function quickLoadContentDependencies(opts) {
   return new Promise((resolve, reject) => {
-    const watcher = watchContentDependencies();
+    const watcher = watchContentDependencies(opts);
 
     watcher.on('error', (name, error) => {
       watcher.close().then(() => {
diff --git a/test/lib/content-function.js b/test/lib/content-function.js
index b51f2847..21af0e5a 100644
--- a/test/lib/content-function.js
+++ b/test/lib/content-function.js
@@ -7,11 +7,15 @@ import urlSpec from '../../src/url-spec.js';
 import {getColors} from '../../src/util/colors.js';
 import {generateURLs} from '../../src/util/urls.js';
 
+import mock from './generic-mock.js';
+
 export function testContentFunctions(t, message, fn) {
   const urls = generateURLs(urlSpec);
 
   t.test(message, async t => {
-    const loadedContentDependencies = await quickLoadContentDependencies();
+    let loadedContentDependencies;
+
+    const mocks = [];
 
     const evaluate = ({
       from = 'localized.home',
@@ -19,9 +23,13 @@ export function testContentFunctions(t, message, fn) {
       extraDependencies = {},
       ...opts
     }) => {
+      if (!loadedContentDependencies) {
+        throw new Error(`Await .load() before performing tests`);
+      }
+
       const {to} = urls.from(from);
 
-      try {
+      return cleanCatchAggregate(() => {
         return quickEvaluate({
           ...opts,
           contentDependencies: {
@@ -37,19 +45,88 @@ export function testContentFunctions(t, message, fn) {
             ...extraDependencies,
           },
         });
-      } catch (error) {
-        if (error instanceof AggregateError) {
-          error = new Error(`AggregateError: ${error.message}\n${error.errors.map(err => `** ${err}`).join('\n')}`);
-        }
-        throw error;
+      });
+    };
+
+    evaluate.load = async (opts) => {
+      if (loadedContentDependencies) {
+        throw new Error(`Already loaded!`);
       }
+
+      loadedContentDependencies = await asyncCleanCatchAggregate(() =>
+        quickLoadContentDependencies(opts));
     };
 
     evaluate.snapshot = (opts, fn) => {
+      if (!loadedContentDependencies) {
+        throw new Error(`Await .load() before performing tests`);
+      }
+
       const result = (fn ? fn(evaluate(opts)) : evaluate(opts));
       t.matchSnapshot(result.toString(), 'output');
     };
 
-    return fn(t, evaluate);
+    evaluate.mock = (...opts) => {
+      const {value, close} = mock(...opts);
+      mocks.push({close});
+      return value;
+    };
+
+    await fn(t, evaluate);
+
+    if (mocks.length) {
+      cleanCatchAggregate(() => {
+        const errors = [];
+        for (const {close} of mocks) {
+          try {
+            close();
+          } catch (error) {
+            errors.push(error);
+          }
+        }
+        if (errors.length) {
+          throw new AggregateError(errors, `Errors closing mocks`);
+        }
+      });
+    }
   });
 }
+
+function cleanAggregate(error) {
+  if (error instanceof AggregateError) {
+    return new Error(`[AggregateError: ${error.message}\n${
+      error.errors
+        .map(cleanAggregate)
+        .map(err => ` * ${err.message.split('\n').map((l, i) => (i > 0 ? '   ' + l : l)).join('\n')}`)
+        .join('\n')}]`);
+  } else {
+    return error;
+  }
+}
+
+function printAggregate(error) {
+  if (error instanceof AggregateError) {
+    const {message} = cleanAggregate(error);
+    for (const line of message.split('\n')) {
+      console.error(line);
+    }
+  }
+}
+
+function cleanCatchAggregate(fn) {
+  try {
+    return fn();
+  } catch (error) {
+    printAggregate(error);
+    throw error;
+  }
+}
+
+async function asyncCleanCatchAggregate(fn) {
+  try {
+    return await fn();
+  } catch (error) {
+    printAggregate(error);
+    throw error;
+  }
+}
diff --git a/test/lib/generic-mock.js b/test/lib/generic-mock.js
new file mode 100644
index 00000000..841ba462
--- /dev/null
+++ b/test/lib/generic-mock.js
@@ -0,0 +1,262 @@
+import {same} from 'tcompare';
+
+export default function mock(callback) {
+  const mocks = [];
+
+  const track = callback => (...args) => {
+    const {value, close} = callback(...args);
+    mocks.push({close});
+    return value;
+  };
+
+  const mock = {
+    function: track(mockFunction),
+  };
+
+  return {
+    value: callback(mock),
+    close: () => {
+      const errors = [];
+      for (const mock of mocks) {
+        try {
+          mock.close();
+        } catch (error) {
+          errors.push(error);
+        }
+      }
+      if (errors.length) {
+        throw new AggregateError(errors, `Errors closing sub-mocks`);
+      }
+    },
+  };
+}
+
+export function mockFunction(...args) {
+  let name = '(anonymous)';
+  let behavior = null;
+
+  if (args.length === 2) {
+    if (
+      typeof args[0] === 'string' &&
+      typeof args[1] === 'function'
+    ) {
+      name = args[0];
+      behavior = args[1];
+    } else {
+      throw new TypeError(`Expected name to be a string`);
+    }
+  } else if (args.length === 1) {
+    if (typeof args[0] === 'string') {
+      name = args[0];
+    } else if (typeof args[0] === 'function') {
+      behavior = args[0];
+    } else if (args[0] !== null) {
+      throw new TypeError(`Expected string (name), function (behavior), both, or null / no arguments`);
+    }
+  } else if (args.length > 2) {
+    throw new TypeError(`Expected string (name), function (behavior), both, or null / no arguments`);
+  }
+
+  let currentCallDescription = newCallDescription();
+  const allCallDescriptions = [currentCallDescription];
+
+  const topLevelErrors = [];
+  let runningCallCount = 0;
+  let limitCallCount = false;
+  let markedAsOnce = false;
+
+  const fn = (...args) => {
+    const description = processCall(...args);
+    return description.behavior(...args);
+  };
+
+  fn.behavior = value => {
+    if (!(value === null || (
+      typeof value === 'function'
+    ))) {
+      throw new TypeError(`Expected function or null`);
+    }
+
+    currentCallDescription.behavior = behavior;
+    currentCallDescription.described = true;
+
+    return fn;
+  }
+
+  fn.argumentCount = value => {
+    if (!(value === null || (
+      typeof value === 'number' &&
+      value === parseInt(value) &&
+      value >= 0
+    ))) {
+      throw new TypeError(`Expected whole number or null`);
+    }
+
+    if (currentCallDescription.argsPattern) {
+      throw new TypeError(`Unexpected .argumentCount() when .args() has been called`);
+    }
+
+    currentCallDescription.argsPattern = {length: value};
+    currentCallDescription.described = true;
+
+    return fn;
+  };
+
+  fn.args = (...args) => {
+    const value = args[0];
+
+    if (args.length > 1 || !(value === null || Array.isArray(value))) {
+      throw new TypeError(`Expected one array or null`);
+    }
+
+    currentCallDescription.argsPattern = Object.fromEntries(
+      value
+        .map((v, i) => v === undefined ? false : [i, v])
+        .filter(Boolean)
+        .concat([['length', value.length]]));
+
+    currentCallDescription.described = true;
+
+    return fn;
+  };
+
+  fn.once = (...args) => {
+    if (args.length) {
+      throw new TypeError(`Didn't expect any arguments`);
+    }
+
+    if (allCallDescriptions.length > 1) {
+      throw new TypeError(`Unexpected .once() when providing multiple descriptions`);
+    }
+
+    limitCallCount = true;
+    markedAsOnce = true;
+
+    return fn;
+  };
+
+  fn.next = (...args) => {
+    if (args.length) {
+      throw new TypeError(`Didn't expect any arguments`);
+    }
+
+    if (markedAsOnce) {
+      throw new TypeError(`Unexpected .next() when .once() has been called`);
+    }
+
+    currentCallDescription = newCallDescription();
+    allCallDescriptions.push(currentCallDescription);
+
+    limitCallCount = true;
+    return fn;
+  };
+
+  fn.repeat = times => {
+    // Note: This function should be called AFTER filling out the
+    // call description which is being repeated.
+
+    if (!(
+      typeof value === 'number' &&
+      value === parseInt(value) &&
+      value >= 2
+    )) {
+      throw new TypeError(`Expected whole number of at least 2`);
+    }
+
+    if (markedAsOnce) {
+      throw new TypeError(`Unexpected .repeat() when .once() has been called`);
+    }
+
+    // The current call description is already in the full list,
+    // so skip the first push.
+    for (let i = 2; i <= times; i++) {
+      allCallDescriptions.push(currentCallDescription);
+    }
+
+    // Prep a new description like when calling .next().
+    currentCallDescription = newCallDescription();
+    allCallDescriptions.push(currentCallDescription);
+
+    limitCallCount = true;
+
+    return fn;
+  };
+
+  return {
+    value: fn,
+    close: () => {
+      if (topLevelErrors.length) {
+        throw new AggregateError(topLevelErrors, `Errors in mock ${name}`);
+      }
+    },
+  };
+
+  function newCallDescription() {
+    return {
+      described: false,
+      behavior: behavior ?? null,
+      argumentCount: null,
+      argsPattern: null,
+    };
+  }
+
+  function processCall(...args) {
+    const callErrors = [];
+
+    runningCallCount++;
+    const currentCallNumber = runningCallCount;
+    const currentDescription = selectCallDescription(currentCallNumber);
+
+    const {
+      argumentCount,
+      argsPattern,
+    } = currentDescription;
+
+    if (argumentCount !== null) {
+      if (args.length !== argumentCount) {
+        callErrors.push(new Error(`Argument count mismatch: expected ${argumentCount}, got ${args.length}`));
+      }
+    }
+
+    if (argsPattern !== null) {
+      const keysToCheck = Object.keys(argsPattern);
+      const argsAsObject = Object.fromEntries(
+        args
+          .map((v, i) => [i.toString(), v])
+          .filter(([i]) => keysToCheck.includes(i))
+          .concat([['length', args.length]]));
+
+      const {match, diff} = same(argsAsObject, argsPattern);
+      if (!match) {
+        callErrors.push(new Error(`Argument pattern mismatch:\n` + diff));
+      }
+    }
+
+    if (callErrors.length) {
+      const aggregate = new AggregateError(callErrors, `Errors in call #${currentCallNumber}`);
+      topLevelErrors.push(aggregate);
+    }
+
+    return currentDescription;
+  }
+
+  function selectCallDescription(currentCallNumber) {
+    // console.log(currentCallNumber, allCallDescriptions[0]);
+
+    const lastDescription = allCallDescriptions[allCallDescriptions.length - 1];
+    const describedCount =
+      (lastDescription.described
+        ? allCallDescriptions.length
+        : allCallDescriptions.length - 1);
+
+    if (currentCallNumber > describedCount) {
+      if (lastDescription.described) {
+        return newCallDescription();
+      } else {
+        return lastDescription;
+      }
+    } else {
+      return allCallDescriptions[currentCallNumber - 1];
+    }
+  }
+}
diff --git a/test/snapshot/linkArtist.js b/test/snapshot/linkArtist.js
index 383dcab2..633e2ae6 100644
--- a/test/snapshot/linkArtist.js
+++ b/test/snapshot/linkArtist.js
@@ -1,8 +1,9 @@
 import t from 'tap';
-
 import {testContentFunctions} from '../lib/content-function.js';
 
-testContentFunctions(t, 'linkArtist', (t, evaluate) => {
+testContentFunctions(t, 'linkArtist', async (t, evaluate) => {
+  await evaluate.load();
+
   evaluate.snapshot({
     name: 'linkArtist',
     args: [
diff --git a/test/snapshot/linkTemplate.js b/test/snapshot/linkTemplate.js
index 6a629682..10321897 100644
--- a/test/snapshot/linkTemplate.js
+++ b/test/snapshot/linkTemplate.js
@@ -1,8 +1,9 @@
 import t from 'tap';
-
 import {testContentFunctions} from '../lib/content-function.js';
 
-testContentFunctions(t, 'linkTemplate', (t, evaluate) => {
+testContentFunctions(t, 'linkTemplate', async (t, evaluate) => {
+  await evaluate.load();
+
   evaluate.snapshot({
     name: 'linkTemplate',
     extraDependencies: {
diff --git a/test/unit/content/dependencies/linkArtist.js b/test/unit/content/dependencies/linkArtist.js
new file mode 100644
index 00000000..6d9e637d
--- /dev/null
+++ b/test/unit/content/dependencies/linkArtist.js
@@ -0,0 +1,31 @@
+import t from 'tap';
+import {testContentFunctions} from '../../../lib/content-function.js';
+
+testContentFunctions(t, 'linkArtist', async (t, evaluate) => {
+  const artistObject = {};
+  const linkTemplate = {};
+
+  await evaluate.load({
+    mock: evaluate.mock(mock => ({
+      linkThing: {
+        relations: mock.function('linkThing.relations', () => ({}))
+          .args([undefined, 'localized.artist', artistObject])
+          .once(),
+
+        data: mock.function('linkThing.data', () => ({}))
+          .args(['localized.artist', artistObject])
+          .once(),
+
+        generate: mock.function('linkThing.data', () => linkTemplate)
+          .once(),
+      }
+    })),
+  });
+
+  const result = evaluate({
+    name: 'linkArtist',
+    args: [artistObject],
+  });
+
+  t.equal(result, linkTemplate);
+});