generic-mock.js 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. import {same} from 'tcompare';
  2. import {empty} from '#sugar';
  3. export default function mock(callback) {
  4. const mocks = [];
  5. const track = callback => (...args) => {
  6. const {value, close} = callback(...args);
  7. mocks.push({close});
  8. return value;
  9. };
  10. const mock = {
  11. function: track(mockFunction),
  12. };
  13. return {
  14. value: callback(mock),
  15. close: () => {
  16. const errors = [];
  17. for (const mock of mocks) {
  18. try {
  19. mock.close();
  20. } catch (error) {
  21. errors.push(error);
  22. }
  23. }
  24. if (!empty(errors)) {
  25. throw new AggregateError(errors, `Errors closing sub-mocks`);
  26. }
  27. },
  28. };
  29. }
  30. export function mockFunction(...args) {
  31. let name = '(anonymous)';
  32. let behavior = null;
  33. if (args.length === 2) {
  34. if (
  35. typeof args[0] === 'string' &&
  36. typeof args[1] === 'function'
  37. ) {
  38. name = args[0];
  39. behavior = args[1];
  40. } else {
  41. throw new TypeError(`Expected name to be a string`);
  42. }
  43. } else if (args.length === 1) {
  44. if (typeof args[0] === 'string') {
  45. name = args[0];
  46. } else if (typeof args[0] === 'function') {
  47. behavior = args[0];
  48. } else if (args[0] !== null) {
  49. throw new TypeError(`Expected string (name), function (behavior), both, or null / no arguments`);
  50. }
  51. } else if (args.length > 2) {
  52. throw new TypeError(`Expected string (name), function (behavior), both, or null / no arguments`);
  53. }
  54. let currentCallDescription = newCallDescription();
  55. const allCallDescriptions = [currentCallDescription];
  56. const topLevelErrors = [];
  57. let runningCallCount = 0;
  58. let limitCallCount = false;
  59. let markedAsOnce = false;
  60. const fn = (...args) => {
  61. const description = processCall(...args);
  62. return description.behavior(...args);
  63. };
  64. fn.behavior = value => {
  65. if (!(value === null || (
  66. typeof value === 'function'
  67. ))) {
  68. throw new TypeError(`Expected function or null`);
  69. }
  70. currentCallDescription.behavior = behavior;
  71. currentCallDescription.described = true;
  72. return fn;
  73. }
  74. fn.argumentCount = value => {
  75. if (!(value === null || (
  76. typeof value === 'number' &&
  77. value === parseInt(value) &&
  78. value >= 0
  79. ))) {
  80. throw new TypeError(`Expected whole number or null`);
  81. }
  82. if (currentCallDescription.argsPattern) {
  83. throw new TypeError(`Unexpected .argumentCount() when .args() has been called`);
  84. }
  85. currentCallDescription.argsPattern = {length: value};
  86. currentCallDescription.described = true;
  87. return fn;
  88. };
  89. fn.args = (...args) => {
  90. const value = args[0];
  91. if (args.length > 1 || !(value === null || Array.isArray(value))) {
  92. throw new TypeError(`Expected one array or null`);
  93. }
  94. currentCallDescription.argsPattern = Object.fromEntries(
  95. value
  96. .map((v, i) => v === undefined ? false : [i, v])
  97. .filter(Boolean)
  98. .concat([['length', value.length]]));
  99. currentCallDescription.described = true;
  100. return fn;
  101. };
  102. fn.neverCalled = (...args) => {
  103. if (!empty(args)) {
  104. throw new TypeError(`Didn't expect any arguments`);
  105. }
  106. if (allCallDescriptions[0].described) {
  107. throw new TypeError(`Unexpected .neverCalled() when any descriptions provided`);
  108. }
  109. limitCallCount = true;
  110. allCallDescriptions.splice(0, allCallDescriptions.length);
  111. currentCallDescription = new Proxy({}, {
  112. set() {
  113. throw new Error(`Unexpected description when .neverCalled() has been called`);
  114. },
  115. });
  116. return fn;
  117. };
  118. fn.once = (...args) => {
  119. if (!empty(args)) {
  120. throw new TypeError(`Didn't expect any arguments`);
  121. }
  122. if (allCallDescriptions.length > 1) {
  123. throw new TypeError(`Unexpected .once() when providing multiple descriptions`);
  124. }
  125. currentCallDescription.described = true;
  126. limitCallCount = true;
  127. markedAsOnce = true;
  128. return fn;
  129. };
  130. fn.next = (...args) => {
  131. if (!empty(args)) {
  132. throw new TypeError(`Didn't expect any arguments`);
  133. }
  134. if (markedAsOnce) {
  135. throw new TypeError(`Unexpected .next() when .once() has been called`);
  136. }
  137. currentCallDescription = newCallDescription();
  138. allCallDescriptions.push(currentCallDescription);
  139. limitCallCount = true;
  140. return fn;
  141. };
  142. fn.repeat = times => {
  143. // Note: This function should be called AFTER filling out the
  144. // call description which is being repeated.
  145. if (!(
  146. typeof times === 'number' &&
  147. times === parseInt(times) &&
  148. times >= 2
  149. )) {
  150. throw new TypeError(`Expected whole number of at least 2`);
  151. }
  152. if (markedAsOnce) {
  153. throw new TypeError(`Unexpected .repeat() when .once() has been called`);
  154. }
  155. // The current call description is already in the full list,
  156. // so skip the first push.
  157. for (let i = 2; i <= times; i++) {
  158. allCallDescriptions.push(currentCallDescription);
  159. }
  160. // Prep a new description like when calling .next().
  161. currentCallDescription = newCallDescription();
  162. allCallDescriptions.push(currentCallDescription);
  163. limitCallCount = true;
  164. return fn;
  165. };
  166. return {
  167. value: fn,
  168. close: () => {
  169. const totalCallCount = runningCallCount;
  170. const expectedCallCount = countDescribedCalls();
  171. if (limitCallCount && totalCallCount !== expectedCallCount) {
  172. if (expectedCallCount > 1) {
  173. topLevelErrors.push(new Error(`Expected ${expectedCallCount} calls, got ${totalCallCount}`));
  174. } else if (expectedCallCount === 1) {
  175. topLevelErrors.push(new Error(`Expected 1 call, got ${totalCallCount}`));
  176. } else {
  177. topLevelErrors.push(new Error(`Expected no calls, got ${totalCallCount}`));
  178. }
  179. }
  180. if (topLevelErrors.length) {
  181. throw new AggregateError(topLevelErrors, `Errors in mock ${name}`);
  182. }
  183. },
  184. };
  185. function newCallDescription() {
  186. return {
  187. described: false,
  188. behavior: behavior ?? null,
  189. argumentCount: null,
  190. argsPattern: null,
  191. };
  192. }
  193. function processCall(...args) {
  194. const callErrors = [];
  195. runningCallCount++;
  196. // No further processing, this indicates the function shouldn't have been
  197. // called at all and there aren't any descriptions to match this call with.
  198. if (empty(allCallDescriptions)) {
  199. return newCallDescription();
  200. }
  201. const currentCallNumber = runningCallCount;
  202. const currentDescription = selectCallDescription(currentCallNumber);
  203. const {
  204. argumentCount,
  205. argsPattern,
  206. } = currentDescription;
  207. if (argumentCount !== null && args.length !== argumentCount) {
  208. callErrors.push(
  209. new Error(`Argument count mismatch: expected ${argumentCount}, got ${args.length}`));
  210. }
  211. if (argsPattern !== null) {
  212. const keysToCheck = Object.keys(argsPattern);
  213. const argsAsObject = Object.fromEntries(
  214. args
  215. .map((v, i) => [i.toString(), v])
  216. .filter(([i]) => keysToCheck.includes(i))
  217. .concat([['length', args.length]]));
  218. const {match, diff} = same(argsAsObject, argsPattern);
  219. if (!match) {
  220. callErrors.push(new Error(`Argument pattern mismatch:\n` + diff));
  221. }
  222. }
  223. if (!empty(callErrors)) {
  224. const aggregate = new AggregateError(callErrors, `Errors in call #${currentCallNumber}`);
  225. topLevelErrors.push(aggregate);
  226. }
  227. return currentDescription;
  228. }
  229. function selectCallDescription(currentCallNumber) {
  230. if (currentCallNumber > countDescribedCalls()) {
  231. const lastDescription = lastCallDescription();
  232. if (lastDescription.described) {
  233. return newCallDescription();
  234. } else {
  235. return lastDescription;
  236. }
  237. } else {
  238. return allCallDescriptions[currentCallNumber - 1];
  239. }
  240. }
  241. function countDescribedCalls() {
  242. if (empty(allCallDescriptions)) {
  243. return 0;
  244. }
  245. return (
  246. (lastCallDescription().described
  247. ? allCallDescriptions.length
  248. : allCallDescriptions.length - 1));
  249. }
  250. function lastCallDescription() {
  251. return allCallDescriptions[allCallDescriptions.length - 1];
  252. }
  253. }