随着技术的发展,软件开发方法不断演进,测试一直都是不可或缺的一步。作为提升用户体验、保障软件质量的关键环节,软件测试至关重要。特别是面对多样化的测试需求、不断加快的版本迭代速度,如何围绕业务功能需求搭建适合其特点且快速、高效的软件测试体系、框架和流程,FreeWheel 核心业务团队对此进行了深入的探索和实践。团队将测试中具有共性的模块进行抽象和提取,形成了自己的“测试之道”,为产品质量提供强有力的保障。
架构演进带来的测试挑战
在项目初期,核心业务团队采取的是基于 Ruby-Rails 的单体架构,如上图左侧所示,主要包括前端、中间业务逻辑层和数据库层三层结构。随着项目规模的扩大,越来越多的人加入了开发团队,代码量也在与日俱增,单体架构的缺点也随之开始显现:复杂性高,模块之间相互依赖;项目部署速度变慢,花费的时间不断增长;阻碍了技术创新,系统扩展能力受限。
基于以上原因,核心业务团队决定改变技术架构,逐步抛弃构建单一、庞大的单体式应用,转而采用微服务架构,如上图所示。微服务架构将单体架构的中间层分解,拆分成多个可独立设计、开发、运行的小应用,各个小应用之间协作通信,为用户提供最终服务。此外,将服务部署在 AWS 上,这些调整都有效地消除了痛点。
与此同时,微服务架构的使用也给我们的测试带来了新的挑战,除了要验证各个微服务的功能是否正常之外,还需要考虑如下问题:
- 如何测试微服务之间的依赖是否正常;
- 在微服务架构下如何验证整个系统的功能是否符合预期;
- 如何有效的进行多个微服务的部署和测试。
新的测试需求需要新的测试策略来满足,下文首先会对软件测试进行简单的介绍,然后会介绍核心业务团队对这些需求的应对良策。
软件测试概述
软件测试是使用人工或者自动化手段来鉴定软件的功能或性能是否满足开发之前提出的需求的一个过程。通过软件测试可以及时发现问题、解决问题,提高软件质量,降低因软件问题带来的商业风险,提升用户体验。
按照不同分类标准,软件测试可划分为不同的类型,下图列举了一些常见的软件测试分类。
下表对几种常见的测试做了较为详细的比较。
基于此,核心业务团队按照产品的研发阶段分别对单元测试、集成测试、端到端测试和性能测试进行了实践并总结了方法,下文将进行详细介绍。
FreeWheel 核心业务团队测试实践
测试框架
单体架构时,团队的测试主要依赖基于 Selenium 的集成测试和后检查测试,基于 Rails 的单元测试略有欠缺。转到微服务架构后,为了满足新的需求,测试框架也进行了相应调整。上图是目前核心业务团队的测试金字塔,它可以很好地帮助我们区分不同层次测试的关注点。金字塔从下层到顶层依次为单元测试、集成测试、端到端测试和性能测试。其中,越靠近金字塔的底端,一般而言测试速度越快,反馈周期也越短,测试发现问题后更容易定位受影响的功能;越是靠近金字塔的顶端,测试覆盖的范围越大,但需要花费更长时间完成测试,经过测试后功能的正确性也更有保证。下面,分别介绍 FreeWheel 核心业务团队在每一类测试上的具体实践。
单元测试
“单元”是软件的最小可测试部件。单元测试就是软件开发中对最小单元进行正确性检验的测试,它是所有测试中最底层的一类测试,由开发人员在开发代码时同步编写,是第一个也是最重要的一个环节。
团队后端开发使用的语言是 Go,Go 语言自带有一个轻量级的测试框架 testing,可使用自带的 go test 命令进行单元测试。同时,我们使用了 TDD,即在开发功能代码之前,先编写单元测试用例,以测试代码来确定需要编写的产品代码,提高代码质量。
Mock 实践
单元测试的编写往往有独立性的要求,很多时候因为业务逻辑复杂,代码逻辑也随之变的复杂,掺杂了很多其他组件,导致在编写单元测试用例时存在比较复杂的依赖项,如数据库环境、网络环境等,这些增加了单元测试的复杂度和工作量。
Mock 对象就是为解决上述问题而诞生的,mock 对象能够模拟实际依赖对象的功能,同时又省去了复杂的依赖准备工作。当前,在核心业务团队 Go 代码库中,存在 2 种 mock 实践。一种是和 mockery 结合使用的 Testify/mock,另一种是和 mockgen 结合使用的 Go/gomock。
- Testify/Mock
Testify 包中一个优秀的功能就是它的 mock 功能,在进行单元测试时,代码中往往有大量的方法和函数需要模拟,此时 vertra/mockery 就成为了我们的得力助手,mockery 的二进制文件可以找到任何在 Go 中定义的 interfaces 的名字,然后自动生成模拟对象到 mocks 文件夹下对应的文件中。
- Golang/mock
Gomock 是 Google 开源的 golang 测试框架,gomock 通过 mockgen 命令生成包含 mock 对象的 .go 文件,它可以根据给定的接口自动生成代码。这里给定的接口有两种方式:接口文件和实现文件。
如果存在接口文件,可通过 -source 参数指定接口文件,-source 指定生成的文件名,-package 指定生成文件的包名。例如:
代码语言:javascript复制mockgen -destination foo/mock_foo.go -package foo -source foo/foo.go
如果没有使用 -source 指定接口文件,mockgen 也支持通过反射方式找到对应的接口,它通过两个非标志参数生效:导入路径和用逗号分隔的符号列表。例如:
代码语言:javascript复制mockgen database/sql/driver Conn,Driver
此外,如果存在分散在不同位置的多个文件,为避免执行多次 mockgen 命令生成 mock 文件,mockgen 提供了一种通过注释生成 mock 文件的方式,这需要借助 go 的“go generate”工具来实现。例如,在接口文件中添加如下注释:
代码语言:javascript复制//go:generate mockgen -source=foo.go -destination=./gomocks/foo.go -package=gomocks
测试用例
下面举例介绍 mock 对象在单元测试用例的使用:
- 生成的 mock 文件
type NetworkDao struct {
mock.Mock
}
// GetNetworkById provides a mock function with given fields: networkId
func (_m *NetworkDao) GetNetworkById(networkId int64) (*business.Network, error) {
ret := _m.Called(networkId)
// ... some mock code ...
return r0, r1
}
- 被测试文件
type dataRightDomain struct {
networkDao NetworkDao // use NetworkDomain in the mock code instead NetworkDomain interface
}
func (domain *dataRightDomain) GetDataRightWhitelist(all bool, searchQuery *types.SearchQuery) ([]*business.WhitelistItem, int32, error) {
// ... some code ...
partner, err := domain.networkDao.GetNetworkById(item.Id) // get return values from ExpectedCalls array in mock when using mock
// ... some code ...
}
- 测试文件
func TestGetDataRightWhitelist(t *testing.T) {
// ... some code ...
networkDaoMock:= &mock. NetworkDao {}
networkDaoMock.On("GetNetworkById", mock2.Anything).Return(nwRet, nil) // set up ExpectedCalls array in mock
wItems, number, err := dataRightDomain.GetDataRightWhitelist(true, searchQuery) // call GetDataRightWhitelist where networkDao is replaced by mocked one
// ... some code ...
}
通过单元测试,核心业务团队达到了以下目标:
- 确保每个功能函数可运行,并保证结果正确;
- 确保代码性能最佳;
- 及时发现程序设计或实现的逻辑错误,使问题及早暴露,便于定位和解决。
集成测试
集成测试在单元测试完成后进行,它将多个代码单元以及所有集成服务(如数据库等)组合在一起,测试它们之间的接口正确性。随着核心业务团队转向微服务架构的步伐加快,构建的 Go 服务越来越多,为此我们设计了适用于不同服务的集成测试用例,在构建新服务时可以最大限度地减少学习和测试成本。下图描绘了我们的集成测试流程,主要包括四个阶段:准备测试数据、准备测试环境、执行测试用例、生成测试报告。
测试数据准备
在测试数据准备阶段,具体策略如下:
使用一个主数据库作为运行服务的基础数据,在所有测试用例开始执行前,从主数据库中下载测试所需要的数据表,保存成临时 SQL 文件。如果某些测试用例需要将数据恢复到初始状态,可使用临时 SQL 文件刷新数据库。在所有测试用例执行完成后,再将所有数据刷回初始状态。这种做法和共享测试数据库相比,具有如下优势:
- 每个测试用例都将拥有独享的数据,避免了由于共享数据库中数据更改而出现的错误。
- 数据刷新 SQL 的量很小,因为仅需刷新与测试用例具体相关的数据表。
- 公用数据将得到更严格的管理。它将提供一个具有更好数据多样性的数据存储,以满足测试需求。
主流 Go 测试框架有 3 个:Ginkgo,GoConvey,Godog,其中,GoDog 支持 Gherkin 语法,容易上手, 所以我们选择使用 GoDog 编写集成测试用例。此外,现有的测试用例集也可以确保代码的修改没有引入新的错误或导致其他代码产生错误,起到了回归测试的功能。
端到端测试
端到端测试是站在用户使用视角进行的测试,它将要测试的软件视为黑盒,无需了解其内部具体实现细节,只需关注输出结果是否符合预期。
在核心业务团队的微服务架构中,端到端测试环节具有更广的范围和更高的地位,是确保整个产品线质量的最后一道防线。在以前的单体架构中,我们采用了 Cucumber 和 Selenium 的组合进行端到端测试,但这种测试框架逐渐暴露出许多问题,并且不适用于微服务架构。为了更好地在当前的微服务架构下实施端到端测试,我们对 Cypress 和 Selenium 进行了比较分析。
核心业务团队基于以上分析结果并结合业务需要,实现了一个新的基于 Cypress 的端到端测试框架,可以同时支持 Web UI 和 API 的自动化测试。
Cypress-fixtures
由上图可以看出,在核心业务团队标准的开发测试流程中,至少有三个阶段需要进行端到端测试:
- 本地测试:当代码位于自定义分支中尚未合并到主分支时,需进行端到端本地测试,开发人员添加新的端到端测试用例来完成功能检测。
- 回归测试:功能代码合并到主分支后,需进行端到端回归测试。该测试 CI 通常在夜间运行,并触发范围更大的端到端测试用例,以帮助开发人员查找新功能的潜在影响。
- 后检查测试:该功能发布到线上环境之后,需进行端到端后检查测试,以确保该功能在线上环境仍能按预期工作。
基于上述情况,为了最大化端到端测试用例的可重用性,并考虑到构建本地 E2E 环境的复杂性,我们将 fixtures 添加到我们的测试流程中。Fixtures 是在软件测试过程中,为测试用例创建其所依赖的前置条件的操作或脚本,这些前置条件通常会根据不同的端到端测试环境而变化。
例如,假设现有一测试场景:检查一个特定订单的状态,而订单编号在线上环境和开发环境中可能有所不同,而且除了订单编号,和订单相关的一些其它信息也不同,此时就可以使用 fixtures。
Cypress-tag
在将 fixtures 用于每个测试流程之后,还需考虑一种情形,即不同的环境下需要运行的测试用例可能不同。对于线上环境的后检查测试,需要运行最高级别的 P1 测试用例;对于日常端到端回归测试,需要运行一些更大范围的测试用例。为满足此要求,核心业务团队为 Cypress 添加了标签功能,以对测试用例进行分类。
Cypress 测试用例
下面通过例子简单说明 fixtures 和 tag 在 cypress 测试用例中的使用。
代码语言:javascript复制//fixture用来表明是在什么环境下执行测试用例
const fixture = {
prd: {
networkInfo: Cypress.env('prdTestNetWorkInfo'),
campaignId: 26341381
},
stg: {
networkInfo: Cypress.env('stgTestNetWorkInfo'),
campaignId: 26341381
},
dev: {
networkInfo: Cypress.env('localNetWorkInfo'),
campaignId: 133469319
}
};
const { networkInfo, campaignId, brandId } = fixture[Cypress.env('TEST_ENV')];
let insertionOrderId;
//tag用来表明这是一个P1的测试用例
Cypress.withTags(describe, [io, create', 'p1'])(
'Create IO', function() {
before(function() {
cy
.loginByUI(networkInfo, `/campaigns/${campaignId}/edit `)
.waitUntilLoaded();
});
it('Create an empty insertion Order', function() {
cy.createEmptyIO()
.get('{insertion_order_id}:eq(0)')
.invoke('text')
.then(ioId => {
insertionOrderId = ioId;
});
});
…
after(function() {
cy.deleteInsertionOrder({ insertionOrderId });
});
afterEach(function() {
cy.saveResult(this.currentTest, testOwner);
if (this.currentTest.state === 'failed') {
Cypress.runner.stop();
}
});
});
通过使用 Cypress 进行端到端测试,我们实现了以下目标:
- 替换消耗性第三方工具(如 Selenium),大大减少了准备和运行端到端测试用例所需的时间;
- 一次编写测试用例,通过使用 fixture 可实现在不同的环境(线上 / 本地开发)中运行;
- 可重用的自定义命令使开发人员可以快速完成测试用例;
- 简短易用的测试报告包括视频报告,可快速调试失败的测试用例;
- 设置独立的测试管道和测试标签,以确保每个组件仅考虑自己的情况。
性能测试
性能测试是指通过自动化的测试工具模拟多种正常、峰值以及异常负载条件来对系统的各项性能指标进行测试,主要使用 Loadrunner、JMeter 等工具对软件进行压力测试、负载测试、强度测试等,因为这些测试过程难以用手工代替,所以必须自动化。核心业务团队选择了 JMeter 作为测试工具,并使用 Taurus 来运行 JMeter。Taurus 能够直接解析原生脚本,如 JMeter JMX 文件,同时还支持使用简单配置语法将测试场景使用 YAML 或 JSON 来描述 JMeter 脚本。
Taurus Yaml 脚本例子:
代码语言:javascript复制execution:
- concurrency: 1 //并发线程数
iterations: 1 //执行次数限制
ramp-up: 5s //启动时间
hold-for: 30s //持续时间
scenario: get-rfps //测试场景
modules:
jmeter:
properties:
host: docker.for.mac.localhost
port: 3070
scenarios: //测试case描述
get-rfps:
headers:
…//some code…
variables:
…//some code…
requests:
- url: http://${__P(host)}:${__P(port)}/.../rfps
method: GET
reporting:
- module: console
- module: final-stats
percentiles: true //显示平均时间和百分比
test-duration: true //测试时间
dump-csv: perf_result_csv.csv
- module: junit-xml
filename: junit-result.xml
settings:
artifacts-dir: TaurusResult
同时我们创建 Jenkins pipeline 并定期进行测试,生成 performance trend 报告,如下图所示:
通过性能测试,核心业务团队达到了以下目标:
- 关注负载测试,检查应用程序在预期用户负载下运行的能力,以在应用程序投入使用前确定其性能瓶颈;
- 提供一种观察应用程序性能趋势的方法;
- 统一并简化性能测试的实现和运行。
测试自动化
为了提高开发效率,及早发现问题,减少重复性劳动,实现测试自动化,核心业务团队集成了 Jenkins,采用 Jenkins Pipeline 的方式进行 CI/CD。
CI 阶段测试
CI 测试的触发点一般有两个:
- 代码合并到主干前,触发 CI 测试,各种检查和测试通过之后,代码才允许被合并到主干分支;
- 代码合并到主干后,触发 CI 测试,目的是为了检验主干分支是否符合质量预期。
上图是由 pipeline groovy 脚本定义的 Jenkins 流水线 blue ocean 效果图,下面将结合例子对测试相关的几个重要阶段进行分析。
- UT& Coverage
在此阶段我们可以获取单元测试覆盖率报告。测试覆盖率的报告获取很简单,只需在 steps 中指定跑单元测试使用的脚本,并在脚本中把生成覆盖率的开关打开,将生成的结果输出到文件中。
代码语言:javascript复制stage('UT & Coverage'){
…//some code…
environment {
core_common = get_core_common(serviceFullName)
//获取ut测试覆盖率报告
ut_cobertura_report_file = get_ut_cobertura_report_file(serviceFullName)
}
steps {
//specify shell script to execute ut cases
sh(returnStdout: true, script: "sh ${WORKSPACE}/shell_scripts/unit_coverage.sh")
}
post {
success {
//如果成功,生成ut测试覆盖率HTML格式的报告
archiveArtifacts allowEmptyArchive: true, artifacts: ut_cobertura_report_file, fingerprint: true
sh 'echo "ci.ut.result=PASS" >> ${WORKSPACE}/env.props'
}
}
}
- Regression
在此阶段我们可以获取 regression 测试覆盖率报告。测试覆盖率的报告获取很简单,只需在 steps 中指定跑 regression 使用的脚本,将生成的结果输出到文件中。
代码语言:javascript复制stage('Regression'){
environment {
…//some code…
html_report_dir = get_report_dir(serviceFullName)
//获取regression测试覆盖率报告
regression_cobertura_report_file = get_regression_cobertura_report_file(serviceFullName)
diff_files = "${WORKSPACE}/diff/*"
}
steps {
sh '''
mysql -uroot -proot -h127.0.0.1 -e "source ${WORKSPACE}/sql/ui_permission_sql.sql"
//regression 测试数据准备
${WORKSPACE}/integration_test_data/bin/initDB.sh
//regression 测试环境准备
${WORKSPACE}/shell_scripts/regression_init.sh
if [[ ! -d ${html_report_dir} ]]; then
mkdir ${html_report_dir}
fi
'''
//指定regression 测试用例的执行脚本
sh "${WORKSPACE}/regression_scripts/${serviceFullName}_regression.sh"
}
post {
success {
…//some code…
}
}
}
- Coverage& Analyze
为了保证测试的高质量和高覆盖率,我们通过 Groovy 脚本设置了测试覆盖率的目标,测试结果失败或者覆盖率没有达标的合并代码请求均不能通过,并且会通过 slack 通知相关人员。
代码语言:javascript复制stage('Coverage & Analyze'){
…//some code…
post {
success {
//判断是否达到测试覆盖率目标
cobertura autoUpdateHealth: false, autoUpdateStability: false, coberturaReportFile: combined_cobertura_report_file, conditionalCoverageTargets: '70, 0, 0', failUnhealthy: false, failUnstable: false, lineCoverageTargets: '80, 0, 0', maxNumberOfBuilds: 0, methodCoverageTargets: '80, 0, 0', onlyStable: false, sourceEncoding: 'ASCII', zoomCoverageChart: false
archiveArtifacts allowEmptyArchive: true, artifacts: "${html_report_dir}/*.json", fingerprint: true
}
}
}
post {
failure {
//如果没有达到,则通过slack发送信息通知相关人员
slackSend channel: "#${slack_channel}",color: "danger", message: "AWS Build FAIL! :bomb: ${serviceFullName} <${BUILD_URL}|${BUILD_DISPLAY_NAME}> ${currentBuild.description}"
}
}
同时,我们也会收集单元测试和集成测试的测试覆盖率并通过邮件发送通知,也起到督促和警示作用。
CD 阶段测试
产品被部署到线上之后,通过 Pipeline 关联触发功能触发端到端测试的 Jenkins job,进行产品上线之后的相关测试。
- 端到端测试
Cypress 支持和 Jenkins 进行集成,我们设置了不同的 Jenkins job,有的用来进行日常的端到端回归测试,有的用来进行线上环境的端到端测试,并通过 groovy 脚本设置将测试结果同时通过邮件和 slack 发出,极大的降低了出错测试用例的响应时间,提高了产品质量。
代码语言:javascript复制pipeline {
…//some code…
post {
success {
publishReport()
notifySuccess()
sendMail()
}
//如果端到端测试用例失败,则发送邮件和slack信息通知相关人员
failure {
publishReport()
archiveArtifacts artifacts: 'screenshots/**, videos/**'
notifyFail()
sendMail()
}
}
}
测试数据准备
- Bug Bash
核心业务团队有一个很有趣的特色传统活动:在产品上线前的某个特定时间点,会组织跨 team 的大型找 bug 活动,邀请大家一起对产品进行测试,并依据找出 Bug 数量的多少进行评比和奖励。一方面可以增加大家的业务知识,在测试时学习自身工作范畴之外的其它功能模块,另一方面也可以提前发现隐藏的 bug,进一步提高产品质量。
- Bug Bash Tool
Bug bash tool 专门服务于我们的 Bug Bash 活动,用来统计每个人发现的 bug 数量。如图所示,根据不同的指标对发现的 bug 进行统计,并通过对这些数据的分析提炼出一些有助于提高产品质量的方法。对于名列前茅的 bug finder,团队会给予丰厚的奖励,提高大家找 bug 的积极性。
未来展望
随着 FreeWheel 业务范围的不断扩大和业务复杂度的持续提升,软件测试也需要不断完善以保证产品的高质量。例如,进一步提升单元测试和集成测试的代码覆盖率,推广基于 Cypress 的端到端测试和基于 Jmeter 的性能测试。此外,团队还将根据业务发展需求,持续开展前沿调研和技术创新,以更高效、全面、先进的软件测试手段为产品质量保驾护航。