lil-mocky 1.4.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 ADDED
@@ -0,0 +1,705 @@
1
+ # lil-mocky
2
+
3
+ A lightweight JavaScript mocking library for testing. Create mock functions, objects, classes, and properties with call tracking and return value control. Includes spy functionality for tracking calls to existing methods.
4
+
5
+ ## ðŸŽŊ Features
6
+
7
+ - ðŸŠķ **Lightweight**: Minimal dependencies, focused on core mocking functionality
8
+ - 🔧 **Flexible**: Builder pattern for composable mock configuration
9
+ - 📊 **Call Tracking**: Automatic tracking of all function calls with deep-cloned arguments
10
+ - 🎭 **Spy Support**: Track calls to existing methods while preserving or replacing behavior
11
+ - 🏗ïļ **Class Mocks**: Per-instance mock configuration for complex class testing
12
+ - ✅ **Simple API**: Clean, intuitive API that's easy to learn and use
13
+
14
+ ## ðŸ“Ĩ Installation
15
+
16
+ ```bash
17
+ npm install lil-mocky
18
+ ```
19
+
20
+ ## 🚀 Quick Start
21
+
22
+ ```javascript
23
+ const { expect } = require('chai');
24
+ const mocky = require('lil-mocky');
25
+
26
+ describe('User Service', () => {
27
+ it('calls the callback with user data', () => {
28
+ // Create a mock function
29
+ const callback = mocky.function().args('user').build();
30
+
31
+ // Call it in your code
32
+ callback({ name: 'Alice', age: 30 });
33
+
34
+ // Verify it was called correctly
35
+ expect(callback.calls(0)).to.deep.equal({
36
+ user: { name: 'Alice', age: 30 }
37
+ });
38
+ expect(callback.calls().length).to.equal(1);
39
+ });
40
+ });
41
+ ```
42
+
43
+ ---
44
+
45
+ ### ðŸ”Ļ Function Mocks
46
+
47
+ Mock functions for testing callbacks and handlers:
48
+
49
+ ```javascript
50
+ // Testing code that accepts a callback
51
+ function processUsers(users, onComplete) {
52
+ const processed = users.map(u => ({ ...u, processed: true }));
53
+ onComplete(processed);
54
+ }
55
+
56
+ // Create mock callback with named arguments
57
+ const onComplete = mocky.function().args('result').build();
58
+
59
+ // Run the code under test
60
+ processUsers([{ name: 'Alice' }], onComplete);
61
+
62
+ // Verify the callback was called correctly
63
+ expect(onComplete.calls(0)).to.deep.equal({
64
+ result: [{ name: 'Alice', processed: true }]
65
+ });
66
+ expect(onComplete.calls().length).to.equal(1);
67
+ ```
68
+
69
+ **Common patterns:**
70
+
71
+ ```javascript
72
+ // Mock with return value
73
+ const mock = mocky.function().build();
74
+ mock.ret('success');
75
+ const result = mock();
76
+ expect(result).to.equal('success');
77
+
78
+ // Mock async functions
79
+ const fetchData = mocky.function().args('url').async().build();
80
+ fetchData.ret({ data: [1, 2, 3] });
81
+ const result = await fetchData('/api/data');
82
+ expect(result.data).to.deep.equal([1, 2, 3]);
83
+
84
+ // Arguments with defaults
85
+ const logger = mocky.function().args('message', { level: 'info' }).build();
86
+ logger('Test message');
87
+ expect(logger.calls(0)).to.deep.equal({
88
+ message: 'Test message',
89
+ level: 'info'
90
+ });
91
+ ```
92
+
93
+ #### .ret() - Set Return Values
94
+
95
+ Configure what the mock returns when called:
96
+
97
+ ```javascript
98
+ const mock = mocky.function().build();
99
+
100
+ // Simple return value
101
+ mock.ret('hello');
102
+ mock(); // Returns 'hello'
103
+
104
+ // Any value type
105
+ mock.ret(null);
106
+ mock.ret(42);
107
+ mock.ret([1, 2, 3]);
108
+ mock.ret({ data: 'value' });
109
+
110
+ // Different return per call (call numbers are 1-indexed)
111
+ mock.ret('first', 1); // First call
112
+ mock.ret('second', 2); // Second call
113
+ mock.ret('default'); // All other calls (call 0 is the default)
114
+
115
+ mock(); // 'first'
116
+ mock(); // 'second'
117
+ mock(); // 'default'
118
+ mock(); // 'default'
119
+ ```
120
+
121
+ **Important:** `.ret()` stores the value - it doesn't call functions:
122
+
123
+ ```javascript
124
+ // ❌ WRONG - returns the function itself
125
+ mock.ret(() => compute());
126
+
127
+ // ✅ RIGHT - use custom implementation for dynamic behavior
128
+ const mock = mocky.function((context) => {
129
+ return compute();
130
+ }).build();
131
+ ```
132
+
133
+ **Throwing errors:**
134
+
135
+ ```javascript
136
+ mock.ret(new Error('Something went wrong'));
137
+ mock(); // Throws 'Something went wrong'
138
+
139
+ // Different errors per call
140
+ mock.ret(new Error('First error'), 1);
141
+ mock.ret(new Error('Second error'), 2);
142
+ ```
143
+
144
+ #### .calls() - Verify Arguments
145
+
146
+ Check what arguments were passed to the mock:
147
+
148
+ ```javascript
149
+ const mock = mocky.function().args('name', 'age').build();
150
+
151
+ mock('Alice', 30);
152
+ mock('Bob', 25);
153
+
154
+ // Get specific call
155
+ mock.calls(0); // { name: 'Alice', age: 30 }
156
+ mock.calls(1); // { name: 'Bob', age: 25 }
157
+
158
+ // Get all calls
159
+ mock.calls(); // [{ name: 'Alice', age: 30 }, { name: 'Bob', age: 25 }]
160
+ mock.calls().length; // 2
161
+
162
+ // Without .args() config, returns raw arguments array
163
+ const rawMock = mocky.function().build();
164
+ rawMock('a', 'b', 'c');
165
+ rawMock.calls(0); // ['a', 'b', 'c']
166
+ ```
167
+
168
+ #### .reset() - Clear State
169
+
170
+ Reset the mock back to its initial state:
171
+
172
+ ```javascript
173
+ const mock = mocky.function().build();
174
+ mock.ret('value');
175
+ mock('test');
176
+
177
+ mock.calls().length; // 1
178
+
179
+ mock.reset();
180
+
181
+ // Everything cleared
182
+ mock.calls().length; // 0
183
+ mock(); // Returns undefined (ret cleared)
184
+ ```
185
+
186
+ #### Using context for Custom Implementations
187
+
188
+ For dynamic behavior, pass a function that receives a `context` object:
189
+
190
+ ```javascript
191
+ const mock = mocky.function((context) => {
192
+ // Your custom logic here
193
+ return context.args.x * 2;
194
+ }).args('x').build();
195
+
196
+ mock(5); // Returns 10
197
+ ```
198
+
199
+ **Context properties:**
200
+
201
+ ```javascript
202
+ const mock = mocky.function((context) => {
203
+ // context.args - Named arguments (from .args() config)
204
+ // context.ret - Value set via .ret()
205
+ // context.call - Call number (1-indexed)
206
+ // context.self - The 'this' context
207
+ // context.state - Internal state object (for storing custom data)
208
+
209
+ return someValue;
210
+ }).args('param1', 'param2').build();
211
+ ```
212
+
213
+ **Override pattern** - Allow `.ret()` to override default behavior:
214
+
215
+ ```javascript
216
+ const compute = mocky.function((context) => {
217
+ // Check if .ret() was called
218
+ if (context.ret !== undefined)
219
+ return context.ret;
220
+
221
+ // Default logic
222
+ return context.args.x * context.args.y;
223
+ }).args('x', 'y').build();
224
+
225
+ compute(5, 3); // Returns 15 (default logic)
226
+
227
+ compute.ret(999); // Override
228
+ compute(5, 3); // Returns 999
229
+ ```
230
+
231
+ **Conditional behavior** - Different logic based on arguments:
232
+
233
+ ```javascript
234
+ const validator = mocky.function((context) => {
235
+ if (context.args.value === null)
236
+ throw new Error('Value cannot be null');
237
+
238
+ if (context.args.value.length < 3)
239
+ return { valid: false, error: 'Too short' };
240
+
241
+ return { valid: true };
242
+ }).args('value').build();
243
+
244
+ validator('ab'); // { valid: false, error: 'Too short' }
245
+ validator('abc'); // { valid: true }
246
+ ```
247
+
248
+ **Stateful mocks** - Track state across calls:
249
+
250
+ ```javascript
251
+ const counter = mocky.function((context) => {
252
+ // Use context.state to store data between calls
253
+ if (!context.state.count)
254
+ context.state.count = 0;
255
+
256
+ context.state.count++;
257
+ return context.state.count;
258
+ }).build();
259
+
260
+ counter(); // 1
261
+ counter(); // 2
262
+ counter(); // 3
263
+
264
+ counter.reset(); // Clears context.state
265
+ counter(); // 1
266
+ ```
267
+
268
+ **Call-specific behavior** - Different logic per call:
269
+
270
+ ```javascript
271
+ const fetcher = mocky.function((context) => {
272
+ if (context.call === 1)
273
+ return { status: 'loading' };
274
+
275
+ if (context.call === 2)
276
+ return { status: 'success', data: [1, 2, 3] };
277
+
278
+ return { status: 'cached' };
279
+ }).build();
280
+
281
+ fetcher(); // { status: 'loading' }
282
+ fetcher(); // { status: 'success', ... }
283
+ fetcher(); // { status: 'cached' }
284
+ ```
285
+
286
+ ---
287
+
288
+ ### ðŸ“Ķ Object Mocks
289
+
290
+ Mock objects for testing APIs, databases, and services:
291
+
292
+ ```javascript
293
+ // Mock an API client
294
+ const api = mocky.object({
295
+ get: mocky.function().args('url'),
296
+ post: mocky.function().args('url', 'data'),
297
+ baseURL: 'https://api.example.com',
298
+ timeout: 5000
299
+ }).build();
300
+
301
+ // Configure responses
302
+ api.get.ret({ status: 200, data: { users: [] } });
303
+ api.post.ret({ status: 201, data: { id: 123 } });
304
+
305
+ // Test your code that uses the API
306
+ async function createUser(apiClient, userData) {
307
+ const response = await apiClient.post('/users', userData);
308
+ return response.data;
309
+ }
310
+
311
+ const result = await createUser(api, { name: 'Alice' });
312
+
313
+ // Verify the API was called correctly
314
+ expect(api.post.calls(0)).to.deep.equal({
315
+ url: '/users',
316
+ data: { name: 'Alice' }
317
+ });
318
+ expect(result).to.deep.equal({ id: 123 });
319
+ ```
320
+
321
+ **Immutability after .build():**
322
+
323
+ Once you call `.build()`, the mock object's structure is immutable. You cannot add or reassign properties:
324
+
325
+ ```javascript
326
+ // ❌ WRONG - can't modify after .build()
327
+ const mock = mocky.object({
328
+ method: mocky.function()
329
+ }).build();
330
+
331
+ mock.method = newImplementation; // TypeError: Cannot assign to read only property
332
+ mock.newMethod = mocky.function().build(); // TypeError: Cannot add property
333
+
334
+ // ✅ RIGHT - define everything when building
335
+ const mock = mocky.object({
336
+ method: mocky.function((context) => {
337
+ // Your custom implementation here
338
+ return 'result';
339
+ })
340
+ }).build();
341
+ ```
342
+
343
+ This immutability prevents bugs and ensures `.reset()` works correctly. To change behavior during tests, use `.ret()` or `.reset()` instead of reassigning properties.
344
+
345
+ **Nested mocks for complex structures:**
346
+
347
+ ```javascript
348
+ const db = mocky.object({
349
+ users: mocky.object({
350
+ findById: mocky.function().args('id'),
351
+ create: mocky.function().args('userData')
352
+ }),
353
+ connected: true
354
+ }).build();
355
+
356
+ db.users.findById.ret({ id: 1, name: 'Alice' });
357
+ const user = await db.users.findById(1);
358
+ ```
359
+
360
+ **Symbol properties (advanced):**
361
+
362
+ ```javascript
363
+ const iterable = mocky.object({
364
+ [Symbol.iterator]: mocky.function()
365
+ }).build();
366
+
367
+ iterable[Symbol.iterator].ret('iterator');
368
+ ```
369
+
370
+ #### Object .reset() Behavior
371
+
372
+ Calling `.reset()` on an object mock:
373
+ - Calls `.reset()` on all nested mocks (clears calls and return values)
374
+ - Restores plain properties to their initial values
375
+ - Deletes any properties added after creation
376
+
377
+ ```javascript
378
+ const api = mocky.object({
379
+ get: mocky.function(),
380
+ baseURL: 'https://api.example.com',
381
+ timeout: 5000
382
+ }).build();
383
+
384
+ api.get.ret('response');
385
+ api.get('test');
386
+ api.baseURL = 'https://other.com';
387
+ api.timeout = 10000;
388
+ api.newProp = 'added';
389
+
390
+ api.reset();
391
+
392
+ // After reset:
393
+ // - api.get.calls() is []
394
+ // - api.get return values cleared
395
+ // - api.baseURL is 'https://api.example.com' (restored)
396
+ // - api.timeout is 5000 (restored)
397
+ // - api.newProp is deleted
398
+ ```
399
+
400
+ ---
401
+
402
+ ### 🏛ïļ Class Mocks
403
+
404
+ Mock classes with per-instance behavior - perfect for services that get instantiated:
405
+
406
+ ```javascript
407
+ // Mock a Logger class that gets instantiated per module
408
+ const Logger = mocky.class({
409
+ constructor: mocky.function().args('moduleName'),
410
+ info: mocky.function().args('message'),
411
+ error: mocky.function().args('message'),
412
+ level: 'info'
413
+ }).build();
414
+
415
+ // Test code that creates multiple logger instances
416
+ class UserService {
417
+ constructor() {
418
+ this.logger = new Logger('UserService');
419
+ }
420
+
421
+ async createUser(data) {
422
+ this.logger.info('Creating user');
423
+ // ... create user logic
424
+ return { id: 1, ...data };
425
+ }
426
+ }
427
+
428
+ class AuthService {
429
+ constructor() {
430
+ this.logger = new Logger('AuthService');
431
+ }
432
+
433
+ login(username) {
434
+ this.logger.info('User logging in');
435
+ // ... auth logic
436
+ }
437
+ }
438
+
439
+ // Create the services (each creates its own Logger instance)
440
+ const userService = new UserService();
441
+ const authService = new AuthService();
442
+
443
+ await userService.createUser({ name: 'Alice' });
444
+ authService.login('alice');
445
+
446
+ // Verify each instance was used correctly
447
+ expect(Logger.inst(0).constructor.calls(0)).to.deep.equal({
448
+ moduleName: 'UserService'
449
+ });
450
+ expect(Logger.inst(0).info.calls(0)).to.deep.equal({
451
+ message: 'Creating user'
452
+ });
453
+
454
+ expect(Logger.inst(1).constructor.calls(0)).to.deep.equal({
455
+ moduleName: 'AuthService'
456
+ });
457
+ expect(Logger.inst(1).info.calls(0)).to.deep.equal({
458
+ message: 'User logging in'
459
+ });
460
+
461
+ expect(Logger.numInsts()).to.equal(2);
462
+ ```
463
+
464
+ **Accessing mock helpers on instances:**
465
+
466
+ You can access `.calls()`, `.ret()`, and `.reset()` directly on instance methods:
467
+
468
+ ```javascript
469
+ const Logger = mocky.class({
470
+ info: mocky.function().args('message')
471
+ }).build();
472
+
473
+ const logger = new Logger();
474
+ logger.info('test message');
475
+
476
+ // Access calls directly on the instance
477
+ expect(logger.info.calls(0)).to.deep.equal({ message: 'test message' });
478
+
479
+ // Configure returns on the instance
480
+ logger.info.ret('logged');
481
+ expect(logger.info('another')).to.equal('logged');
482
+
483
+ // Reset via instance
484
+ logger.info.reset();
485
+ expect(logger.info.calls().length).to.equal(0);
486
+ ```
487
+
488
+ **Pre-configuring instances:**
489
+
490
+ ```javascript
491
+ // Configure behavior BEFORE instances are created
492
+ const Database = mocky.class({
493
+ constructor: mocky.function().args('connectionString'),
494
+ query: mocky.function().args('sql'),
495
+ pool: mocky.object({
496
+ acquire: mocky.function(),
497
+ release: mocky.function()
498
+ })
499
+ }).build();
500
+
501
+ // First instance returns user data
502
+ Database.inst(0).query.ret([{ id: 1, name: 'Alice' }]);
503
+ // Second instance returns empty results
504
+ Database.inst(1).query.ret([]);
505
+
506
+ const db1 = new Database('postgres://primary');
507
+ const db2 = new Database('postgres://replica');
508
+
509
+ const users = await db1.query('SELECT * FROM users'); // [{ id: 1, ... }]
510
+ const empty = await db2.query('SELECT * FROM users'); // []
511
+ ```
512
+
513
+ #### Using context.self for Instance State
514
+
515
+ Use `context.self` to access and modify instance properties:
516
+
517
+ ```javascript
518
+ // Mock a class where methods need to access instance properties
519
+ const Counter = mocky.class({
520
+ constructor: mocky.function((context) => {
521
+ // Store initial value on the instance
522
+ context.self._count = context.args.initial || 0;
523
+ }).args('initial'),
524
+
525
+ increment: mocky.function((context) => {
526
+ // Access instance state via context.self
527
+ context.self._count++;
528
+ return context.self._count;
529
+ }),
530
+
531
+ getCount: mocky.function((context) => {
532
+ return context.self._count;
533
+ })
534
+ }).build();
535
+
536
+ const counter = new Counter(10);
537
+ counter.increment(); // 11
538
+ counter.increment(); // 12
539
+ counter.getCount(); // 12
540
+
541
+ // Each instance has its own state
542
+ const counter2 = new Counter(100);
543
+ counter2.increment(); // 101
544
+ counter.getCount(); // Still 12
545
+ ```
546
+
547
+ #### Class .reset() Behavior
548
+
549
+ Calling `.reset()` on a class mock:
550
+ - Clears all instance configurations
551
+ - Resets instance counter to 0
552
+ - Next instantiation starts fresh at instance 0
553
+
554
+ ```javascript
555
+ const Mock = mocky.class({
556
+ method: mocky.function()
557
+ }).build();
558
+
559
+ Mock.inst(0).method.ret('value');
560
+ const instance1 = new Mock();
561
+ const instance2 = new Mock();
562
+
563
+ Mock.numInsts(); // 2
564
+
565
+ Mock.reset();
566
+
567
+ // After reset:
568
+ // - All instance configurations cleared
569
+ // - Mock.numInsts() is 0
570
+ // - Next instantiation starts fresh at instance 0
571
+ ```
572
+
573
+ ---
574
+
575
+ ### 🔍 Spy Function
576
+
577
+ Track calls to existing methods without breaking their behavior:
578
+
579
+ ```javascript
580
+ // Spy on an existing object's method
581
+ const emailService = {
582
+ send: (to, subject, body) => {
583
+ // Real implementation that sends email
584
+ console.log(`Sending email to ${to}`);
585
+ return { sent: true, id: '12345' };
586
+ }
587
+ };
588
+
589
+ // Create a spy - calls through to original by default
590
+ const spy = mocky.spy(emailService, 'send');
591
+
592
+ // Test code that uses the email service
593
+ function notifyUser(user, message) {
594
+ return emailService.send(user.email, 'Notification', message);
595
+ }
596
+
597
+ const result = notifyUser({ email: 'alice@example.com' }, 'Hello!');
598
+
599
+ // Verify the method was called correctly
600
+ expect(spy.calls(0)).to.deep.equal([
601
+ 'alice@example.com',
602
+ 'Notification',
603
+ 'Hello!'
604
+ ]);
605
+ expect(result).to.deep.equal({ sent: true, id: '12345' });
606
+
607
+ // Clean up
608
+ spy.restore();
609
+ ```
610
+
611
+ **Override return value:**
612
+
613
+ ```javascript
614
+ const cache = {
615
+ get: (key) => localStorage.getItem(key)
616
+ };
617
+
618
+ const spy = mocky.spy(cache, 'get');
619
+ spy.ret('mocked-value'); // Override return value
620
+
621
+ const value = cache.get('user-data'); // Returns 'mocked-value'
622
+ spy.restore();
623
+ ```
624
+
625
+ **Spy with custom replacement:**
626
+
627
+ When using a custom replacement, context includes additional properties for working with the original function:
628
+
629
+ ```javascript
630
+ const analytics = {
631
+ track: (event, data) => {
632
+ // Real implementation posts to analytics server
633
+ fetch('/analytics', { method: 'POST', body: JSON.stringify({ event, data }) });
634
+ }
635
+ };
636
+
637
+ const spy = mocky.spy(analytics, 'track', mocky.function((context) => {
638
+ // context.original - The original function
639
+ // context.rawArgs - Unprocessed arguments array
640
+
641
+ // Call original but also add test logging
642
+ console.log('Analytics called:', context.rawArgs);
643
+ context.original.apply(context.self, context.rawArgs);
644
+ }).args('event', 'data'));
645
+
646
+ analytics.track('page_view', { page: '/home' });
647
+ expect(spy.calls(0)).to.deep.equal({
648
+ event: 'page_view',
649
+ data: { page: '/home' }
650
+ });
651
+
652
+ spy.restore();
653
+ ```
654
+
655
+ **Spy on class prototypes (affects all instances):**
656
+
657
+ ```javascript
658
+ class HttpClient {
659
+ request(url, options) {
660
+ return fetch(url, options);
661
+ }
662
+ }
663
+
664
+ const spy = mocky.spy(HttpClient.prototype, 'request');
665
+
666
+ const client1 = new HttpClient();
667
+ const client2 = new HttpClient();
668
+
669
+ client1.request('/api/users', { method: 'GET' });
670
+ client2.request('/api/posts', { method: 'GET' });
671
+
672
+ // Both instances' calls are tracked
673
+ expect(spy.calls().length).to.equal(2);
674
+ spy.restore();
675
+ ```
676
+
677
+ ---
678
+
679
+ ## 🔄 Coming from Jest?
680
+
681
+ Migration guide for Jest users:
682
+
683
+ | Jest | lil-mocky |
684
+ |------|-----------|
685
+ | `jest.fn()` | `mocky.function().build()` |
686
+ | `mock.mockReturnValue(val)` | `mock.ret(val)` |
687
+ | `mock.mock.calls[0][0]` | `mock.calls(0)` |
688
+ | `jest.spyOn(obj, 'method')` | `mocky.spy(obj, 'method')` |
689
+ | `spy.mockRestore()` | `spy.restore()` |
690
+
691
+ **Key differences:**
692
+ - **Builder pattern**: Explicit configuration via chainable builders before `.build()`
693
+ - **Named arguments**: Built-in support for named argument tracking with `.args()`
694
+ - **Per-instance class mocks**: Configure different behavior for each class instance
695
+ - **Simpler API**: Fewer concepts, more predictable behavior
696
+
697
+ ---
698
+
699
+ ## 📄 License
700
+
701
+ MIT
702
+
703
+ ## ðŸĪ Contributing
704
+
705
+ Contributions welcome! Please open an issue or PR on GitHub.