Browse Source

chore: adds spies group

e22m4u 6 months ago
parent
commit
38278639ef
10 changed files with 582 additions and 84 deletions
  1. 193 24
      README.md
  2. 29 1
      dist/cjs/index.cjs
  3. 68 0
      src/create-spies-group.d.ts
  4. 35 0
      src/create-spies-group.js
  5. 160 0
      src/create-spies-group.spec.js
  6. 40 38
      src/create-spy.d.ts
  7. 11 4
      src/create-spy.js
  8. 44 17
      src/create-spy.spec.js
  9. 1 0
      src/index.d.ts
  10. 1 0
      src/index.js

+ 193 - 24
README.md

@@ -1,6 +1,6 @@
 # @e22m4u/js-spy
 
-Утилита слежения за вызовом функций и методов для JavaScript.
+Утилита слежения за вызовом функций и методов для JavaScript. Позволяет создавать "шпионов" для функций или методов объектов, отслеживать их вызовы, аргументы, возвращаемые значения, выброшенные ошибки, а также управлять группой шпионов.
 
 ## Содержание
 
@@ -8,6 +8,7 @@
 - [Использование](#использование)
   - [Отслеживание вызова функции](#отслеживание-вызова-функции)
   - [Отслеживание вызова метода](#отслеживание-вызова-метода)
+  - [Управление группой шпионов (SpiesGroup)](#управление-группой-шпионов-spiesgroup)
 - [API](#api)
   - [createSpy(target, [methodNameOrImpl], [customImplForMethod])](#createspytarget-methodnameorimpl-customimplformethod)
   - [Свойства и методы шпиона](#свойства-и-методы-шпиона)
@@ -20,6 +21,10 @@
     - [spy.nthCallReturned(n, expectedReturnValue)](#spynthcallreturnedn-expectedreturnvalue)
     - [spy.nthCallThrew(n, [expectedError])](#spynthcallthrewn-expectederror)
     - [spy.restore()](#spyrestore)
+  - [createSpiesGroup()](#createspiesgroup)
+  - [Методы SpiesGroup](#методы-spiesgroup)
+    - [group.on(target, [methodNameOrImpl], [customImplForMethod])](#groupon)
+    - [group.restore() (для группы)](#grouprestore-для-группы)
 - [Тесты](#тесты)
 - [Лицензия](#лицензия)
 
@@ -33,17 +38,17 @@ npm install @e22m4u/js-spy
 
 *ESM*
 ```js
-import {createSpy} from '@e22m4u/js-spy';
+import {createSpy, createSpiesGroup} from '@e22m4u/js-spy';
 ```
 
 *CommonJS*
 ```js
-const {createSpy} = require('@e22m4u/js-spy');
+const {createSpy, createSpiesGroup} = require('@e22m4u/js-spy');
 ```
 
 ## Использование
 
-Отслеживание вызова функции.
+### Отслеживание вызова функции:
 
 ```js
 import {createSpy} from '@e22m4u/js-spy';
@@ -91,7 +96,7 @@ try {
 }
 ```
 
-Отслеживание вызова метода.
+### Отслеживание вызова метода:
 
 ```js
 import {createSpy} from '@e22m4u/js-spy';
@@ -146,6 +151,66 @@ addSpy.restore();
 // calculator.add теперь снова оригинальный метод
 ```
 
+### Управление группой шпионов (SpiesGroup)
+
+Иногда бывает удобно управлять несколькими шпионами одновременно,
+например, восстановить их все разом. Для этого используется `SpiesGroup`.
+
+```js
+import {createSpiesGroup} from '@e22m4u/js-spy';
+
+const service = {
+  fetchData(id) {
+    console.log(`Fetching data for ${id}...`);
+    return {id, data: `Data for ${id}`};
+  },
+  processItem(item) {
+    console.log(`Processing ${item.data}...`);
+    return `Processed: ${item.data}`;
+  }
+};
+
+function standaloneLogger(message) {
+  console.log(`LOG: ${message}`);
+}
+
+// создаем группу
+const group = createSpiesGroup();
+
+// добавление шпионов в группу:
+//   метод group.on() работает аналогично createSpy(),
+//   но добавляет шпиона в группу и возвращает созданного
+//   шпиона
+const fetchDataSpy = group.on(service, 'fetchData');
+const processItemSpy = group.on(service, 'processItem');
+const loggerSpy = group.on(standaloneLogger);
+
+// вызов отслеживаемых функций/методов
+const data = service.fetchData(1);
+service.processItem(data);
+standaloneLogger('All done!');
+
+console.log(fetchDataSpy.callCount);   // 1
+console.log(processItemSpy.callCount); // 1
+console.log(loggerSpy.callCount);      // 1
+
+// восстановление всех шпионов в группе:
+//   - оригинальные методы service.fetchData
+//     и service.processItem будут восстановлены
+//   - история вызовов (callCount, called, getCall и т.д.)
+//     для fetchDataSpy, processItemSpy и loggerSpy
+//     будет сброшена
+//   - внутренний список шпионов в группе будет очищен
+group.restore();
+
+console.log(service.fetchData === fetchDataSpy);
+// false (оригинальный метод восстановлен)
+console.log(fetchDataSpy.callCount);
+// 0 (история сброшена)
+console.log(loggerSpy.called);
+// false (история сброшена)
+```
+
 ## API
 
 ### createSpy(target, [methodNameOrImpl], [customImplForMethod])
@@ -177,8 +242,8 @@ addSpy.restore();
 
 ### Свойства и методы шпиона
 
-Каждая функция-шпион, возвращаемая `createSpy`, обладает следующими
-свойствами и методами:
+Каждая функция-шпион, возвращаемая `createSpy` (или `group.on`), обладает
+следующими свойствами и методами:
 
 #### spy(...args)
 
@@ -187,7 +252,7 @@ addSpy.restore();
 записывает информацию о вызове и возвращает результат (или пробрасывает
 ошибку).
 
-```javascript
+```js
 const fn = (x) => x * 2;
 const spy = createSpy(fn);
 
@@ -200,7 +265,7 @@ console.log(spy.callCount); // 1
 - **Тип:** `boolean` (только для чтения)
 - **Описание:** Указывает, был ли шпион вызван хотя бы один раз.
 
-```javascript
+```js
 const spy = createSpy(() => {});
 console.log(spy.called); // false
 spy();
@@ -212,7 +277,7 @@ console.log(spy.called); // true
 - **Тип:** `number` (только для чтения)
 - **Описание:** Количество раз, которое шпион был вызван.
 
-```javascript
+```js
 const spy = createSpy(() => {});
 console.log(spy.callCount); // 0
 spy();
@@ -240,7 +305,7 @@ console.log(spy.callCount); // 2
 
 Пример:
 
-```javascript
+```js
 const spy = createSpy((a, b) => a + b);
 spy.call({ id: 1 }, 10, 20); // 0-й вызов
 
@@ -270,7 +335,7 @@ try {
 
 Пример:
 
-```javascript
+```js
 const spy = createSpy(() => {});
 spy(1, 'a', true);
 spy(2, 'b');
@@ -301,7 +366,7 @@ console.log(spy.calledWith(2, 'c'));       // false
 
 Пример:
 
-```javascript
+```js
 const spy = createSpy(() => {});
 spy('first call');
 spy('second call', 123);
@@ -339,7 +404,7 @@ try {
 
 Пример:
 
-```javascript
+```js
 const spy = createSpy(val => {
   if (val === 0) throw new Error('zero');
   return val * 10;
@@ -393,7 +458,7 @@ try {
 
 Пример:
 
-```javascript
+```js
 const mightThrow = (val) => {
   if (val === 0) throw new TypeError('Zero is not allowed');
   if (val < 0) throw new Error('Negative value');
@@ -422,23 +487,127 @@ try {
 
 Описание:
 
-- Если шпион был создан для метода объекта, этот метод
-  восстанавливает оригинальный метод на объекте.
-- Если шпион был создан для отдельной функции, вызов этого метода
-  ничего не делает.
+- Восстанавливает оригинальный метод, если шпион был создан
+  для метода объекта.
+- Сбрасывает историю вызовов шпиона (`callCount` становится 0,
+  `called` становится `false`, и все записи о вызовах очищаются).
+- Если шпион был создан для отдельной функции (а не для метода объекта),
+  восстановление метода не происходит (так как нечего восстанавливать),
+  но история вызовов все равно сбрасывается.
 
-```javascript
+```js
+// для метода объекта
 const myObject = {
   doSomething() {
     return 'original';
   }
 };
 
-const spy = createSpy(myObject, 'doSomething');
-// myObject.doSomething(); // может быть вызван шпион
+const methodSpy = createSpy(myObject, 'doSomething');
+// вызов шпиона
+myObject.doSomething();
+console.log(methodSpy.callCount); // 1
+
+// восстановление метода
+methodSpy.restore();
+console.log(myObject.doSomething()); // 'original' (метод восстановлен)
+console.log(methodSpy.callCount);    // 0 (история сброшена)
+
+// для отдельной функции
+const fn = () => 'result';
+const fnSpy = createSpy(fn);
+fnSpy();
+console.log(fnSpy.callCount); // 1
+
+// сброс истории функции
+fnSpy.restore();
+console.log(fnSpy.callCount); // 0 (история сброшена)
+```
+
+### createSpiesGroup()
+
+Фабричная функция для создания экземпляра `SpiesGroup`.
+
+Возвращает:
+
+- Новый экземпляр `SpiesGroup`.
+
+```js
+import {createSpiesGroup} from '@e22m4u/js-spy';
+const group = createSpiesGroup();
+```
+
+### Методы SpiesGroup
+
+Экземпляр `SpiesGroup` имеет следующие методы:
+
+#### group.on(target, [methodNameOrImpl], [customImplForMethod])
+
+Создает шпиона (используя `createSpy` с теми же аргументами) и добавляет
+его в группу.
+
+Сигнатуры вызова и аргументы идентичны `createSpy`:
+
+1. `group.on(targetFn, [customImplementation])`
+2. `group.on(targetObject, methodName, [customImplementation])`
+
+Возвращает:
+
+- Созданную функцию-шпион (такую же, как вернул бы `createSpy`).
+
+Пример:
+
+```js
+const group = createSpiesGroup();
+const obj = {greet: () => 'Hello'};
+
+const greetSpy = group.on(obj, 'greet');
+// obj.greet теперь шпион, и greetSpy добавлен в группу
+obj.greet();
+console.log(greetSpy.called); // true
+```
+
+#### group.restore()
+
+Вызывает метод `restore()` для каждого шпиона, содержащегося в группе.
+Это означает, что:
+
+- Все оригинальные методы объектов, для которых были созданы шпионы
+  в этой группе, будут восстановлены.
+- История вызовов всех шпионов в группе будет сброшена.
+- Внутренний список шпионов в самой группе будет очищен, делая группу
+  готовой к повторному использованию (если необходимо).
+
+Возвращает:
+
+- `this` (экземпляр `SpiesGroup`) для возможной цепочки вызовов.
+
+Пример:
+
+```js
+const group = createSpiesGroup();
+
+const service = {
+  process() { /* ... */ }
+};
+
+function utilFn() { /* ... */ }
+
+const processSpy = group.on(service, 'process');
+const utilSpy = group.on(utilFn);
+
+service.process();
+utilFn();
+
+console.log(processSpy.callCount); // 1
+console.log(utilSpy.callCount);    // 1
+
+group.restore();
 
-spy.restore();
-console.log(myObject.doSomething()); // 'original'
+// service.process теперь оригинальный метод
+console.log(processSpy.callCount); // 0
+console.log(utilSpy.callCount);    // 0
+console.log(group.spies.length);   // 0
 ```
 
 ## Тесты

+ 29 - 1
dist/cjs/index.cjs

@@ -20,6 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
 // src/index.js
 var index_exports = {};
 __export(index_exports, {
+  SpiesGroup: () => SpiesGroup,
+  createSpiesGroup: () => createSpiesGroup,
   createSpy: () => createSpy
 });
 module.exports = __toCommonJS(index_exports);
@@ -169,8 +171,12 @@ function createSpy(target, methodNameOrImpl, customImplForMethod) {
   };
   spy.restore = () => {
     if (isMethodSpy && objToSpyOn) {
-      objToSpyOn[methodName] = originalFn;
+      if (originalFn !== void 0) {
+        objToSpyOn[methodName] = originalFn;
+      }
     }
+    callLog.count = 0;
+    callLog.calls = [];
   };
   if (isMethodSpy && objToSpyOn) {
     objToSpyOn[methodName] = spy;
@@ -178,7 +184,29 @@ function createSpy(target, methodNameOrImpl, customImplForMethod) {
   return spy;
 }
 __name(createSpy, "createSpy");
+
+// src/create-spies-group.js
+function SpiesGroup() {
+  this.spies = [];
+}
+__name(SpiesGroup, "SpiesGroup");
+SpiesGroup.prototype.on = function(target, methodNameOrImpl, customImplForMethod) {
+  const spy = createSpy(target, methodNameOrImpl, customImplForMethod);
+  this.spies.push(spy);
+  return spy;
+};
+SpiesGroup.prototype.restore = function() {
+  this.spies.forEach((spy) => spy.restore());
+  this.spies = [];
+  return this;
+};
+function createSpiesGroup() {
+  return new SpiesGroup();
+}
+__name(createSpiesGroup, "createSpiesGroup");
 // Annotate the CommonJS export names for ESM import in node:
 0 && (module.exports = {
+  SpiesGroup,
+  createSpiesGroup,
   createSpy
 });

+ 68 - 0
src/create-spies-group.d.ts

@@ -0,0 +1,68 @@
+import {Spy} from './create-spy.js';
+import {MethodKey} from './create-spy.js';
+import {AnyCallable} from './create-spy.js';
+
+/**
+ * Представляет группу шпионов, позволяющую
+ * управлять ими коллективно.
+ */
+export interface SpiesGroup {
+  /**
+   * Внутренний массив, хранящий все шпионы, созданные в этой группе.
+   * Обычно не предназначен для прямого манипулирования.
+   * @readonly
+   */
+  readonly spies: Spy<any>[]; // массив шпионов любого типа
+
+  /**
+   * Создает шпиона для отдельной функции и добавляет его в группу.
+   *
+   * @template TFunc                Тип функции, для которой создается шпион.
+   * @param    targetFn             Функция, для которой создается шпион.
+   * @param    customImplementation Необязательно. Функция для замены поведения
+   *                                оригинальной функции. Должна иметь ту же сигнатуру, что и `targetFn`.
+   * @returns                       Созданная функция-шпион.
+   */
+  on<TFunc extends AnyCallable>(
+    targetFn: TFunc,
+    customImplementation?: TFunc
+  ): Spy<TFunc>;
+
+  /**
+   * Создает шпиона для метода объекта, добавляет его в группу
+   * и заменяет оригинальный метод объекта шпионом.
+   *
+   * @template TObj                 Тип объекта.
+   * @template K                    Ключ метода, для которого создается шпион.
+   * @param    targetObject         Объект, метод которого отслеживается.
+   * @param    methodName           Имя отслеживаемого метода.
+   * @param    customImplementation Необязательно. Функция для замены поведения
+   *                                оригинального метода. Должна иметь ту же сигнатуру, что и оригинальный
+   *                                метод `TObj[K]`.
+   * @returns                       Созданная функция-шпион для метода.
+   */
+  on<
+    TObj extends object,
+    K extends MethodKey<TObj>
+  >(
+    targetObject: TObj,
+    methodName: K,
+    customImplementation?: TObj[K]
+  ): Spy<Extract<TObj[K], AnyCallable>>;
+
+  /**
+   * Восстановление всех оригинальных методов объектов, для которых
+   * были созданы шпионы в этой группе, и сброс истории вызовов
+   * для всех шпионов в группе. Очищает внутренний список шпионов.
+   *
+   * @returns `this` (экземпляр `SpiesGroup`) для возможной цепочки вызовов.
+   */
+  restore(): this;
+}
+
+/**
+ * Фабричная функция для создания нового экземпляра `SpiesGroup`.
+ *
+ * @returns Новый экземпляр `SpiesGroup`.
+ */
+export function createSpiesGroup(): SpiesGroup;

+ 35 - 0
src/create-spies-group.js

@@ -0,0 +1,35 @@
+import {createSpy} from './create-spy.js';
+
+/**
+ * Группа позволяет создавать шпионов и управлять ими как одним.
+ *
+ * @constructor
+ */
+export function SpiesGroup() {
+  this.spies = [];
+}
+
+SpiesGroup.prototype.on = function (
+  target,
+  methodNameOrImpl,
+  customImplForMethod,
+) {
+  const spy = createSpy(target, methodNameOrImpl, customImplForMethod);
+  this.spies.push(spy);
+  return spy;
+};
+
+SpiesGroup.prototype.restore = function () {
+  this.spies.forEach(spy => spy.restore());
+  this.spies = [];
+  return this;
+};
+
+/**
+ * Создание группы шпионов.
+ *
+ * @returns {SpiesGroup}
+ */
+export function createSpiesGroup() {
+  return new SpiesGroup();
+}

+ 160 - 0
src/create-spies-group.spec.js

@@ -0,0 +1,160 @@
+import {expect} from 'chai';
+import {SpiesGroup} from './create-spies-group.js';
+import {createSpiesGroup} from './create-spies-group.js';
+
+describe('SpiesGroup', function () {
+  describe('createSpiesGroup factory', function () {
+    it('should return an instance of SpiesGroup', function () {
+      const group = createSpiesGroup();
+      expect(group).to.be.instanceOf(SpiesGroup);
+    });
+
+    it('should initialize with an empty spies array', function () {
+      const group = createSpiesGroup();
+      expect(group.spies).to.be.an('array').that.is.empty;
+    });
+  });
+
+  describe('SpiesGroup instance', function () {
+    let group;
+
+    beforeEach(function () {
+      group = createSpiesGroup();
+    });
+
+    describe('.on(target, methodNameOrImpl, customImplForMethod)', function () {
+      it('should create a spy using createSpy with the given arguments', function () {
+        const targetFn = () => {};
+        const customImpl = () => {};
+        // шпион для standalone функции
+        const fnSpy = group.on(targetFn);
+        expect(fnSpy).to.be.a('function');
+        // проверка, что это действительно шпион
+        expect(fnSpy.callCount).to.equal(0);
+        // шпион для standalone функции с кастомной реализацией
+        const fnSpyWithImpl = group.on(targetFn, customImpl);
+        // вызов для проверки кастомной реализации
+        fnSpyWithImpl();
+        expect(fnSpyWithImpl.getCall(0).returnValue).to.equal(customImpl());
+        // шпион для метода объекта
+        const obj = {method: () => 'original method'};
+        const methodSpy = group.on(obj, 'method');
+        // проверка замены метода
+        expect(obj.method).to.equal(methodSpy);
+        // шпион для метода объекта с кастомной реализацией
+        const objWithCustom = {method: () => 'original method 2'};
+        const customMethodImpl = () => 'custom method';
+        group.on(objWithCustom, 'method', customMethodImpl);
+        // проверка вызова кастомной реализации
+        expect(objWithCustom.method()).to.equal('custom method');
+      });
+
+      it('should add the created spy to the internal spies array', function () {
+        const targetFn1 = () => {};
+        const targetFn2 = () => {};
+        const spy1 = group.on(targetFn1);
+        expect(group.spies).to.have.lengthOf(1);
+        expect(group.spies[0]).to.equal(spy1);
+        const spy2 = group.on(targetFn2);
+        expect(group.spies).to.have.lengthOf(2);
+        expect(group.spies[1]).to.equal(spy2);
+      });
+
+      it('should return the created spy instance', function () {
+        const targetFn = () => {};
+        const returnedSpy = group.on(targetFn);
+        expect(returnedSpy).to.be.a('function');
+        // проверка, что это тот же шпион, что и в массиве
+        expect(group.spies[0]).to.equal(returnedSpy);
+      });
+    });
+
+    describe('.restore()', function () {
+      let obj1, originalMethod1;
+      let obj2, originalMethod2;
+      let standaloneFn1, standaloneFn2;
+      let spyObj1, spyObj2, spyFn1, spyFn2;
+
+      beforeEach(function () {
+        originalMethod1 = function () {
+          return 'original1';
+        };
+        obj1 = {method: originalMethod1};
+
+        originalMethod2 = function () {
+          return 'original2';
+        };
+        obj2 = {method: originalMethod2};
+
+        standaloneFn1 = function () {
+          return 'standalone1';
+        };
+        standaloneFn2 = function () {
+          return 'standalone2';
+        };
+
+        spyObj1 = group.on(obj1, 'method');
+        spyFn1 = group.on(standaloneFn1);
+        spyObj2 = group.on(obj2, 'method');
+        spyFn2 = group.on(standaloneFn2);
+
+        // вызов всех шпионов для наполнения истории
+        obj1.method(); // spyObj1
+        spyFn1();
+        obj2.method(); // spyObj2
+        spyFn2();
+
+        expect(spyObj1.callCount).to.equal(1);
+        expect(spyFn1.callCount).to.equal(1);
+        expect(spyObj2.callCount).to.equal(1);
+        expect(spyFn2.callCount).to.equal(1);
+      });
+
+      it('should call restore() on all spies in the group', function () {
+        group.restore();
+        // проверка восстановления методов объектов
+        expect(obj1.method).to.equal(originalMethod1);
+        expect(obj1.method()).to.equal('original1');
+        expect(obj2.method).to.equal(originalMethod2);
+        expect(obj2.method()).to.equal('original2');
+        // проверка сброса истории
+        expect(spyObj1.callCount).to.equal(0);
+        expect(spyObj1.called).to.be.false;
+        expect(spyFn1.callCount).to.equal(0);
+        expect(spyFn1.called).to.be.false;
+        expect(spyObj2.callCount).to.equal(0);
+        expect(spyObj2.called).to.be.false;
+        expect(spyFn2.callCount).to.equal(0);
+        expect(spyFn2.called).to.be.false;
+      });
+
+      it('should clear the internal spies array', function () {
+        expect(group.spies).to.have.lengthOf(4);
+        group.restore();
+        expect(group.spies).to.be.an('array').that.is.empty;
+      });
+
+      it('should return the SpiesGroup instance for chaining (if other methods were added)', function () {
+        const returnedValue = group.restore();
+        expect(returnedValue).to.equal(group);
+      });
+
+      it('should be idempotent - calling restore multiple times should not error', function () {
+        // первый вызов restore
+        group.restore();
+        // второй вызов restore
+        expect(() => group.restore()).to.not.throw();
+        // проверки состояний после второго вызова (должно быть таким же)
+        expect(obj1.method).to.equal(originalMethod1);
+        expect(spyObj1.callCount).to.equal(0);
+        expect(group.spies).to.be.an('array').that.is.empty;
+      });
+
+      it('should handle an empty spies array gracefully', function () {
+        const emptyGroup = createSpiesGroup();
+        expect(() => emptyGroup.restore()).to.not.throw();
+        expect(emptyGroup.spies).to.be.an('array').that.is.empty;
+      });
+    });
+  });
+});

+ 40 - 38
src/create-spy.d.ts

@@ -75,9 +75,9 @@ export interface Spy<TFunc extends AnyCallable = AnyCallable> {
   /**
    * Получает детали n-го вызова шпиона.
    * Если вызов с указанным индексом `n` не существует, выбрасывает `RangeError`.
-   * @param n Индекс вызова (начиная с нуля).
    *
-   * @returns Объект `CallInfo` для n-го вызова.
+   * @param   n           Индекс вызова (начиная с нуля).
+   * @returns             Объект `CallInfo` для n-го вызова.
    * @throws `RangeError` если индекс `n` невалиден.
    */
   getCall(n: number): CallInfo<Parameters<TFunc>, ReturnType<TFunc>>;
@@ -87,8 +87,8 @@ export interface Spy<TFunc extends AnyCallable = AnyCallable> {
    * Использует `Object.is` для сравнения аргументов.
    *
    * @param expectedArgs Ожидаемые аргументы для проверки.
-   * @returns `true`, если шпион был вызван с совпадающими
-   *   аргументами, иначе `false`.
+   * @returns            `true`, если шпион был вызван с совпадающими
+   *                     аргументами, иначе `false`.
    */
   calledWith(...expectedArgs: Parameters<TFunc>): boolean;
 
@@ -97,11 +97,11 @@ export interface Spy<TFunc extends AnyCallable = AnyCallable> {
    * Использует `Object.is` для сравнения аргументов.
    * Если вызов с указанным индексом `n` не существует, выбрасывает `RangeError`.
    *
-   * @param n Индекс вызова (начиная с нуля).
-   * @param expectedArgs Ожидаемые аргументы для проверки.
-   * @returns `true`, если n-ый вызов имел совпадающие
-   *   аргументы, иначе `false`.
-   * @throws `RangeError` если индекс `n` невалиден (унаследовано от `getCall`).
+   * @param   n            Индекс вызова (начиная с нуля).
+   * @param   expectedArgs Ожидаемые аргументы для проверки.
+   * @returns              `true`, если n-ый вызов имел совпадающие
+   *                       аргументы, иначе `false`.
+   * @throws  `RangeError` если индекс `n` невалиден (унаследовано от `getCall`).
    */
   nthCalledWith(n: number, ...expectedArgs: Parameters<TFunc>): boolean;
 
@@ -110,11 +110,11 @@ export interface Spy<TFunc extends AnyCallable = AnyCallable> {
    * Использует `Object.is` для сравнения значений.
    * Если вызов с указанным индексом `n` не существует, выбрасывает `RangeError`.
    *
-   * @param n Индекс вызова (начиная с нуля).
-   * @param expectedReturnValue Ожидаемое возвращаемое значение.
-   * @returns `true`, если n-ый вызов вернул ожидаемое значение, иначе `false`
-   *   (включая случаи, когда он выбросил ошибку).
-   * @throws `RangeError` если индекс `n` невалиден (унаследовано от `getCall`).
+   * @param   n                   Индекс вызова (начиная с нуля).
+   * @param   expectedReturnValue Ожидаемое возвращаемое значение.
+   * @returns                     `true`, если n-ый вызов вернул ожидаемое значение,
+   *                              иначе `false` (включая случаи, когда он выбросил ошибку).
+   * @throws  `RangeError`        если индекс `n` невалиден (унаследовано от `getCall`).
    */
   nthCallReturned(n: number, expectedReturnValue: ReturnType<TFunc>): boolean;
 
@@ -122,15 +122,16 @@ export interface Spy<TFunc extends AnyCallable = AnyCallable> {
    * Проверяет, выбросил ли n-ый вызов шпиона ошибку.
    * Если вызов с указанным индексом `n` не существует, выбрасывает `RangeError`.
    *
-   * @param n Индекс вызова (начиная с нуля).
+   * @param n             Индекс вызова (начиная с нуля).
    * @param expectedError Необязательно.
-   *   Если предоставлено, проверяет соответствие выброшенной ошибки:
-   *     - `string`: совпадение по сообщению ошибки.
-   *     - Конструктор `Error`: совпадение через `instanceof`.
-   *     - Экземпляр `Error`: совпадение по имени и сообщению ошибки.
-   *     - Прямое сравнение объектов с использованием `Object.is`.
-   * @returns `true`, если n-ый вызов выбросил совпадающую ошибку
-   *   (или любую ошибку, если матчер не предоставлен), иначе `false` (если вызов не бросил ошибку).
+   *                      Если предоставлено, проверяет соответствие выброшенной ошибки:
+   *                        - `string`: совпадение по сообщению ошибки.
+   *                        - Конструктор `Error`: совпадение через `instanceof`.
+   *                        - Экземпляр `Error`: совпадение по имени и сообщению ошибки.
+   *                        - Прямое сравнение объектов с использованием `Object.is`.
+   * @returns             `true`, если n-ый вызов выбросил совпадающую ошибку
+   *                      (или любую ошибку, если матчер не предоставлен),
+   *                      иначе `false` (если вызов не бросил ошибку).
    * @throws `RangeError` если индекс `n` невалиден (унаследовано от `getCall`).
    */
   nthCallThrew(
@@ -149,11 +150,12 @@ export interface Spy<TFunc extends AnyCallable = AnyCallable> {
 /**
  * Создает шпиона для отдельной функции.
  *
- * @template TFunc Тип функции, для которой создается шпион.
- * @param targetFn Функция, для которой создается шпион.
- * @param customImplementation Необязательно. Функция для замены поведения
- *   оригинальной функции. Должна иметь ту же сигнатуру, что и `targetFn`.
- * @returns Функция-шпион.
+ * @template TFunc                Тип функции, для которой создается шпион.
+ * @param    targetFn             Функция, для которой создается шпион.
+ * @param    customImplementation Необязательно. Функция для замены поведения
+ *                                оригинальной функции. Должна иметь ту же
+ *                                сигнатуру, что и `targetFn`.
+ * @returns                       Функция-шпион.
  */
 export function createSpy<TFunc extends AnyCallable>(
   targetFn: TFunc,
@@ -161,19 +163,19 @@ export function createSpy<TFunc extends AnyCallable>(
 ): Spy<TFunc>;
 
 /**
- * Создает шпиона для метода объекта.
- * Оригинальный метод объекта будет заменен шпионом.
- * Используйте `spy.restore()` для восстановления оригинального метода.
+ * Создание шпиона для метода объекта. Оригинальный метод объекта будет заменен
+ * шпионом. Используйте `spy.restore()` для восстановления оригинального метода.
  *
  * @template TObj Тип объекта.
- * @template K Ключ метода, для которого создается шпион.
- *   Должен быть ключом `TObj`, значение которого является функцией.
- * @param targetObject Объект, метод которого отслеживается.
- * @param methodName Имя отслеживаемого метода.
- * @param customImplementation Необязательно. Функция для замены поведения
- *   оригинального метода. Должна иметь ту же сигнатуру, что и оригинальный
- *   метод `TObj[K]`.
- * @returns Функция-шпион для метода.
+ * @template K                    Ключ метода, для которого создается шпион.
+ *                                Должен быть ключом `TObj`, значение которого
+ *                                является функцией.
+ * @param    targetObject         Объект, метод которого отслеживается.
+ * @param    methodName           Имя отслеживаемого метода.
+ * @param    customImplementation Необязательно. Функция для замены поведения
+ *                                оригинального метода. Должна иметь ту же сигнатуру,
+ *                                что и оригинальный метод `TObj[K]`.
+ * @returns                       Функция-шпион для метода.
  */
 export function createSpy<
   TObj extends object,

+ 11 - 4
src/create-spy.js

@@ -298,13 +298,20 @@ export function createSpy(target, methodNameOrImpl, customImplForMethod) {
     return Object.is(call.error, expectedError);
   };
   // определение метода `restore` для восстановления
-  // оригинального метода объекта, если шпионили за ним
+  // оригинального метода объекта и сброса истории вызовов
   spy.restore = () => {
-    // восстановление происходит только
-    // для шпионов методов объектов
+    // восстановление оригинального метода
+    // объекта, если шпионили за ним
     if (isMethodSpy && objToSpyOn) {
-      objToSpyOn[methodName] = originalFn;
+      // проверка, что originalFn существует (на всякий случай,
+      // хотя по логике _parseSpyArgs он должен быть)
+      if (originalFn !== undefined) {
+        objToSpyOn[methodName] = originalFn;
+      }
     }
+    // сброс истории вызовов
+    callLog.count = 0;
+    callLog.calls = [];
   };
   // если создается шпион для метода объекта,
   // оригинальный метод немедленно заменяется шпионом

+ 44 - 17
src/create-spy.spec.js

@@ -174,6 +174,25 @@ describe('createSpy', function () {
       // проверка сохраненного контекста
       expect(spy.getCall(0).thisArg).to.equal(contextObj);
     });
+
+    it('restore() on a function spy should reset its history and not throw', function () {
+      const standaloneFn = () => 'standalone result';
+      const fnSpy = createSpy(standaloneFn);
+      // вызов шпиона, чтобы у него была история
+      fnSpy('call standalone');
+      expect(fnSpy.called).to.be.true;
+      expect(fnSpy.callCount).to.equal(1);
+      expect(fnSpy.getCall(0).args).to.deep.equal(['call standalone']);
+      // проверка, что вызов restore не вызывает ошибок
+      expect(() => fnSpy.restore()).to.not.throw();
+      // проверки сброса истории
+      expect(fnSpy.callCount).to.equal(0);
+      expect(fnSpy.called).to.be.false;
+      expect(() => fnSpy.getCall(0)).to.throw(
+        RangeError,
+        'Invalid call index 0. Spy has 0 call(s).',
+      );
+    });
   });
 
   describe('when spying on an object method', function () {
@@ -242,10 +261,13 @@ describe('createSpy', function () {
       expect(spy.getCall(0).thisArg).to.equal(obj);
     });
 
-    it('restore() should put the original method back', function () {
+    it('restore() should put the original method back and reset spy history', function () {
       // создание шпиона для метода
       const spy = createSpy(obj, 'method');
-      // проверка, что метод заменен
+      // вызов шпиона, чтобы у него была история
+      obj.method('call before restore');
+      expect(spy.called).to.be.true;
+      expect(spy.callCount).to.equal(1);
       expect(obj.method).to.equal(spy);
       // вызов метода restore на шпионе
       spy.restore();
@@ -253,22 +275,28 @@ describe('createSpy', function () {
       expect(obj.method).to.equal(originalMethodImpl);
       // вызов восстановленного метода
       // для проверки его работоспособности
-      const result = obj.method('after restore');
-      // проверка результата вызова
-      // оригинального метода
-      expect(result).to.equal('original: TestObj after restore');
+      const result = obj.method('call after restore');
+      // проверка результата вызова оригинального метода
+      expect(result).to.equal('original: TestObj call after restore');
+      // проверки сброса истории
+      expect(spy.callCount).to.equal(0);
+      expect(spy.called).to.be.false;
+      expect(() => spy.getCall(0)).to.throw(
+        RangeError,
+        'Invalid call index 0. Spy has 0 call(s).',
+      );
     });
 
-    it('restore() on a function spy should not throw and do nothing to objects', function () {
-      // создание шпиона для отдельной функции
-      const fnSpy = createSpy(function () {});
-      // проверка, что вызов restore
-      // не вызывает ошибок
-      expect(() => fnSpy.restore()).to.not.throw();
-      // проверка, что метод объекта не был изменен,
-      // так как шпион не был на нем установлен
-      expect(obj.method).to.equal(originalMethodImpl);
-    });
+    // Этот тест стал частью теста для standalone функции, но если хочешь оставить его здесь для ясности
+    // относительно влияния на `obj` (из beforeEach), то можно.
+    // Я бы его убрал, т.к. его суть (restore на fnSpy не трогает obj.method)
+    // покрывается тем, что fnSpy.restore() вообще не должен иметь дела с obj.
+    // Для чистоты, я перенес логику проверки истории в тест для standalone шпиона выше.
+    // it('restore() on a function spy should not throw and do nothing to objects', function () {
+    //   const fnSpy = createSpy(function () {});
+    //   expect(() => fnSpy.restore()).to.not.throw();
+    //   expect(obj.method).to.equal(originalMethodImpl); // obj из beforeEach
+    // });
   });
 
   describe('spy properties and methods', function () {
@@ -476,7 +504,6 @@ describe('createSpy', function () {
         } catch (e) {
           // бросает ошибку
         }
-
         // проверки для различных сценариев
         expect(spy.nthCallReturned(0, 4)).to.be.false;
         expect(spy.nthCallReturned(1, undefined)).to.be.false;

+ 1 - 0
src/index.d.ts

@@ -1 +1,2 @@
 export * from './create-spy.js';
+export * from './create-spies-group.js';

+ 1 - 0
src/index.js

@@ -1 +1,2 @@
 export * from './create-spy.js';
+export * from './create-spies-group.js';