探索更完整的前端测试策略

497 阅读8分钟

本文假设我们正在持续开发一个相对较大的前端项目,并且我们采用了域驱动的设计以及面向对象的编程模型。前端业务逻辑被划分为域模块/UI组件。因此,我们可能需要一个更完整的测试策略,以确保这样一个前端项目。

无论是传统测试模型还是连续交付模型,我们通常定义以下三种类型的测试:

端到端测试包括确保应用程序的集成组件如预期的功能。在真实的场景中测试整个应用程序,例如与数据库、网络、硬件和其他应用程序通信。

集成测试是软件开发生命周期的关键策略。通常,小型软件系统在单个阶段进行集成和测试,而较大的系统涉及多个集成阶段来构建一个完整的系统,例如将模块集成到低层次的子系统中,以便与较大的子系统集成。集成测试包括软件系统的性能、功能和可靠性的所有方面。

单元测试是一种软件测试方法,通过对源代码的单个单元、一组或多组计算机程序模块以及相关的控制数据、使用程序和操作程序进行测试,以确定它们是否适合使用。

一般的前端测试有什么问题?

当我们评估一个测试策略的完整性时,我们尝试用以下一些标准来验证它:

通过验收标准

尽早捕获bug

更快的运行速度,特别是在顶级测试中

发现错误时有效

测试编写成本和维护成本

持续重构风险

我认为一个更好的测试策略应该尽可能与上面的项目保持一致。

在上述通用测试策略中,E2E可以覆盖更多的AC条件,但通常运行频率较低;运行比E2E更频繁,但它通常包括一个集成的应用程序在几乎整个系统,这通常是更臃肿的在这种情况下,和UT通常占更大比例的这个策略,尽管逻辑覆盖是好的,但是在一个大的重构通常UT也将改变,当然,大多数但不是太大的问题。

从尽早捕获bug的角度来看,这是否意味着通用测试策略可以得到更好的改进?由于E2E测试最有可能在相对较长的时间内运行一次,而不是每次PR甚至每次代码提交,由于E2E运行通常缓慢且不稳定,因此它是运行成本最高的自动化测试。

有时我们有一些集成测试启动了一个庞大的集成系统,也许它已经包含了大量的mock,并且您可以一遍又一遍地运行测试,但是当集成测试的案例越来越多时,我们甚至无法 确保在每个PR中更快地完成,并且在资源受限的CI环境中,这可能需要半小时甚至更长的时间。

随着系统变得越来越复杂,我们需要一个完整的测试策略来告诉我们哪些案例在这些测试中失败了,并使我们能够通过测试报告更有效地捕获错误。无论是网络不稳定、后端服务器api异常、前端域模块异常还是UI组件异常等等,我们都可以快速捕捉到这些bug。显然,通用测试策略在定位错误方面只能提供有限的帮助。例如,UT成功了,它失败了,E2E也失败了。我们很难从这样的测试报告中分析出更清晰的信息。

编写测试代码的成本应该与持续交付开发模型相平衡。当AC定义清楚,只是我们测试代码应该能够使被交流的信息。从理论上讲,如果所有的交流由E2E完全实现,这也将使验证交流。但很明显,这带来了一个高度不平衡测试不稳定和低效的操作。

如果单元测试足够充分,这将确保我们的AC可以被接受并成为可行的吗?这应该取决于单元测试的维护费用,每次我们重构代码,我们将不得不修改这些代码的单元测试,这意味着我们需要一个更高级别的测试,以确保质量和正确性的代码,尽管他们快速变化的性质。

解决问题的关键是什么?

在我们针对某些测试策略提到的问题中,基于我们的ATDD可持续交付发展模型,AC的保证显然是最重要的,一个好的测试策略应确保每个重构都充满信心,而与此同时, 在运行速度,发现错误和维护测试代码成本之间取得良好的平衡。 在这些其他要素中,我们非常不建议您采用极端方式,而应采用类似于利比希定律的方法,以使我们的测试策略更完整。

提出更完整的测试策略

这里的E2E应该实现最重要的AC部分,并且最好支持冒烟测试/UI测试/多浏览器兼容性测试。

IT3是基于模拟服务对整个系统的集成测试,它可以运行E2E代码,但它实际上不启动浏览器进行测试,所有测试都运行在Node.js中。因为它是对后端服务器api和浏览器真正的DOM的模拟,所以它比E2E快,并且可以不断地运行。特别要注意的是,IT3是完全可重用的E2E代码,而上面提到的通用测试策略往往不能重用E2E代码。

IT2是最小的一个集成测试组UI和域的最小设置模块,也是基于模拟服务(包括服务器DOM api / / BOM),因为它是模块开始的最小集合,所以它的测试运行速度和最低的操作性能可以保证,这是比IT3快。同时,它和IT3有一个很好的分区,IT2负责最小集,IT3负责整个集合。除了最小集,编写IT2和IT3不是非常不同,它可以通过交流满足映射关系。当然,除非有一个问题non-dependent模块底部,它实际上是容易通过IT2 / IT3测试报告错误的定位。

IT1只是最小的模块集集成测试,它只需要模拟后端服务器api,因为它只启动最小的域模块集,所以它比IT2运行更快。AC中的一个或多个步骤都可以转换为IT2测试。通过IT1/IT2/IT3的测试报告,也更容易推断出错误的位置或原因。

IT2讨论测试的问题与底层模块(减少模块的依赖或non-dependent模块),我们建议这样的模块适合更完整的UT,尤其是核心功能,其他模块的核心模块,或辅助函数,可以考虑单元测试,这在许多情况下,可以帮助AC覆盖更多的例子。它是IT1/IT2/IT3的重要补充。

在不同的测试类型中,所涵盖的测试因素也不同。然后我们希望可以更完整的测试策略,有效满足测试的各种因素:E2E测试涵盖了几乎所有的测试因素,IT3小于E2E真正的服务器api和浏览器,IT2小于T3许多不必要的模块和UI组件,IT1 UI组件比IT2少,但仅覆盖少数核心逻辑部分。

在这样一个测试策略,我们可以开发一个更好的策略来运行测试代码,我们可以运行ut / it1提交承诺时,我们可以运行公关提交ut / it1 IT2甚至IT3时,或者当我们还定期运行E2E(一周或几天)。在这样一个操作系统中,我们可以保证AC是被验证的,同时也保证了一定程度的操作效率平衡,而不同类型的测试报告也会有助于错误的定位。

如何实现构建这个更完整的测试

业务代码示例:

@Module()
class Foo {
    a() {}
    _x() {
        //No dependent module core logic
    }
}

@Module({ dependences:['Foo'] })
class Bar() {
    b() {}
     _y() {
        //No dependent module core logic
    }
    get name(){}
}

@Module({ dependences:['Foo', 'Bar'] })
class Foobar() {
    c() {}
    get name(){}
}

const store = createStore(
    //...factory module
);

const FoobarContainer = (props) => (
    <div onClick={props.foobar.c}>
        {props.foobar.name}
    </div>
);

const BarContainer = (props) => (
    <div onClick={props.bar.b}>
        {props.bar.name}
    </div>
);

class App extends Component {
    render() {
        return (
        <div>
            {this.props.foobar ? (
                <FoobarContainer {...this.props}>
            ): null }
            {this.props.bar ? (
                <BarContainer {...this.props}>
            ): null }
        </div>
        );
    }
}

render(
    <App store={store} />,
    mountNode
);
Feature: AC

  Scenario Outline:
    Given User saw 'b' node
    When User click 'b'
    Then User should see 'b' changed
    When User click 'f'
    Then User should see 'f' changed

  Scenario Outline:
    Given User saw 'c' node
    When User click 'c'
    Then User should see 'c' changed
    When User click 'e'
    Then User should see 'e' changed
// E2E & IT3
test(() => {
    const app = getApp();
    app.find(nodeSelectorB).click();
    expect(result).toBe(expectedValue1);
    app.find(nodeSelectorF).click();
    expect(result).toBe(expectedValue2);
});

test(() => {
    const app = getApp();
    app.find(nodeSelectorC).click();
    expect(result).toBe(expectedValue1);
    app.find(nodeSelectorE).click();
    expect(result).toBe(expectedValue2);
});

// IT2
test(() => {
    const barContainer = getMinimalSet(BarContainer);
    barContainer.find(nodeSelector).click();
    expect(result).toBe(expectedValue2);
});

test(() => {
    const foobarContainer = getMinimalSet(FoobarContainer);
    foobarContainer.find(nodeSelector).click();
    expect(result).toBe(expectedValue1);
});


// IT1
test(() => {
    const app = getMinimalSet(Bar);
    app.b();
    expect(result).toBe(expectedValue);
});

test(() => {
    const app = getMinimalSet(Foobar);
    app.c();
    expect(result).toBe(expectedValue);
});

// UT
test(() => {
    const result = Foo.prototype._x();
    expect(result).toBe(expectedValue);
});

test(() => {
    const result = Bar.prototype._y();
    expect(result).toBe(expectedValue); 
});

在开发测试策略时,我们需要考虑许多因素。从基于AC的正确验证的角度来看,还应该考虑操作策略、运行效率、编写和维护测试的成本、容易发现的bug和重构保证等重要因素,不能走极端。在确保某个AC的情况下,我们希望这个E2E/IT3/IT2/IT1/UT可以在许多方面得到保证,以保证代码质量和项目工程质量,同时足够敏捷,可以持续交付。

本文使用 mdnice 排版