2

在 Javascript 中,我有两个版本的递归函数,一个同步运行,一个使用简单的调度来异步运行。给定某些输入,在这两种情况下,函数都应具有无限的执行路径。我需要为这些功能开发测试,特别是检查异步版本不会阻塞主线程的测试。

我已经有测试在不返回的情况下检查这些函数的输出回调行为,我只关心测试阻塞行为。我也可以将函数运行的时间限制为一些很长但有限的时间,以用于测试目的。我目前正在使用 QUnit,但可以切换到另一个测试框架。

如何测试不返回的异步函数不会阻塞?

编辑,澄清

这将是我正在使用的函数的一个简单示例:

function a()
{
    console.log("invoked");
    setTimeout(a, 1000);
}

a();

我故意在我的描述中滥用一些线程术语,因为我觉得它们最清楚地表达了问题。不阻塞主线程,我的意思是调用函数不会阻止其他逻辑的调度和执行。我希望函数本身将在主线程上执行,但我认为只要它被安排在将来执行,函数就会运行。

4

7 回答 7

2

单元测试基于单一责任原则和隔离(将被测对象与其依赖项分开)。

在这种情况下,您希望您的函数异步运行,但此行为不是由您的函数完成的,而是由“setTimeout”函数完成的,所以我认为您必须将您的函数与“setTimeout”隔离,因为它是您不依赖的依赖项想要测试,浏览器保证它会工作。

然后,由于我们相信“setTimeout”会执行异步逻辑,我们只能测试对“setTimeout”的函数调用,我们可以用另一个函数替换“window.setTimeout”,而我们必须始终在测试完成后恢复它.

function replaceSetTimeout() {
    var originalSetTimeout = window.setTimeout;
    var callCount = 0;

    window.setTimeout = function() {
        callCount++;
    };

    window.setTimeout.restore = function() {
        window.setTimeout = originalSetTimeout;
    };

    window.setTimeout.getCallCount = function() {
        return callCount;
    };
}

replaceSetTimeout();
asyncFunction();
assert(setTimeout.getCallCount() === 1);
setTimeout.restore();

我建议您使用sinon.js,因为它提供了许多工具,例如函数式间谍,而不是告诉您调用了多少次以及调用了哪些参数。

var originalSetTimeout = window.setTimeout;
window.setTimeout = sinon.spy();
asyncFunction();

// check called only once
assert(setTimeout.calledOnce);
// check the first argument was asyncFunction
assert(setTimeout.calledWith(asyncFunction));

Sinon 还提供了执行 setTimeout 替换但具有更多功能的假计时器,例如将模拟“x”毫秒的 .tick(x) 方法,但在这种情况下,我认为它对您没有帮助。

更新以回答问题编辑:

1 - 您的函数无限执行,因此您无法在不中断执行的情况下对其进行测试,因此您必须在某处覆盖“setTimeout”。

2 - 您希望您的函数递归执行,允许在迭代之间执行其他代码?伟大的!但请理解您的函数不能做到这一点,您的函数只能调用 setTimeout 或 setInterval 并希望此函数按预期工作。你应该测试你的函数做了什么。

3 - 您想从 Javascript(沙盒环境)进行测试,而不是另一个 Javascript 代码使用并释放唯一的一个执行线程(与您用于测试的线程相同)。你真的认为这是一个简单的测试吗?

4 -但最重要的一个- 我不喜欢白盒,因为它将测试与依赖项结合在一起,如果您更改依赖项或将来如何调用它,您将不得不更改测试。DOM 函数不存在这个问题,DOM 函数将保持相同的接口多年,而现在,除了调用这两个函数之一,你没有其他方法可以做你想做的事,所以我不认为案例“白盒测试”是个坏主意。

我告诉你这个是因为我在测试 Promise 模式实现时遇到了与必须始终异步的相同问题,即使承诺已经实现,并且我已经使用测试引擎异步测试方式(使用回调和其他东西)对其进行了测试这是一团糟,测试随机失败,测试执行速度非常慢。然后我问了一位 TDD 专家,测试怎么这么难,他回答说我没有遵循单一责任原则,因为我试图测试我的 promise 实现和 setTimeout 行为。

于 2012-10-06T00:00:40.237 回答
1

如果您从行为驱动测试的角度考虑它,那么“我的功能会阻塞吗?” 不是一个有用的问题。它肯定会阻塞,一个更好的问题可能是“它是否在不超过 50 毫秒内返回”。

你可以这样做:

test( "speed test", function() {
  var start = new Date();
  a();
  ok(new Date() - start < 50, "Passed!" );
});

这样做的问题是,如果有人做了一些愚蠢的事情,使你的功能无限期地阻塞,测试不会失败,它会挂起。

因为 JavaScript 是单线程的,所以没有办法解决这个问题。如果我过来并将您的功能更改为:

function a() {
    while(true) {
        console.log("invoked")
    }
}

测试将挂起。

您可以通过稍微重构一些东西来使这种方式更难破坏。有两件不同的事情正在做。您的工作量和日程安排。将它们分开,您最终会得到以下函数:

function a() {
    // doWork
    var stopRunning = true;

    return stopRunning;
}

function doAsync(workFunc, scheduleFunc, timeout) {
    if (!workFunc()) {
       scheduleFunc(doAsync, [workFunc, scheduleFunc, timeout], timeout);
    }
}

function schedule(func, args, timeout) {
    setTimeout(function() {func.apply(window, args);}, timeout);
}

现在您可以自由地单独测试所有内容。您可以为 doAsync 的测试提供一个模拟 workFunc 和 scheduleFunc 以验证它的行为是否符合预期,并且您可以测试您的函数 a() 而不必担心它是如何安排的。

傻瓜程序员仍然可以将无限循环放入函数 a() 中,但因为他们不必考虑如何运行更多的工作单元,所以它应该不太可能。

于 2012-10-12T02:32:30.847 回答
1

要测试或证明无限执行的执行路径永远不会阻塞几乎是不可能的,因此您必须将问题拆分为多个部分。

您的路径基本上是foo(foo(foo(foo(...etc...)))),没关系SetTimeout实际上消除了递归。因此,您所要做的就是测试或证明您的 foo 没有阻塞(我现在告诉您,测试将比证明“稍微”容易一些,更多内容如下)

那么,功能是否foo阻塞?

说点数学,如果你想知道f(f(...f(x)...))总是有一个值,你实际上只需要证明f(x)任何可以返回的总是有一个xf。如果你能确保它们的返回值是好的,你有多少递归并不重要。

这对您foo来说意味着您只需要证明foo不会阻止任何可能的输入值。请记住,在这种情况下,所有全局变量和闭包也是输入值。这意味着您必须对每次通话中使用的每个值进行全面检查。

要进行测试,您当然必须替换 SetTimeout,但这很简单,如果您用空函数 ( function(){}) 替换它,很容易证明该函数不会阻塞或以其他方式改变您的执行。然后你会

让事情变得更容易

考虑到我上面写的内容,这也意味着您必须确保您曾经使用的任何全局函数或变量都不会更改为您的函数中断到它中断的点。这实际上是相当困难的,但是您仍然可以通过确保始终使用相同的函数和值并且其他函数不能通过使用闭包来触及它们来使事情变得更容易。

function foo(n, setTimeout)
{
   var x = global_var;
   // sanity check n here
   function f()
   {
      setTimeout(f, n)
   }
   return f();
}
  • 这样,您只需在第一次执行时测试这些值。能够假设Math.Pi实际上是 Pi 而不是包含"noodles". 非常好。
  • 不要使用全局可变对象
  • 打电话给那些你不能规避的人,setTimeout以确保他们不能阻止

    • 如果您需要返回值,事情会变得非常棘手,但有可能,考虑一下:

      function() {
        var x = 0;
        setTimeout(function(){x = insecure();}, 1); 
      }
      

      你所要做的就是

      • 使用 x 下一次迭代
      • x 的健全性检查值优先!
  • SetTimeout 是否阻塞?

    当然这取决于 setTimeout 是否阻塞。这很难证明,但更容易测试。您实际上无法证明它,因为它的实现取决于解释器。

    就我个人而言,我会假设 setTimeout 在其返回值被丢弃时表现得像一个空函数。

于 2012-10-12T17:17:15.563 回答
0

根据所有答案,我想出了适用于我的案例的解决方案:

testAsync("Doesn't hang", function(){
    expect(1);

    var ranToLong = false;
    var last = new Date();
    var sched = setInterval(function(){
        var now = new Date();
        ranToLong = ranToLong || (now - last) >= 50;
        last = now;
    }, 0);

    // In this case, asyncRecursiveFunction runs for a long time and 
    // returns a single value in callback
    asyncRecursiveFunction(function callback(v){
        clearInterval(sched);
        var now = new Date();
        ranToLong = ranToLong || (now - last) >= 50;
        assert.equal(ranToLong, false);
        start();
    });
});

它通过查看另一个计划函数调用之间的时间来测试“asyncRecursiveFunction”在处理时是否挂起。

这真的很难看,并不适用于所有情况,但它似乎对我有用,因为我可以将我的函数限制为一些大型异步递归调用,因此它运行很长时间但不是无限时间。正如我在问题中提到的,我很高兴证明这种情况不会阻止。

顺便说一句,有问题的实际代码可以在gen.js中找到。主要问题是异步减少生成器。它正确地异步返回了一个值,但在以前的版本中会因为同步的内部实现而停止。

于 2012-10-16T04:19:17.537 回答
0

在 QUnit 中执行这种异步测试实际上是可能的,但在另一个 JavaScript 测试框架 Jasmine JS 中处理得更好。我将在这两个方面提供示例。

在 QUnit 中,您需要首先调用 stop() 函数来表示测试预计将异步运行,然后您应该使用包含您期望的函数调用 setTimeout 以及调用 start() 函数来完成该块. 这是一个例子:

test( "a test", function() {
    stop();
    asyncOp();
    setTimeout(function() {
        equals( asyncOp.result, "someExpectedValue" );
        start();
    }, 150 );
});

编辑:显然,您还可以使用整个 asyncTest 构造来简化此过程。看看:http ://api.qunitjs.com/asyncTest/

在行为驱动开发 (BDD) 测试框架 Jasmine (http://pivotal.github.com/jasmine/) 中,有用于编写异步测试的内置方法。这是 Jasmine 中的异步测试示例:

describe('Some module', function() {

    it('should run asynchronously', function() {
        var isDone = false;
        runs(function() {
            // The first call to runs should trigger some async operation
            // that has a side-effect that can be tested for. In this case,
            // lets say that the doSomethingAsyncWithCallback function
            // does something asynchronously and then calls the passed callback
            doSomethingAsyncWithCallback(function() { isDone = true; });
        });

        waitsFor(function() {
            // The call to waits for is a polling function that will get called
            // periodically until either a condition is met (the function should return
            // a boolean testing for this condition) or the timeout expires.
            // The optional text is what error to display if the test fails.
            return isDone === true;
        }, "Should set isDone to true", 500);

        runs(function() {
            // The second call to runs should contain any assertions you need to make
            // after the async call is complete.
            expect(isDone).toBe(true);
        });
    });
});

编辑:此外,Jasmine 有几种内置方法可以伪造浏览器的 setTimeout 和 setInterval 函数,而无需在您的套件中进行可能依赖于此的任何其他测试。我会看看使用那些而不是手动覆盖 setTimeout/setInterval 函数。

于 2012-10-07T20:20:41.723 回答
0

基本上,JavaScript 是单线程的,所以它阻塞主线程。但 :

  • 我假设您正在使用setTimesout来安排您的功能,因此如果对该功能的调用不会花费太多时间(例如,少于 200 或 300 毫秒),用户不会注意到它。

  • 如果您在该功能期间进行 DOM 操作(包括 Canvas 或 WebGL),那么您就完蛋了。但如果没有,您可以查看Web Workers,它可以生成保证不会阻塞 UI 的单独线程。

但无论如何,JavaScript 和主循环,这是一个棘手的问题,过去几个月一直困扰着我,所以你并不孤单!

于 2012-10-05T20:16:23.870 回答
0

一旦你的函数返回(在为它的下一次运行设置超时之后),javascript 将查看下一个需要运行的东西并运行它。

据我所知,javascript 中的“主线程”只是一个响应事件的循环(例如脚本标签的 onload,它运行该标签的内容)。

基于上述两个条件,尽管有任何 setTimeouts,调用线程总是会运行到完成,并且这些超时将在调用线程没有任何东西可以运行之后开始。

我测试的方法是在调用 a() 之后立即运行以下函数

function looper(name,duration) {
    var start = (new Date()).getTime();
    var elapsed = 0;
    while (elapsed < duration) {
        elapsed = (new Date()).getTime() - start;
        console.log(name + ": " + elapsed);
    }        
}

持续时间应设置为比 a() 中的 setTimeout 持续时间更长的时间段。预期的输出将是“looper”的输出,然后是重复调用 a() 的输出。

接下来要测试的是其他脚本标签是否能够在 a() 及其子调用执行时运行。

你可以这样做:

<script>
    a(); 
</script>
<script>
    looper('delay',500); // ie; less than the 1000 timeout in a();
</script>
<script>
    console.log('OK');
</script>

尽管 a() 及其子项仍在执行,但您仍希望日志中出现“OK”。您还可以测试它的变体,例如 window.onload() 等。

最后,您要确保其他计时器事件也能正常工作。只需将 2 个调用延迟半秒并检查它们是否交错应该表明工作正常:

function b()
{
    console.log("invoked b")
    setTimeout(b, 1000);
}

a();
looper('wait',500);
b();

应该产生像这样的输出

invoked
invoked b
invoked
invoked b
invoked
invoked b

希望这就是你要找的!

编辑以防您需要有关如何在 Qunit 中执行此操作的一些技术细节:

如果 Qunit 无法捕获 console.log 输出(我不确定),只需将这些字符串推送到数组或字符串中并在运行后检查。您可以在 test module() 设置中覆盖 console.log 并在拆卸时恢复它。我不确定 Qunit 是如何工作的,但可能必须删除“this”,并使用全局变量来存储 old_console_log 和 test_output

// in the setup
this.old_console_log = console.log;
this.test_output = [];
var self = this;
console.log = function(text) { self.test_output.push(text); }

// in the teardown
console.log = this.old_console_log;

最后,您可以使用 stop() 和 start() 以便 Qunit 知道等待测试中的所有事件完成运行。

stop();
kickoff_async_test();
setTimeout(function(){
    // assertions
    start();
    },<expected duration of run>);
于 2012-10-12T05:07:38.797 回答