@rcompat/test 0.11.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,9 +5,9 @@ Testing library for JavaScript runtimes.
5
5
  ## What is @rcompat/test?
6
6
 
7
7
  A cross-runtime testing library with a fluent assertion API. Provides deep
8
- equality checks, type assertions, exception testing, and fetch interception.
9
- Designed to work with the `proby` test runner. Works consistently across
10
- Node, Deno, and Bun.
8
+ equality checks, type assertions, exception testing, fetch interception, and
9
+ module mocking. Designed to work with the `proby` test runner. Works
10
+ consistently across Node, Deno, and Bun.
11
11
 
12
12
  ## Installation
13
13
 
@@ -115,12 +115,12 @@ test.case("throws", assert => {
115
115
  // check that function throws
116
116
  assert(() => {
117
117
  throw new Error("oops");
118
- }).throws();
118
+ }).throws("oops");
119
119
 
120
- // Check specific error message
120
+ // Check specific error type
121
121
  assert(() => {
122
- throw new Error("invalid input");
123
- }).throws("invalid input");
122
+ throw new TypeError("invalid input");
123
+ }).throws(TypeError);
124
124
  });
125
125
 
126
126
  test.case("tries (does not throw)", assert => {
@@ -178,6 +178,106 @@ test.ended(async () => {
178
178
  });
179
179
  ```
180
180
 
181
+ ### Grouping tests
182
+
183
+ Use `test.group` to cluster related test cases together. Groups can be run
184
+ individually via proby.
185
+
186
+ ```js
187
+ import test from "@rcompat/test";
188
+
189
+ test.group("addition", () => {
190
+ test.case("integers", assert => {
191
+ assert(1 + 1).equals(2);
192
+ });
193
+
194
+ test.case("floats", assert => {
195
+ assert(0.1 + 0.2).equals(0.3);
196
+ });
197
+ });
198
+
199
+ test.group("subtraction", () => {
200
+ test.case("integers", assert => {
201
+ assert(3 - 1).equals(2);
202
+ });
203
+ });
204
+ ```
205
+
206
+ Run a specific group:
207
+
208
+ ```bash
209
+ npx proby math.spec.ts addition
210
+ ```
211
+
212
+ ### Mocking modules
213
+
214
+ Use `test.mock` to replace a module's exports and `test.import` to import the
215
+ mocked module.
216
+
217
+ ```js
218
+ import test from "@rcompat/test";
219
+
220
+ using math = test.mock("./math.ts", () => ({
221
+ add: (a, b) => 99,
222
+ }));
223
+
224
+ const { add } = await test.import("./math.ts");
225
+
226
+ test.case("returns mocked value", assert => {
227
+ assert(add(1, 2)).equals(99);
228
+ });
229
+
230
+ test.case("tracks calls", assert => {
231
+ add(1, 2);
232
+ assert(math.add.calls.length).equals(1);
233
+ assert(math.add.calls[0]).equals([1, 2]);
234
+ assert(math.add.called).true();
235
+ });
236
+ ```
237
+
238
+ Tracked mock functions record each call as a tuple of arguments in `calls`.
239
+ Call tracking resets between test cases.
240
+
241
+ ### Static mocks with proby
242
+
243
+ When running tests with `proby`, you can preload mocks before a spec file is
244
+ evaluated by adding a sibling mock file.
245
+
246
+ - `foo.spec.ts` pairs with `foo.mock.ts`
247
+ - `foo.spec.js` pairs with `foo.mock.js`
248
+
249
+ `proby` loads the mock file before the spec file, so the spec's static imports
250
+ see the mocked module immediately.
251
+
252
+ ```ts
253
+ // math.ts
254
+ export function add(a: number, b: number) {
255
+ return a + b;
256
+ }
257
+ ```
258
+
259
+ ```ts
260
+ // math.mock.ts
261
+ import test from "@rcompat/test";
262
+
263
+ test.mock("./math.ts", () => ({
264
+ add: (a: number, b: number) => 99,
265
+ }));
266
+ ```
267
+
268
+ ```ts
269
+ // math.spec.ts
270
+ import test from "@rcompat/test";
271
+ import { add } from "./math.ts";
272
+
273
+ test.case("static mock is loaded before the spec", assert => {
274
+ assert(add(1, 2)).equals(99);
275
+ });
276
+ ```
277
+
278
+ Static mocks are file-scoped when run through `proby`; they do not leak into
279
+ later spec files.
280
+
181
281
  ### Intercepting fetch
182
282
 
183
283
  Use `test.intercept` to block outbound fetch calls to a specific origin and
@@ -283,6 +383,45 @@ test.ended(callback: () => void | Promise<void>): void;
283
383
 
284
384
  Register a cleanup callback to run after all tests in the file.
285
385
 
386
+ ### `test.group`
387
+
388
+ ```ts
389
+ test.group(name: string, fn: () => void): void;
390
+ ```
391
+
392
+ Group test cases under a named scope. Groups can be targeted individually
393
+ when running proby.
394
+
395
+ | Parameter | Type | Description |
396
+ | --------- | ---------- | ----------------------------------- |
397
+ | `name` | `string` | Group name, used by proby to filter |
398
+ | `fn` | `function` | Function containing `test.case` calls |
399
+
400
+ ### `test.mock`
401
+
402
+ ```ts
403
+ test.mock<T extends object>(
404
+ specifier: string,
405
+ factory: (original: unknown) => T,
406
+ ): MockHandle<T>;
407
+ ```
408
+
409
+ Register a module mock and return a handle to the tracked mocked exports.
410
+ Function exports are wrapped so you can inspect `calls` and `called`.
411
+
412
+ | Parameter | Type | Description |
413
+ | ----------- | ---------- | -------------------------------------------- |
414
+ | `specifier` | `string` | Module specifier to mock |
415
+ | `factory` | `function` | Returns the mocked exports for that module |
416
+
417
+ ### `test.import`
418
+
419
+ ```ts
420
+ test.import(specifier: string): Promise<unknown>;
421
+ ```
422
+
423
+ Import a module after mocks have been registered.
424
+
286
425
  ### `test.intercept`
287
426
 
288
427
  ```ts
@@ -295,32 +434,10 @@ test.intercept(
295
434
  Intercept outbound fetch calls to `base_url`. Returns an `Intercept` object
296
435
  for asserting on recorded requests.
297
436
 
298
- | Parameter | Type | Description |
299
- | ---------- | ---------- | ------------------------------------------------ |
300
- | `base_url` | `string` | Origin to intercept, e.g. `"https://api.example.com"` |
301
- | `setup` | `function` | Register route handlers on the setup object |
302
-
303
- #### `Setup`
304
-
305
- | Method | Description |
306
- | ------------------------------- | ---------------------------- |
307
- | `get(path, handler)` | Register a GET handler |
308
- | `post(path, handler)` | Register a POST handler |
309
- | `put(path, handler)` | Register a PUT handler |
310
- | `patch(path, handler)` | Register a PATCH handler |
311
- | `delete(path, handler)` | Register a DELETE handler |
312
-
313
- Each handler receives the incoming `Request` and returns a plain object,
314
- which is serialized into a `Response` automatically.
315
-
316
- #### `Intercept`
317
-
318
- | Method | Description |
319
- | ------------------- | ---------------------------------------------------- |
320
- | `calls(path)` | Number of times `path` was hit |
321
- | `requests(path)` | Array of `Request` objects recorded for `path` |
322
- | `restore()` | Reinstate the original `globalThis.fetch` |
323
- | `[Symbol.asyncDispose]` | Called automatically by `await using` |
437
+ | Parameter | Type | Description |
438
+ | ---------- | ---------- | --------------------------------------------------------- |
439
+ | `base_url` | `string` | Origin to intercept, e.g. `"https://api.example.com"` |
440
+ | `setup` | `function` | Register route handlers on the setup object |
324
441
 
325
442
  ### `test.extend`
326
443
 
@@ -333,9 +450,31 @@ test.extend<Subject, Extensions>(
333
450
  Create a new test object with custom assertion methods mixed into the
334
451
  asserter.
335
452
 
336
- | Parameter | Type | Description |
337
- | --------- | ---------- | -------------------------------------------------------- |
338
- | `factory` | `function` | Returns extra methods to attach to each `Assert` instance |
453
+ | Parameter | Type | Description |
454
+ | --------- | ---------- | ---------------------------------------------------------- |
455
+ | `factory` | `function` | Returns extra methods to attach to each `Assert` instance |
456
+
457
+ #### `Setup`
458
+
459
+ | Method | Description |
460
+ | ---------------------- | ------------------------- |
461
+ | `get(path, handler)` | Register a GET handler |
462
+ | `post(path, handler)` | Register a POST handler |
463
+ | `put(path, handler)` | Register a PUT handler |
464
+ | `patch(path, handler)` | Register a PATCH handler |
465
+ | `delete(path, handler)`| Register a DELETE handler |
466
+
467
+ Each handler receives the incoming `Request` and returns a plain object,
468
+ which is serialized into a `Response` automatically.
469
+
470
+ #### `Intercept`
471
+
472
+ | Method | Description |
473
+ | ----------------------- | ----------------------------------------------- |
474
+ | `calls(path)` | Number of times `path` was hit |
475
+ | `requests(path)` | Array of `Request` objects recorded for `path` |
476
+ | `restore()` | Reinstate the original `globalThis.fetch` |
477
+ | `[Symbol.asyncDispose]` | Called automatically by `await using` |
339
478
 
340
479
  ### `Asserter`
341
480
 
@@ -406,7 +545,7 @@ import E from "@rcompat/test/E";
406
545
  E(error: unknown): { message: string };
407
546
  ```
408
547
 
409
- Extract error message from unknown error type.
548
+ Extract error data from unknown error types.
410
549
 
411
550
  ## Examples
412
551
 
@@ -5,9 +5,10 @@ import type { FileRef } from "@rcompat/fs";
5
5
  export default class Suite {
6
6
  #private;
7
7
  constructor(file: FileRef);
8
- test(name: string, body: Body): void;
8
+ test(name: string, body: Body, group?: string): void;
9
9
  ended(end: End): void;
10
10
  get file(): FileRef;
11
+ between(fn: () => void): void;
11
12
  run(): AsyncGenerator<Test, void, unknown>;
12
13
  end(): Promise<void>;
13
14
  }
@@ -3,11 +3,12 @@ export default class Suite {
3
3
  #file;
4
4
  #tests = [];
5
5
  #ends = [];
6
+ #between = [];
6
7
  constructor(file) {
7
8
  this.#file = file;
8
9
  }
9
- test(name, body) {
10
- this.#tests.push(new Test(name, body));
10
+ test(name, body, group) {
11
+ this.#tests.push(new Test(name, body, group));
11
12
  }
12
13
  ended(end) {
13
14
  this.#ends.push(end);
@@ -15,9 +16,15 @@ export default class Suite {
15
16
  get file() {
16
17
  return this.#file;
17
18
  }
19
+ between(fn) {
20
+ this.#between.push(fn);
21
+ }
18
22
  async *run() {
19
23
  for (const test of this.#tests) {
20
24
  yield await test.run();
25
+ for (const fn of this.#between) {
26
+ fn();
27
+ }
21
28
  }
22
29
  }
23
30
  async end() {
@@ -2,8 +2,9 @@ import type Body from "#Body";
2
2
  import Result from "#Result";
3
3
  export default class Test {
4
4
  #private;
5
- constructor(name: string, body: Body);
5
+ constructor(name: string, body: Body, group?: string);
6
6
  get name(): string;
7
+ get group(): string | undefined;
7
8
  get results(): Result<unknown>[];
8
9
  report<T>(actual: T, expected: T, passed: boolean): void;
9
10
  run(): Promise<this>;
@@ -4,13 +4,18 @@ export default class Test {
4
4
  #name;
5
5
  #body;
6
6
  #results = [];
7
- constructor(name, body) {
7
+ #group;
8
+ constructor(name, body, group) {
8
9
  this.#name = name;
9
10
  this.#body = body;
11
+ this.#group = group;
10
12
  }
11
13
  get name() {
12
14
  return this.#name;
13
15
  }
16
+ get group() {
17
+ return this.#group;
18
+ }
14
19
  get results() {
15
20
  return this.#results;
16
21
  }
@@ -0,0 +1,2 @@
1
+ export declare function add(a: number, b: number): number;
2
+ //# sourceMappingURL=math.d.ts.map
@@ -0,0 +1,5 @@
1
+ export function add(a, b) {
2
+ return a + b;
3
+ }
4
+ ;
5
+ //# sourceMappingURL=math.js.map
@@ -0,0 +1,2 @@
1
+ export default function mock_import(specifier: string): Promise<any>;
2
+ //# sourceMappingURL=import.d.ts.map
@@ -0,0 +1,4 @@
1
+ export default function mock_import(specifier) {
2
+ return import(`${specifier}?mock=${Date.now()}`);
3
+ }
4
+ //# sourceMappingURL=import.js.map
@@ -5,6 +5,8 @@ import type Env from "#Env";
5
5
  import type Result from "#Result";
6
6
  import type Test from "#Test";
7
7
  import type { ExtendedTest, Factory } from "#extend";
8
+ import mock from "#mock";
9
+ import import_ from "#import";
8
10
  declare const _default: {
9
11
  intercept: (base_url: string, setup: (setup: {
10
12
  get(path: string, handler: (request: Request) => unknown): void;
@@ -21,6 +23,9 @@ declare const _default: {
21
23
  extend<Subject, Extensions>(factory: Factory<Subject, Extensions>): ExtendedTest<Extensions>;
22
24
  case(name: string, body: Body): void;
23
25
  ended(end: End): void;
26
+ group(name: string, fn: () => void): void;
27
+ mock: typeof mock;
28
+ import: typeof import_;
24
29
  };
25
30
  export default _default;
26
31
  export type { Asserter, Env, ExtendedTest, Result, Test };
@@ -1,6 +1,8 @@
1
1
  import extend from "#extend";
2
2
  import repository from "#repository";
3
3
  import intercept from "#intercept";
4
+ import mock from "#mock";
5
+ import import_ from "#import";
4
6
  const base = {
5
7
  case(name, body) {
6
8
  repository.put(name, body);
@@ -8,6 +10,11 @@ const base = {
8
10
  ended(end) {
9
11
  repository.ended(end);
10
12
  },
13
+ group(name, fn) {
14
+ repository.group(name, fn);
15
+ },
16
+ mock,
17
+ import: import_,
11
18
  };
12
19
  export default {
13
20
  ...base,
@@ -0,0 +1,14 @@
1
+ type AnyFn = (...args: any[]) => any;
2
+ type Tracked<F extends AnyFn> = F & {
3
+ calls: Parameters<F>[];
4
+ readonly called: boolean;
5
+ };
6
+ type MockHandle<T extends object> = {
7
+ [K in keyof T]: T[K] extends AnyFn ? Tracked<T[K]> : T[K];
8
+ } & {
9
+ restore(): void;
10
+ [Symbol.dispose](): void;
11
+ };
12
+ export default function mock<T extends object>(specifier: string, factory: (original: unknown) => T): MockHandle<T>;
13
+ export {};
14
+ //# sourceMappingURL=mock.d.ts.map
@@ -0,0 +1,87 @@
1
+ import repository from "#repository";
2
+ import is from "@rcompat/is";
3
+ import module from "node:module";
4
+ let loader_registered = false;
5
+ const MARK = "std:test/mock";
6
+ const clean = (specifier) => specifier.split("?")[0];
7
+ const track = (fn) => {
8
+ const calls = [];
9
+ const tracked = ((...args) => {
10
+ calls.push(args);
11
+ return fn(...args);
12
+ });
13
+ tracked.calls = calls;
14
+ Object.defineProperty(tracked, "called", {
15
+ get: () => calls.length > 0,
16
+ });
17
+ return tracked;
18
+ };
19
+ const hooks = {
20
+ resolve(specifier, context, next) {
21
+ const id = clean(specifier);
22
+ if (!repository.mocks.has(id)) {
23
+ return next(specifier, context);
24
+ }
25
+ const resolved = next(id, context);
26
+ const url = new URL(resolved.url);
27
+ url.searchParams.set(MARK, id);
28
+ return {
29
+ ...resolved,
30
+ shortCircuit: true,
31
+ url: url.href,
32
+ };
33
+ },
34
+ load(url, context, next) {
35
+ const parsed = new URL(url);
36
+ const id = parsed.searchParams.get(MARK);
37
+ if (id === null) {
38
+ return next(url, context);
39
+ }
40
+ const tracked = repository.mocks.get(id);
41
+ if (tracked === undefined)
42
+ throw new Error(`mock not found for ${id}`);
43
+ const exports = Object.keys(tracked);
44
+ const source = `
45
+ import repository from "@rcompat/test/repository";
46
+ const tracked = repository.mocks.get(${JSON.stringify(id)});
47
+ if (tracked === undefined) {
48
+ throw new Error(${JSON.stringify(`mock not found for ${id}`)});
49
+ }
50
+ ${exports.map(k => `export const ${k} = tracked[${JSON.stringify(k)}];`).join("\n")}
51
+ `;
52
+ return {
53
+ format: "module",
54
+ shortCircuit: true,
55
+ source,
56
+ };
57
+ },
58
+ };
59
+ export default function mock(specifier, factory) {
60
+ if (!loader_registered) {
61
+ module.registerHooks(hooks);
62
+ loader_registered = true;
63
+ }
64
+ const id = clean(specifier);
65
+ const mocked = factory({});
66
+ const tracked = Object.fromEntries(Object.entries(mocked).map(([k, v]) => [
67
+ k,
68
+ is.function(v) ? track(v) : v,
69
+ ]));
70
+ repository.mocks.set(id, tracked);
71
+ repository.between(() => {
72
+ for (const v of Object.values(tracked)) {
73
+ if (is.function(v) && "calls" in v) {
74
+ v.calls.length = 0;
75
+ }
76
+ }
77
+ });
78
+ return Object.assign(tracked, {
79
+ restore() {
80
+ repository.mocks.delete(id);
81
+ },
82
+ [Symbol.dispose]() {
83
+ this.restore();
84
+ },
85
+ });
86
+ }
87
+ //# sourceMappingURL=mock.js.map
@@ -4,7 +4,10 @@ import Suite from "#Suite";
4
4
  import type { FileRef } from "@rcompat/fs";
5
5
  declare class Repository {
6
6
  #private;
7
+ get mocks(): Map<string, unknown>;
7
8
  put(name: string, body: Body): void;
9
+ group(name: string, fn: () => void): void;
10
+ between(fn: () => void): void;
8
11
  ended(end: End): void;
9
12
  suite(file: FileRef): void;
10
13
  reset(): void;
@@ -1,11 +1,24 @@
1
1
  import Suite from "#Suite";
2
2
  class Repository {
3
3
  #suites = [];
4
+ #current_group;
5
+ #mocks = new Map();
4
6
  get #suite() {
5
7
  return this.#suites.at(-1);
6
8
  }
9
+ get mocks() {
10
+ return this.#mocks;
11
+ }
7
12
  put(name, body) {
8
- this.#suite.test(name, body);
13
+ this.#suite.test(name, body, this.#current_group);
14
+ }
15
+ group(name, fn) {
16
+ this.#current_group = name;
17
+ fn();
18
+ this.#current_group = undefined;
19
+ }
20
+ between(fn) {
21
+ this.#suite.between(fn);
9
22
  }
10
23
  ended(end) {
11
24
  this.#suite.ended(end);
@@ -15,6 +28,7 @@ class Repository {
15
28
  }
16
29
  reset() {
17
30
  this.#suites = [];
31
+ this.#mocks = new Map();
18
32
  }
19
33
  *next() {
20
34
  for (const suite of this.#suites) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rcompat/test",
3
- "version": "0.11.3",
3
+ "version": "0.12.0",
4
4
  "description": "Standard library testing",
5
5
  "bugs": "https://github.com/rcompat/rcompat/issues",
6
6
  "license": "MIT",
@@ -14,42 +14,45 @@
14
14
  "url": "https://github.com/rcompat/rcompat",
15
15
  "directory": "packages/test"
16
16
  },
17
+ "dependencies": {
18
+ "@rcompat/is": "^0.6.0"
19
+ },
17
20
  "devDependencies": {
18
- "@rcompat/fs": "^0.27.1",
19
- "@rcompat/type": "^0.10.0"
21
+ "@rcompat/fs": "^0.28.0",
22
+ "@rcompat/type": "^0.11.0"
20
23
  },
21
24
  "type": "module",
22
25
  "imports": {
23
26
  "#mask/*": {
24
- "apekit": "./src/private/mask/*.ts",
27
+ "source": "./src/private/mask/*.ts",
25
28
  "default": "./lib/private/mask/*.js"
26
29
  },
27
30
  "#types/*": {
28
- "apekit": "./src/private/types/*.ts",
31
+ "source": "./src/private/types/*.ts",
29
32
  "default": "./lib/private/types/*.js"
30
33
  },
31
34
  "#*": {
32
- "apekit": "./src/private/*.ts",
35
+ "source": "./src/private/*.ts",
33
36
  "default": "./lib/private/*.js"
34
37
  }
35
38
  },
36
39
  "exports": {
37
40
  "./mask/*": {
38
- "apekit": "./src/public/mask/*.ts",
41
+ "source": "./src/public/mask/*.ts",
39
42
  "default": "./lib/public/mask/*.js"
40
43
  },
41
44
  "./*": {
42
- "apekit": "./src/public/*.ts",
45
+ "source": "./src/public/*.ts",
43
46
  "default": "./lib/public/*.js"
44
47
  },
45
48
  ".": {
46
- "apekit": "./src/public/index.ts",
49
+ "source": "./src/public/index.ts",
47
50
  "default": "./lib/public/index.js"
48
51
  }
49
52
  },
50
53
  "scripts": {
51
54
  "build": "npm run clean && tsc",
52
- "test": "npm run build && npx proby",
55
+ "test": "npx proby",
53
56
  "clean": "rm -rf ./lib"
54
57
  }
55
58
  }