【问题标题】:How to write a unit test for typed class?如何为类型化类编写单元测试?
【发布时间】:2019-12-22 20:41:08
【问题描述】:

我一直在学习使用 Jest 库编写 JavaScript/TypeScript 代码的单元测试。这是一个我不知道如何处理的例子。它是用 TypeScript 输入的——只有两个公共方法和一个需要 service1 参数的构造函数。

我觉得需要测试两种情况:

  • 如果 this.attr is <= 42 发生增量,

  • 如果 this.attr is > 42 和方法 end() 触发。

我的问题是:

  • 我无法访问 attr 属性,它是私有的,我不知道如何为其分配任何值(可能在测试中创建实例时,但不知道如何)

  • 我不知道this.service1.get() 函数是什么。我没有在代码中看到它的任何实现,也不知道它是如何工作的。我应该将它作为参数传递给此类的实例吗?

  • 在这个特定的示例中我应该使用 fakeTimers 还是 mock/spy 感到困惑?

export class Class4 {
    private attr: number;
    private intervalId;

    constructor(private service1) { }

    public method() {
        this.intervalId = setInterval(() => {
            if (this.service1.get() > 42) {
                this.end()
            } else {
                this.attr++;
            }
        }, 100);
    }

    public getAttr() {
        return this.attr;
    }

    private end() {
        clearInterval(this.intervalId);
    }
}

在我描述的 2 种情况下,我需要你的帮助来编写 Jest 测试。

编辑。 这是基于此类的简单测试。它没有分配this.attr 的值(尽管我的参数的值被分配给service1)并且在运行测试后我收到一条错误消息

Expected: 40 Received: undefined

代码:

    it('should stop incrementing Class4.attr if it\'s > 42', () => {
        const class4 = new Class4(40);
        const attrVal = class4.getAttr();
        expect(attrVal).toBe(40);
    });

【问题讨论】:

  • 示例看起来不完整和/或测试没有意义,因为增量取决于this.service1.get(),而不是this.attr。也就是说,关于您的问题,1)您可以访问 this.attr 属性,因为您有 getAttr getter,2)您肯定需要一个服务实例来调用 Class4 的构造函数 2)我认为您不需要这些您描述的测试,但了解更多细节会有所帮助。
  • 我也认为它可能不完整,但我确信它已经足够了。如果可以仅对其进行部分测试,那也将是我问题的一个很好的答案。 1)但是在这种情况下如何给 this.attr 赋值呢?我会用简单的测试来更新我的问题,说 attr 是未定义的,我找不到为它分配值的方法。
  • 不能赋值 attr,attr 是如果 service1.get() 返回小于 42 时每 100ms 递增的值。
  • 我刚刚意识到您收到 undefined 的可能性很大,因为您从未调用过 method,因此 attr 从未增加。
  • 这实际上可能是真的,但主要的是之前没有为 attr 分配值。它只是声明和输入private attr: number;,所以调用method返回undefined

标签: javascript typescript unit-testing jestjs


【解决方案1】:

我不太确定这会有所帮助,但下面是一个示例,说明如何使用 Jest 来测试类似的东西。

这是您的代码从 typescript 转换为 es6 并附加了一个轻假的 Jest 实现。 它位于一个单独的脚本中,以单独保留示例本身。

伪造的 Jest 只实现了本次测试所需的 Jest 匹配器:expecttoBeGreaterThannottoHaveBeenCalledTimes

以及以下 Jest 实用程序:useFakeTimersadvanceTimersByTimeclearAllTimersmock

// self calling function is required to simulate Class4 module and for fake Jest mock to work
(function() {
// translated from typescript to es6
class Class4 {
    attr = 0;

    intervalId = null;

    constructor(service1) {
        this.service1 = service1;
    }

    method() {
        this.intervalId = setInterval(() => {
            if (this.service1.get() > 42) {
                this.end();
            } else {
                this.attr++;
            }
        }, 100);
    }

    getAttr() {
        return this.attr;
    }

    end() {
        clearInterval(this.intervalId);
    }
}
// this is required to simulate Class4 module and for fake Jest mock to work
window.Class4 = Class4;
})();

// even if we do not know exactly what Service is,
// we know that it has a get method which returns a varying number.
// so this implementation of Service will do
// (it's ok since we're testing Class4, not Service)
class ServiceImpl {
    v = 0;
    set(v) { this.v = v; }
    get() { return this.v; }
}

// after this call, jest will control the flow of
// time in the following tests
// (reimplements the global methods setInterval, setTimeout...etc)
jest.useFakeTimers();

// actually it should be jest.mock('<path to your module>')
// but remember we're using a fake Jest working in SO's snippet)
// now Class4 is a mock
jest.mock(Class4);

// we need a Service instance for a Class4 object to be instanciated
const service = new ServiceImpl();

const class4 = new Class4(service);

it('Class4 constructor has been called 1 time', () => {
    expect(Class4).toHaveBeenCalledTimes(1);
});

it('should be incrementing Class4.attr if service.get() < 42', () => {
    // service.get() will return 40
    service.set(40);

    // storing initial attr val
    let lastAttrVal = class4.getAttr();

    // now class4 is running and should be incrementing
    class4.method();

    // jest controls the time, advances time by 1 second
    jest.advanceTimersByTime(1000);

    expect(class4.getAttr()).toBeGreaterThan(lastAttrVal);
});

it('should have been called Class4.end 0 time', () => {
    expect(Class4.mock.instances[0].end).toHaveBeenCalledTimes(0);
});

it('should stop incrementing Class4.attr if service.get() > 42', () => {
    // service.get() will now return 45, this should end class4
    // incrementation in the next interval
    service.set(45);

    // storing current attr val
    let lastAttrVal = class4.getAttr();

    jest.advanceTimersByTime(1000);

    expect(class4.getAttr()).not.toBeGreaterThan(lastAttrVal);

});

it('end should have been called end 1 time', () => {
    expect(Class4.mock.instances[0].end).toHaveBeenCalledTimes(1);
});

jest.clearAllTimers();
<script type="text/javascript">
window.jest = {};
jest.useFakeTimers = () => {
    jest.oldSetTimeout = window.setTimeout;
    jest.oldSetInterval = window.setInterval;
    jest.oldClearTimeout = window.clearTimeout;
    jest.oldClearInterval = window.clearInterval;
    jest.time = 0;
    jest.runningIntervals = [];
    window.setInterval = (callback, delay) => {
        let interIndex = jest.runningIntervals.findIndex(i => i.cancelled);
        let inter = interIndex !== -1 && jest.runningIntervals[interIndex];
        if (!inter) {
            inter = {};
            interIndex = jest.runningIntervals.length;
            jest.runningIntervals.push(inter);
        }
        Object.assign(
            inter,
            {
                start: jest.time,
                last: jest.time,
                callback,
                delay,
                cancelled: false
            }
        );
        callback();
        return interIndex;
    };
    window.clearInterval = idx => {
        jest.runningIntervals[idx].cancelled = true;
    };
    jest.advanceTimersByTime = advance => {
        for (const end = jest.time + advance;jest.time < end; jest.time++) {
            jest.runningIntervals.forEach(inter => {
                if (!inter.cancelled && jest.time - inter.last >= inter.delay) {
                    inter.last = jest.time;
                    inter.callback();
                }
            });
        }
    };
    jest.clearAllTimers = () => {
        jest.runningIntervals.length = 0;
        window.setTimeout = jest.oldSetTimeout;
        window.setInterval = jest.oldSetInterval;
        window.clearTimeout = jest.oldClearTimeout;
        window.clearInterval = jest.oldClearInterval;
    };
};

jest.resolve = (v) => {
  console.log(v ? 'PASS' : 'FAIL');
}
window.it = (description, test) => {
    console.log(description);
    test();
};
window.expect = (received) => {
  return {
    toBeGreaterThan: (expected) => jest.resolve(received > expected),
    not: {
      toBeGreaterThan: (expected) => jest.resolve(received <= expected),
    },
    toHaveBeenCalledTimes: (expected) => jest.resolve((received ? received.mock.calls.length : 0) === expected),
  }
}
jest.mock = (cls) => {
    if (cls.mock) return;
    const mock = {
        instances: [],
        calls: []
    }
    const proto0 = cls.prototype;

    function ClassMock(...args) {
        mock.calls.push(args);
        
        this.instance = new proto0.constructor(...args);
        this.instanceMock = {};
        mock.instances.push(this.instanceMock);
        Object.getOwnPropertyNames(proto0).forEach((member) => {
          if (member === 'constructor' || typeof proto0[member] !== 'function') return;
          this.instanceMock[member] = this.instanceMock[member] || { mock: { calls: [] } };
          this.instance[member] = (function(...args) {
              this.instanceMock[member].mock.calls.push(args);
              return proto0[member].apply(this.instance, [args]);
          }).bind(this);
      });
    }

    Object.getOwnPropertyNames(proto0).forEach((member) => {
        if (member === 'constructor' || typeof proto0[member] !== 'function') return;
        ClassMock.prototype[member] = function(...args) {
            return this.instance[member](...args);
        }
    });
    
    
    ClassMock.mock = mock;
    window[proto0.constructor.name] = ClassMock;
}
</script>

【讨论】:

  • 非常感谢,解释的很好!我能想到的 - 是否可以在不从 TypeScript 迁移的情况下对其进行测试?是否有可能以某种方式监视method() 并检查它是否最终调用end() 方法?我试图对其进行模拟,但由于它是私有方法,我不允许,但也许有正确的方法?
  • Aaah 在这里,我们正在进入“我们应该测试私有方法吗?”的微妙辩论。如果 1)我们不应该:只需删除对被调用的私有方法的测试。如果 2)您的私有方法的开发者,您可以将其定义为 protected 并在同一个包中创建测试。如果 3)您不希望任何人告诉您该怎么做:在测试之前预处理 typescript 源以用公共修饰符替换私有修饰符
  • 您可能会发现此讨论很有帮助:stackoverflow.com/questions/35987055/…
  • 附带说明:编译后的 typescript 可能无法真正阻止私有成员访问(因为在一天结束时 typescript 被转译为 es5,这将改变运行时行为)。因此,在 es6 中编写测试并导入已编译的打字稿可能会成功。
  • 我明白了。你很有帮助。但是仍然 - 如果不将 0 (或任何其他实际数字)分配给 attr (因此不修改给定的类),它将无法工作。因为method 找不到要递增的分配数字,而getAttr 将返回未定义(attr 仅声明但没有值)。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多