likes
comments
collection
share

JavaScript测试。需要学习的9个最佳实践

作者站长头像
站长
· 阅读数 1

许多开发人员不喜欢测试,但它是软件工程的一个重要方面,直接影响到代码质量。不稳定的测试不会帮助你在写代码时捕捉到错误,这就违背了测试的全部目的。

除此之外,测试还可以作为其他开发者的文档。通过阅读你所创建的测试,他们应该对你所开发的代码的目的有一个很好的理解。

本文放大了JavaScript测试的九个最佳实践,可以帮助你写出更好的测试,并帮助你的团队更好地理解你所创建的测试。我们将专注于三个具体的要素。

  1. 测试解剖和测试描述
  2. 单元测试的反模式
  3. 测试准备

让我们开始吧!

1.测试剖析和测试描述

本节探讨了如何改进你的测试解剖和测试描述。目的是提高你的测试文件的可读性,以便开发人员能够快速扫描它们,找到他们想要的信息。

例如,他们已经更新了一个功能,想了解哪些测试需要改变。你可以通过对你的测试应用结构和编写有思想的测试描述来真正帮助他们。

1.1 - 用AAA模式构造测试

一开始,AAA模式可能会告诉你什么 - 所以让我们来澄清一下AAA模式代表安排行动,和断言。你想把测试中的逻辑分成三部分,使它们更容易理解。

安排 "部分包括所有的设置代码和测试数据,你需要模拟一个测试场景。其次,正如其名称所暗示的,"行动 "部分执行单元测试。通常情况下,测试执行只包括一两行代码。最后,"断言 "部分对所有断言进行分组,将收到的输出与预期输出进行比较。

这里有一个例子可以证明这一点。

it('should resolve with "true" when block is forged by correct delegate', async () => {
    // Arrange
    const block = {
        height: 302,
        timestamp: 23450,
        generatorPublicKey: '6fb2e0882cd9d895e1e441b9f9be7f98e877aa0a16ae230ee5caceb7a1b896ae',
    };

    // Act
    const result = await dpos.verifyBlockForger(block);

    // Assert
    expect(result).toBeTrue();
});

如果你将上面的测试结构与下面的例子进行比较,很明显哪个更具有可读性。你必须花更多的时间来阅读下面的测试,以弄清楚它是做什么的,而上面的方法让你直观地看到测试的结构。

it('should resolve with "true" when block is forged by correct delegate', async () => {
    const block = {
        height: 302,
        timestamp: 23450,
        generatorPublicKey: '6fb2e0882cd9d895e1e441b9f9be7f98e877aa0a16ae230ee5caceb7a1b896ae',
    };
    const result = await dpos.verifyBlockForger(block);
    expect(result).toBeTrue();
});

1.2 - 使用3层系统编写详细的测试描述

编写详细的测试描述听起来很容易,然而有一个系统你可以应用,使测试描述更加简单易懂。我建议使用三层系统来构造测试。

  • 第1层:你要测试的单元,或测试需求
  • 第二层:你想测试的具体行动或场景
  • 第三层:描述预期结果

下面是一个写测试描述的三层系统的例子。在这个例子中,我们将测试一个处理订单的服务。

在这里,我们要验证向购物篮添加新物品的功能是否如预期那样工作。因此,我们写下两个 "第3层 "的测试用例,在这里我们描述所期望的结果。这是一个简单的系统,可以提高你的测试的可扫描性。

describe('OrderServcie', () => {
    describe('Add a new item', () => {
        it('When item is already in shopping basket, expect item count to increase', async () => {
            // ...
        });

        it('When item does not exist in shopping basket, expect item count to equal one', async () => {
            // ...
        });
    });
});

2.单元测试的反模式

单元测试对于验证你的业务逻辑至关重要--它们旨在捕捉你代码中的逻辑错误。这是最基本的测试形式,因为你希望在通过E2E测试开始测试组件或应用程序之前,你的逻辑是正确的。

2.1 - 避免测试私有方法

我见过很多开发者测试私有方法的实现细节。如果你可以通过测试公共方法来覆盖它们,你为什么要测试它们?如果实现细节发生变化,而这些细节实际上对你的暴露方法并不重要,你会遇到假阳性,而且你不得不花更多的时间来维护私有方法的测试。

这里有一个例子可以说明这一点。一个私有或内部函数返回一个对象,你也验证这个对象的格式。如果你现在改变了私有函数的返回对象,你的测试将失败,即使实现是正确的。没有要求让用户计算增值税,只要求显示最终价格。尽管如此,我们在这里错误地坚持要测试类的内部结构。

class ProductService {
  // Internal method - change the key name of the object and the test below will fail
  calculateVATAdd(priceWithoutVAT) {
    return { finalPrice: priceWithoutVAT * 1.2 };
  }

  //public method
  getPrice(productId) {
    const desiredProduct = DB.getProduct(productId);
    finalPrice = this.calculateVATAdd(desiredProduct.price).finalPrice;
    return finalPrice;
  }
}

it('When the internal methods get 0 vat, it return 0 response', async () => {
  expect(new ProductService().calculateVATAdd(0).finalPrice).to.equal(0);
});

2.2 - 避免在测试中捕捉错误

我经常看到那些在测试中使用try...catch 语句来捕捉错误的开发者在断言中使用它们。这并不是一个好的方法,因为它为假阳性留下了机会。

如果你在试图测试的函数的逻辑中犯了一个错误,有可能在你期望它抛出一个错误时,该函数并没有抛出一个错误。因此,测试跳过了catch 块,测试通过了--尽管业务逻辑是错误的。

下面是这样一个例子,当你创建一个新产品而不提供产品名称时,预期addNewProduct 函数会抛出一个错误。如果addNewProduct 函数没有抛出错误,你的测试就会通过,因为在try...catch 块之外只有一个断言,验证了该函数被调用的次数。

it('When no product price, it throws error', async () => {
    let expectedError = null;
    try {
        const result = await addNewProduct({ name: 'rollerblades' });
    } catch (error) {
        expect(error.msg).to.equal("No product name");
        errorWeExceptFor = error;
    }
    expect(errorWeExceptFor).toHaveBeenCalledTimes(1)
});

那么,你如何重写这个测试呢?例如,Jest为开发者提供了一个toThrow 函数,你期望函数的调用会抛出一个错误。如果该函数没有抛出错误,则断言失败。

it('When no product price, it throws error', async () => {
    await expect(addNewProduct({ name: 'rollerblades' }))
        .toThrow(AppError)
        .with.property("msg", "No product name");
});

2.3 - 不要模拟所有东西

一些开发者在单元测试中模拟所有的函数调用,因此他们最终测试了if...else 语句。这样的测试是没有价值的,因为你可以相信一种编程语言能够正确地实现if...else 语句。

你应该只模拟底层或最底层的依赖关系和I/O操作,如数据库调用、API调用或对其他服务的调用。这样,你可以测试私有方法的实现细节。

例如,下面的例子说明了一个调用内部方法calculateVATAddgetPrice 函数,它本身调用了一个API与getVATPercentage 。不要模拟calculateVATAdd 这个函数;我们要验证这个函数的实现细节。

因此,我们应该只模拟外部API调用getVATPercentage ,因为我们对这个API返回的结果没有任何控制。

class ProductService {
    // Internal method
    calculateVATAdd(priceWithoutVAT) {
        const vatPercentage = getVATPercentage(); // external API call -> Mock
        const finalprice = priceWithoutVAT * vatPercentage;
        return finalprice;
    }

    //public method
    getPrice(productId) {
        const desiredProduct = DB.getProduct(productId);
        finalPrice = this.calculateVATAdd(desiredProduct.price); // Don't mock this method, we want to verify implementation details
        return finalPrice;
    }
}

2.4 - 使用真实的数据

不是每个开发者都喜欢创建测试数据。但是测试数据应该尽可能的真实,以覆盖尽可能多的应用路径来检测缺陷。因此,存在许多数据生成策略,以转换和屏蔽生产数据,在你的测试中使用它。另一个策略是开发生成随机输入的函数。

简而言之,不要使用典型的foo 输入字符串来测试你的代码。

// Faker class to generate product-specific random data
const name = faker.commerce.productName();
const product = faker.commerce.product();
const number = faker.random.number());

2.5 - 避免每个测试案例有太多的断言

不要害怕拆分场景或写下更具体的测试描述。一个测试用例包含五个以上的断言是一个潜在的红旗;它表明你试图一次验证太多的东西。

换句话说,你的测试描述还不够具体。除此之外,通过编写更具体的测试用例,开发人员在进行代码更新时,会更容易识别需要修改的测试。

提示:使用像faker.js这样的库来帮助你生成真实的测试数据。

3.测试准备

这最后一节描述了测试准备的最佳实践。

3.1 - 避免过多的辅助库

通常,使用辅助库来抽象出很多复杂的设置要求是一件好事。然而,过多的抽象会变得非常混乱,特别是对于那些刚接触测试套件的开发者。

你可能有一个边缘案例,你需要一个不同的设置来完成一个测试场景。现在,创建你的边缘案例设置变得非常困难和混乱。除此之外,抽象出太多的细节可能会使开发人员感到困惑,因为他们不知道在引擎盖下发生了什么。

作为一个经验法则,你希望测试是简单而有趣的。假设你不得不花15分钟以上的时间来弄清楚在beforeEachbeforeAll 钩子的设置过程中,引擎盖下正在发生什么。在这种情况下,你的测试设置就过于复杂了。这可能表明你存根了太多的依赖关系。或者相反:什么都不存根,创造一个非常复杂的测试设置。要注意这一点!

提示:你可以通过让一个新的开发人员弄清楚你的测试套件来衡量这一点。如果花费超过15分钟,说明你的测试设置可能太复杂。记住,测试应该是容易的

3.2 - 不要过度使用测试准备钩子

引入太多的测试准备钩子--beforeAll,beforeEach,afterAll,afterEach, 等等--同时将它们嵌套在describe 块中,就会成为一个实际的混乱,难以理解和调试。下面是Jest文档中的一个例子,说明了其复杂性。

beforeAll(() => console.log('1 - beforeAll'));
afterAll(() => console.log('1 - afterAll'));
beforeEach(() => console.log('1 - beforeEach'));
afterEach(() => console.log('1 - afterEach'));

test('', () => console.log('1 - test'));

describe('Scoped / Nested block', () => {
  beforeAll(() => console.log('2 - beforeAll'));
  afterAll(() => console.log('2 - afterAll'));
  beforeEach(() => console.log('2 - beforeEach'));
  afterEach(() => console.log('2 - afterEach'));
  test('', () => console.log('2 - test'));
});

// 1 - beforeAll
// 1 - beforeEach
// 1 - test
// 1 - afterEach
// 2 - beforeAll
// 1 - beforeEach
// 2 - beforeEach
// 2 - test
// 2 - afterEach
// 1 - afterEach
// 2 - afterAll
// 1 - afterAll

在使用测试准备钩子时要注意。只有当你想为你所有的测试用例引入行为时才使用钩子。最常见的是,钩子被用来启动或关闭进程以运行测试场景。

总结

测试一开始可能看起来很简单,但有许多事情你可以改进,使你和你的同事的测试更有趣。你的目标是保持你的测试易于阅读,易于扫描,易于维护。避免复杂的设置或过多的抽象层,这样会增加测试的复杂性。

通过引入三层系统和AAA模式,你可以大大影响你的测试的质量和可读性。这是一个小的努力,为你的团队回报很多的价值。不要忘记考虑本博文中描述的其他最佳实践。

The postJavaScript testing:要学习的9个最佳实践首次出现在LogRocket博客上。