Skip to content

Commit

Permalink
assert: add new callTracker.callsWith function
Browse files Browse the repository at this point in the history
  • Loading branch information
ErickWendel committed May 17, 2022
1 parent 895cc57 commit 8290594
Show file tree
Hide file tree
Showing 3 changed files with 267 additions and 8 deletions.
67 changes: 67 additions & 0 deletions doc/api/assert.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,73 @@ function func() {}
const callsfunc = tracker.calls(func);
```

### `tracker.callsWith([fn][withArgs][, exact])`

<!-- YAML
added:
- v19.0.0
-->

* `fn` {Function} **Default:** A no-op function.
* `withArgs` {Array} **Default:** An array with the arguments list.
* `exact` {number} **Default:** `1`.
* Returns: {Function} that wraps `fn`.

The wrapper function is expected to be called exactly `exact` times. If the
function has not been called exactly `exact` times when
[`tracker.verify()`][] is called, then [`tracker.verify()`][] will throw an
error.

```mjs
import assert from 'node:assert';

// Creates call tracker.
const tracker = new assert.CallTracker();

function func() {}

// Returns a function that wraps func() that must be called exact times
// Before tracker.verify().
const callsfunc = tracker.callsWith(func, ['test', 1, 2]);
callsfunc('test', 1, 2);

// Or without any no-op function and calling twice
const myfn = tracker.callsWith(['test', 1, 2], 2);
myfn('test', 1, 2);
myfn('test', 1, 2);
tracker.verify();

// Or
const fn = tracker.callsWith(['test', 1, 2]);
fn('test', 1, 2);
tracker.verify();
```

```cjs
const assert = require('node:assert');

// Creates call tracker.
const tracker = new assert.CallTracker();

function func() {}

// Returns a function that wraps func() that must be called exact times
// Before tracker.verify().
const callsfunc = tracker.callsWith(func, ['test', 1, 2]);
callsfunc('test', 1, 2);

// Or without any no-op function and calling twice
const myfn = tracker.callsWith(['test', 1, 2], 2);
myfn('test', 1, 2);
myfn('test', 1, 2);
tracker.verify();

// Or
const fn = tracker.callsWith(['test', 1, 2]);
fn('test', 1, 2);
tracker.verify();
```

### `tracker.report()`

<!-- YAML
Expand Down
82 changes: 74 additions & 8 deletions lib/internal/assert/calltracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const {
ArrayPrototypePush,
ArrayIsArray,
Error,
FunctionPrototype,
Proxy,
Expand All @@ -13,25 +14,42 @@ const {
codes: {
ERR_UNAVAILABLE_DURING_EXIT,
},
genericNodeError,
} = require('internal/errors');

const AssertionError = require('internal/assert/assertion_error');
const {
validateUint32,
} = require('internal/validators');
const {
isDeepStrictEqual,
} = require('internal/util/comparisons');

const noop = FunctionPrototype;

class CallTracker {

#callChecks = new SafeSet();

calls(fn, exact = 1) {
#calls(fn, exact, withArgs = []) {
if (process._exiting)
throw new ERR_UNAVAILABLE_DURING_EXIT();

// When calls([arg1, arg2], ?1)
if (ArrayIsArray(fn)) {
exact = typeof withArgs === 'number' ? withArgs : exact;
withArgs = fn;
fn = noop;
}

// When calls(1)
if (typeof fn === 'number') {
exact = fn;
fn = noop;
} else if (fn === undefined) {
}

// When calls()
if (fn === undefined) {
fn = noop;
}

Expand All @@ -40,40 +58,88 @@ class CallTracker {
const context = {
exact,
actual: 0,
expectedArgs: withArgs,
currentFnArgs: [],
// eslint-disable-next-line no-restricted-syntax
stackTrace: new Error(),
name: fn.name || 'calls'
};

const callChecks = this.#callChecks;
callChecks.add(context);

return new Proxy(fn, {
__proto__: null,
apply(fn, thisArg, argList) {
context.actual++;
if (context.actual === context.exact) {
context.currentFnArgs = argList;

// TODO:(erick): not working for functions with different instances
const containsExpectArgs = isDeepStrictEqual(context.currentFnArgs, context.expectedArgs);

if (context.actual === context.exact && containsExpectArgs) {
// Once function has reached its call count remove it from
// callChecks set to prevent memory leaks.
callChecks.delete(context);
}
// If function has been called more than expected times, add back into
// callchecks.
if (context.actual === context.exact + 1) {
if (context.actual === context.exact + 1 && containsExpectArgs) {
callChecks.add(context);
}
return ReflectApply(fn, thisArg, argList);
},
});
}

callsWith(fn, withArgs, exact = 1) {
const expectedArgsWerePassed = ArrayIsArray(fn) ||
ArrayIsArray(exact) ||
ArrayIsArray(withArgs);

if (!expectedArgsWerePassed) {
const message = 'the [ withArgs ] param is required';

throw new AssertionError({
message,
details: ArrayPrototypePush([], {
message,
operator: 'callsWith',
stack: genericNodeError()
})
});
}

return this.#calls(fn, exact, withArgs);
}

calls(fn, exact = 1) {
return this.#calls(fn, exact);
}

report() {
const errors = [];
for (const context of this.#callChecks) {
// If functions have not been called exact times
if (context.actual !== context.exact) {

const needsToCheckArgs = !!context.expectedArgs;

const invalidArgs = needsToCheckArgs ?
(context.currentFnArgs !== context.expectedArgs) :
false;

const msg = needsToCheckArgs ? [
`with args (${context.expectedArgs}) `,
` with args (${context.currentFnArgs})`,
] : ['', ''];

// If functions have not been called exact times and with correct arguments
if (
context.actual !== context.exact ||
invalidArgs
) {
const message = `Expected the ${context.name} function to be ` +
`executed ${context.exact} time(s) but was ` +
`executed ${context.actual} time(s).`;
`executed ${context.exact} time(s) ${msg[0]}but was ` +
`executed ${context.actual} time(s)${msg[1]}.`;
ArrayPrototypePush(errors, {
message,
actual: context.actual,
Expand Down
126 changes: 126 additions & 0 deletions test/parallel/test-assert-calltracker-callsWith.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
'use strict';
require('../common');
const assert = require('assert');

function bar() {}

// It should call once the bar function with 1 as first argument
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith(bar, [1], 1);
callsNoop(1);
tracker.verify();
}

// It should call once the bar function with 'a' and 'b' as arguments
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith(bar, ['a', 'b'], 1);
callsNoop('a', 'b');
tracker.verify();
}

// It should call twice the bar function with 'a' and 'b' as arguments
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith(bar, ['a', 'b'], 2);
callsNoop('a', 'b');
callsNoop('a', 'b');
tracker.verify();
}

// When calling the watching function with incorrect params it should show an error message
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith(['a', 'b'], 1);
callsNoop('c', 'd');
const expectedMessage = 'Expected the calls function to be executed 1 time(s)' +
' with args (a,b) but was executed 1 time(s) with args (c,d).';
const [{ message }] = tracker.report();
assert.deepStrictEqual(message, expectedMessage);
}

// It should show an error with calling the function wrongly once and rightly once
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith(['a', 'b'], 1);
callsNoop('c', 'd');
callsNoop('a', 'b');
const expectedMessage = 'Expected the calls function to be executed 1 time(s)' +
' with args (a,b) but was executed 2 time(s) with args (a,b).';
const [{ message }] = tracker.report();
assert.deepStrictEqual(message, expectedMessage);
}

// It should verify once the given args
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith([bar]);
callsNoop(bar);
tracker.verify();
}

// It should call 100 times with the same args
{
const tracker = new assert.CallTracker();
const callCount = 100;
const callsNoop = tracker.callsWith([ { abc: 1 }, [1], bar], callCount);
for (let i = 0; i < callCount; i++)
callsNoop({ abc: 1 }, [1], bar);

tracker.verify();
}

// Known bug: when sending a nearly created function its different (?)
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith([function() {}]);
callsNoop(function() {});

const [{ message }] = tracker.report();
const expectedMsg = 'Expected the calls function to be executed 1 time(s)' +
' with args (function() {}) but was executed 1 time(s) with args (function() {}).';
assert.deepStrictEqual(message, expectedMsg);
}

// It should not use the same signature of tracker.call
{
const tracker = new assert.CallTracker();
assert.throws(
() => tracker.callsWith(bar),
{
code: 'ERR_ASSERTION',
}
);
}

// It should not use the same signature of tracker.call
{
const tracker = new assert.CallTracker();
assert.throws(
() => tracker.callsWith(1),
{
code: 'ERR_ASSERTION',
}
);
}

// It should not validate two Map instances with different values as the same
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith([ new Map([[ 'a', '1' ]]) ]);
callsNoop(new Map([[ 'a', '2']]));

const [{ message }] = tracker.report();
const expectedMsg = 'Expected the calls function to be executed 1 time(s)' +
' with args ([object Map]) but was executed 1 time(s) with args ([object Map]).';
assert.deepStrictEqual(message, expectedMsg);
}

// It should validate two complex objects
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith([ new Map([[ 'a', '1' ]]) ]);
callsNoop(new Map([[ 'a', '1']]));
tracker.verify();
}

0 comments on commit 8290594

Please sign in to comment.