发布于 2026-01-06 1 阅读
0

逆向工程——了解测试中的间谍活动

逆向工程——了解测试中的间谍活动

逆向工程——了解测试中的间谍活动

逆向工程——了解测试中的间谍活动

欢迎在推特上关注我,我很乐意接受您对话题或改进方面的建议。/克里斯

我们使用 spy 不仅是为了模拟依赖项的响应,也是为了确保依赖项已被正确调用。所谓正确,指的是调用次数、参数类型和数量是否正确。我们可以验证很多方面来确保代码运行正常。本练习旨在帮助你理解 Jasmine 中的 spy,以及其底层工作原理。

本文旨在阐述:

  • 为什么要使用间谍?了解我们为什么要使用间谍,以及它们目前为止有什么用处。
  • 什么?解释一下间谍能为我们做什么。
  • 如何才能揭示它们底层的工作原理,并尝试对其公共 API 进行逆向工程?

简而言之,如果您只想查看实现过程,而不想了解具体实现方法,请直接滚动到文章底部查看完整代码。:)

 为什么是间谍?

让我们来描述一下场景。我们有一个对业务至关重要的功能,需要将订单发送给用户。该应用程序是用 Node.js 编写的,也就是后端使用 JavaScript。

我们必须先收到货款才能发货。任何对这段代码的改动都应该会被我们即将部署的监控系统检测到。

代码如下:

async function makeOrder(
  paymentService, 
  shippingService, 
  address, 
  amount, 
  creditCard
) {
  const paymentRef = await paymentService.charge(creditCard, amount)

  if (paymentService.isPaid(paymentRef)) {
    shippingService.shipTo(address);
  }
}
Enter fullscreen mode Exit fullscreen mode

我们有这样一个函数makeOrder()。它需要两个不同的依赖项 a和 a 的makeOrder()帮助。至关重要的是,在发货之前必须调用该函数来检查我们是否已收到付款,否则会对业务造成不利影响。shippingServicepaymentServicepaymentService

同样重要的是,我们需要在某个时候调用该函数shippingService来确保物品能够送达。现在,代码很少能如此清晰明了,让你清楚地看到它的作用以及删除以下任何代码的后果。关键在于,我们需要为以下代码编写测试,并且需要使用间谍程序来验证我们的代码是否被直接调用。

简而言之:

间谍们往往asserting behavior过分强调结果。

 什么

好的,我们在本文开头几行提到,Spies 可以帮助我们检查依赖项被调用了多少次,使用了哪些参数等等,但让我们尝试列出 Jasmine Spies 中我们知道的所有功能:

  • 已调用,请确认是否已调用
  • Args,验证它是否已使用特定参数调用。
  • 呼叫次数,请核实呼叫次数。
  • 调用次数和参数:验证函数被调用的次数以及使用的所有参数。
  • 模拟,返回一个模拟值
  • 恢复,因为间谍程序会替换原有功能,所以我们迟早需要将依赖项恢复到其原始实现。

这真是一系列功能,应该能够帮助我们验证上述行为makeOrder()

方法

接下来,我们将开始了解 Jasmine Spies 及其公共 API。然后,我们将开始勾勒出可能的实现方案。

好的。在 Jasmine 中,我们通过调用类似这样的代码来创建 Spy:

const apiService = {
  fetchData() {}
}
Enter fullscreen mode Exit fullscreen mode

然后我们在测试中使用它,如下所示:

it('test', () => {
  // arrange
  spyOn(apiService, 'fetchData')

  // act
  doSomething(apiService.fetchData)

  // assert
  expect(apiService.fetchData).toHaveBeenCalled();
})
Enter fullscreen mode Exit fullscreen mode

如上所示,我们需要关注三个不同的步骤。

  1. 创建间谍spyOn()
  2. 启用间谍
  3. 声称该间谍已被召集

让我们开始实施吧。

打造间谍

通过观察它的使用方式,你会发现你替换的是一个真实的函数,而替换的是一个模拟函数。这意味着我们最终赋值的对象apiService.fetchData 必须是一个函数

问题的另一部分在于我们如何断言它已被调用。我们需要考虑以下这行代码:

expect(apiService.fetchData).toHaveBeenCalled()
Enter fullscreen mode Exit fullscreen mode

现在我们需要开始实现这条线路,如下所示:

function expect(spy) {
  return {
    toHaveBeenCalled() {
      spy.calledTimes()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

等等。你刚才说那apiService.fetchData是一个函数。可是你又把它当作对象expect()来传递和调用。我搞不懂了 :(calledTimes()

啊,我明白了。你之前可能接触过像 C# 或 Java 这样的面向对象语言,对吧?

你是怎么知道的?

在那些语言中,你要么是对象,要么是函数,不可能两者兼具。但我们使用的是 JavaScript,而 JavaScript 规定:

函数是函数对象。在 JavaScript 中,任何非原始类型(undefined、null、boolean、number 或 string)的值都是对象

这意味着我们的间谍程序虽然是一个函数,但它拥有像对象一样的方法和属性。

真棒,又有点怪……

好的。有了这些信息,我们就可以开始实施了。

// spy.js

function spy(obj, key) {
  times = 0;
  old = obj[key];

  function spy() {
    times++;
  }

  spy.calledTimes = () => times;

  obj[key] = spy;
}


function spyOn(obj, key) {
  spy(obj, key);
}

module.exports = {
  spyOn
}
Enter fullscreen mode Exit fullscreen mode

spyOn()内部调用spy()会创建一个函数,_spy()该函数知道变量的值times并公开其公共方法calledTime()。然后,我们最终将其赋值_spy给要替换其函数的对象。

添加匹配器toHaveBeenCalled()

我们来创建这个文件util.js,让它看起来像这样:

// util.js

function it(testName, fn) {
  console.log(testName);
  fn();
}

function expect(spy) {
  return {
    toHaveBeenCalled() {
      let result = spy.calledTimes() > 0;
      if (result) {
        console.log('spy was called');
      } else {
        console.error('spy was NOT called');
      }
    }
  }
}

module.exports = {
  it, 
  expect
}
Enter fullscreen mode Exit fullscreen mode

如您所见,它仅包含一个非常简单的 ` expect()and`it()方法实现。接下来,我们创建一个demo.js文件来测试我们的实现:

// demo.js

const { spyOn } = require('./spy');
const { it, expect } = require('./util');

function impl(obj) {
  obj.calc();
}

it('test spy', () => {
  // arrange
  const obj = {
    calc() {}
  }

  spyOn(obj, 'calc');

  // act
  impl(obj);

  // assert
  expect(obj.calc).toHaveBeenCalled();
})
Enter fullscreen mode Exit fullscreen mode

我们已经取得了很大的进展,但让我们看看如何才能做得更好。

添加匹配器toHaveBeenCalledTimes()

这个匹配器几乎已经自动编写好了,因为我们已经记录了调用次数。只需将以下代码添加到我们的it()函数中,util.js如下所示:

toHaveBeenCalledTimes(times) {
  let result = spy.calledTimes();
  if(result == times) {
    console.log(`success, spy was called ${times}`)
  } else {
    console.error(`fail, expected spy to be called: ${times} but was: ${result}`)
  }
}
Enter fullscreen mode Exit fullscreen mode

添加匹配器toHaveBeenCalledWith()

现在这个匹配器要求我们验证我们能否知道我们的间谍被调用了什么,它的使用方法如下:

expect(obj.someMethod).toHaveBeenCalledWith('param', 'param2');
Enter fullscreen mode Exit fullscreen mode

让我们重新审视一下我们对以下功能的实现spy()

// excerpt from spy.js

function spy(obj, key) {
  times = 0;
  old = obj[key];

  function spy() {
    times++;
  }

  spy.calledTimes = () => times;

  obj[key] = spy;
}
Enter fullscreen mode Exit fullscreen mode

我们可以看到,我们通过变量记录了某个函数被调用的次数,times但我们想稍微修改一下。与其使用存储数字的变量,不如用数组来代替,如下所示:

// spy-with-args.js

function spy(obj, key) {
  let calls = []

  function _spy(...params) {
    calls.push({
      args: params
    });
  }

  _spy.calledTimes = () => calls.length;
  _spy._calls = calls;

  obj[key] = _spy;
}
Enter fullscreen mode Exit fullscreen mode

如您所见,该_spy()方法收集所有输入参数并将它们添加到数组中callscalls它不仅会记住调用次数,还会记住每次调用所使用的参数。

创建匹配器

为了测试它是否存储了所有调用及其参数,让我们在方法中创建另一个匹配器函数expect()并调用它toHaveBeenCalledWith()。现在,它的要求是我们的间谍函数必须在某个迭代周期中被调用过并带有这些参数。它没有说明是哪一次迭代,这意味着我们可以遍历calls数组直到找到匹配项。

让我们像这样将匹配器添加到我们的方法it()中:utils.js

// excerpt from util.js
toHaveBeenCalledWith(...params) {
  for(var i =0; i < spy._calls.length; i++) {
    const callArgs = spy._calls[i].args;
    const equal = params.length === callArgs.length && callArgs.every((value, index) => { 
      const res = value === params[index];
      return res;
    });
    if(equal) {
      console.log(`success, spy was called with ${params.join(',')} `)
      return;
    }
  }
  console.error(`fail, spy was NOT called with ${params} spy was invoked with:`);
  console.error(spy.getInvocations());

}
Enter fullscreen mode Exit fullscreen mode

上面你可以看到我们如何将params它(我们称之为)与我们在间谍调用中的每个参数进行比较。

现在,让我们在demo.js测试方法调用中添加一些代码,以便测试我们的新匹配器,如下所示:


// excerpt from demo.js

it('test spy', () => {
  // arrange
  const obj = {
    calc() {}
  }

  spyOn(obj, 'calc');

  // act
  impl(obj);

  // assert
  expect(obj.calc).toHaveBeenCalled();
  expect(obj.calc).toHaveBeenCalledWith('one', 'two');
  expect(obj.calc).toHaveBeenCalledWith('three');
  expect(obj.calc).toHaveBeenCalledWith('one', 'two', 'three');
})
Enter fullscreen mode Exit fullscreen mode

在终端中运行此命令,我们得到:

我们可以看到,它的效果非常好。前两个问题都解决了,最后一个问题失败了,这也在意料之中。

重置,最后一块

我们还需要添加一项功能,即重置实现的功能。这可能是最简单的操作。让我们打开spy-with-args.js文件。我们需要执行以下操作:

  1. 添加对旧实现的引用
  2. 添加一个方法reset(),使我们能够返回到最初的实现。

添加参考文献

在函数内部spy()添加以下代码行:

let old = obj[key];
Enter fullscreen mode Exit fullscreen mode

这将把实现保存到变量中。old

添加reset()方法

只需添加以下代码行:

_spy.reset = () => obj[key] = old;
Enter fullscreen mode Exit fullscreen mode

现在,该spy()方法应该如下所示:

function spy(obj, key) {
  let calls = []
  let old = obj[key];

  function _spy(...params) {
    calls.push({
      args: params
    });
  }

  _spy.reset = () => obj[key] = old;
  _spy.calledTimes = () => calls.length;
  _spy.getInvocations = () => {
    let str = '';
    calls.forEach((call, index) => {
      str+= `Invocation ${index + 1}, args: ${call.args} \n`;
    });

    return str;
  }

  _spy._calls = calls;

  obj[key] = _spy;
}
Enter fullscreen mode Exit fullscreen mode

概括

我们已经走到了最后一步。
我们从一开始就实现了一个间谍程序。此外,我们还解释了几乎所有事物都是对象,这使得我们能够以这种方式实现它。

最终我们得到了一个间谍程序,它会存储所有调用记录以及调用时传递的参数。此外,我们还创建了三个不同的匹配器,分别用于检测我们的间谍程序是否被调用、被调用了多少次以及每次调用时传递了哪些参数。

总而言之,这是一次成功的探索间谍本质的冒险之旅。

我们意识到这只是一个雏形,要将其投入生产环境,我们可能需要支持诸如比较某个函数是否被对象调用、支持模拟等功能。这些留给你们作为练习。

作为另一项课后练习,请尝试为makeOrder()我们开头提到的函数编写测试。

完整代码

如果你还没看懂,这里是完整的代码:

util.js,其中包含我们的匹配器函数

我们的文件包含我们的函数it()及其expect()匹配器。

// util.js

function it(testName, fn) {
  console.log(testName);
  fn();
}

function expect(spy) {
  return {
    toHaveBeenCalled() {
      let result = spy.calledTimes() > 0;
      if (result) {
        console.log('success,spy was called');
      } else {
        console.error('fail, spy was NOT called');
      }
    },
    toHaveBeenCalledTimes(times) {
      let result = spy.calledTimes();
      if(result == times) {
        console.log(`success, spy was called ${times}`)
      } else {
        console.error(`fail, expected spy to be called: ${times} but was: ${result}`)
      }
    },
    toHaveBeenCalledWith(...params) {
      for(var i =0; i < spy._calls.length; i++) {
        const callArgs = spy._calls[i].args;
        const equal = params.length === callArgs.length && callArgs.every((value, index) => { 
          const res = value === params[index];
          return res;
        });
        if(equal) {
          console.log(`success, spy was called with ${params.join(',')} `)
          return;
        }
      }
      console.error(`fail, spy was NOT called with ${params} spy was invoked with:`);
      console.error(spy.getInvocations());

    }
  }
}

module.exports = {
  it, 
  expect
}
Enter fullscreen mode Exit fullscreen mode

间谍实施

我们的间谍方案spy-with-args.js

function spyOn(obj, key) {
  return spy(obj, key);
}

function spy(obj, key) {
  let calls = []
  let old = obj[key];

  function _spy(...params) {
    calls.push({
      args: params
    });
  }

  _spy.reset = () => obj[key] = old;
  _spy.calledTimes = () => calls.length;
  _spy.getInvocations = () => {
    let str = '';
    calls.forEach((call, index) => {
      str+= `Invocation ${index + 1}, args: ${call.args} \n`;
    });

    return str;
  }

  _spy._calls = calls;

  obj[key] = _spy;
}

module.exports = {
  spyOn
};
Enter fullscreen mode Exit fullscreen mode

demo.js,用于测试

最后,是我们的demo.js文件:

const { spyOn } = require('./spy-with-args');
const { it, expect } = require('./util');

function impl(obj) {
  obj.calc('one', 'two');

  obj.calc('three');
}

it('test spy', () => {
  // arrange
  const obj = {
    calc() {}
  }

  spyOn(obj, 'calc');

  // act
  impl(obj);

  // assert
  expect(obj.calc).toHaveBeenCalled();
  expect(obj.calc).toHaveBeenCalledWith('one', 'two');
  expect(obj.calc).toHaveBeenCalledWith('three');
  expect(obj.calc).toHaveBeenCalledWith('one', 'two', 'three');
})
Enter fullscreen mode Exit fullscreen mode
文章来源:https://dev.to/itnext/reverse-engineering-understanding-spies-in-testing-3443