持续测试
概览
持续测试将自动化测试集成到软件开发生命周期中,每个阶段都能快速获得质量反馈。它将测试向左移动到开发过程中,并确保代码变更在到达生产环境之前自动验证。
何时使用
- 建立CI/CD管道时
- 提交时自动执行测试
- 实施左移测试
- 并行运行测试
- 创建部署的测试门
- 监控测试健康
- 优化测试执行时间
- 建立质量门
关键概念
- 左移:在开发周期早期进行测试
- 测试金字塔:单元 > 集成 > E2E测试
- 并行执行:同时运行测试
- 测试门:晋升前的质量要求
- 不稳定测试:需要修复的不可靠测试
- 测试报告:仪表板和指标
- 测试选择:仅运行受影响的测试
指令
1. GitHub Actions CI管道
# .github/workflows/ci.yml
name: 持续测试
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
env:
NODE_VERSION: '18'
jobs:
# 单元测试 - 快速反馈
unit-tests:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
- name: 设置Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: 安装依赖
run: npm ci
- name: 运行单元测试
run: npm run test:unit -- --coverage
- name: 上传覆盖率
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
flags: unit
- name: 在PR中评论覆盖率
if: github.event_name == 'pull_request'
uses: romeovs/lcov-reporter-action@v0.3.1
with:
lcov-file: ./coverage/lcov.info
github-token: ${{ secrets.GITHUB_TOKEN }}
# 集成测试
integration-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- name: 设置Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: 安装依赖
run: npm ci
- name: 运行迁移
run: npm run db:migrate
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
- name: 运行集成测试
run: npm run test:integration
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
REDIS_URL: redis://localhost:6379
# E2E测试 - 并行运行
e2e-tests:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
- uses: actions/checkout@v3
- name: 设置Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: 安装Playwright浏览器
run: npx playwright install --with-deps chromium
- name: 构建应用
run: npm run build
- name: 运行E2E测试(分片 ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- name: 上传测试结果
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report-${{ matrix.shardIndex }}
path: playwright-report/
retention-days: 7
# 视觉回归测试
visual-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0 # 需要Percy
- name: 设置Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: 安装依赖
run: npm ci
- name: 构建Storybook
run: npm run build-storybook
- name: 运行视觉测试
run: npx percy storybook ./storybook-static
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
# 安全扫描
security-tests:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
- name: 运行npm审计
run: npm audit --audit-level=high
continue-on-error: true
- name: 运行Snyk
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
- name: 运行SAST与Semgrep
uses: returntocorp/semgrep-action@v1
with:
config: >-
p/security-audit
p/owasp-top-ten
# 性能测试
performance-tests:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
timeout-minutes: 15
steps:
- uses: actions/checkout@v3
- name: 设置Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
- name: 安装k6
run: |
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update
sudo apt-get install k6
- name: 运行性能测试
run: k6 run tests/performance/load-test.js
# 质量门 - 所有测试必须通过
quality-gate:
runs-on: ubuntu-latest
needs:
- unit-tests
- integration-tests
- e2e-tests
- security-tests
steps:
- name: 检查测试结果
run: echo "所有质量门已通过!"
- name: 可以部署到暂存环境
if: github.ref == 'refs/heads/develop'
run: echo "准备部署到暂存环境"
- name: 可以部署到生产环境
if: github.ref == 'refs/heads/main'
run: echo "准备部署到生产环境"
2. GitLab CI管道
# .gitlab-ci.yml
stages:
- test
- security
- deploy
variables:
POSTGRES_DB: test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
# 测试模板
.test_template:
image: node:18
cache:
paths:
- node_modules/
before_script:
- npm ci
# 单元测试 - 每次提交都运行
unit-tests:
extends: .test_template
stage: test
script:
- npm run test:unit -- --coverage
coverage: '/Lines\s*:\s*(\d+\.\d+)%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
paths:
- coverage/
# 集成测试
integration-tests:
extends: .test_template
stage: test
services:
- postgres:14
- redis:7
variables:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/test
script:
- npm run db:migrate
- npm run test:integration
# E2E测试 - 并行执行
e2e-tests:
extends: .test_template
stage: test
parallel: 4
script:
- npx playwright install --with-deps chromium
- npm run build
- npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
artifacts:
when: always
paths:
- playwright-report/
expire_in: 7 days
# 安全扫描
security-scan:
stage: security
image: node:18
script:
- npm audit --audit-level=moderate
- npx snyk test --severity-threshold=high
allow_failure: true
# 契约测试
contract-tests:
extends: .test_template
stage: test
script:
- npm run test:pact
- npx pact-broker publish ./pacts \
--consumer-app-version=$CI_COMMIT_SHA \
--broker-base-url=$PACT_BROKER_URL \
--broker-token=$PACT_BROKER_TOKEN
only:
- merge_requests
- main
3. Jenkins管道
// Jenkinsfile
pipeline {
agent any
environment {
NODE_VERSION = '18'
DATABASE_URL = credentials('test-database-url')
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Install Dependencies') {
steps {
sh 'npm ci'
}
}
stage('Parallel Tests') {
parallel {
stage('Unit Tests') {
steps {
sh 'npm run test:unit -- --coverage'
}
post {
always {
junit 'test-results/junit.xml'
publishCoverage adapters: [
coberturaAdapter('coverage/cobertura-coverage.xml')
]
}
}
}
stage('Integration Tests') {
steps {
sh 'npm run test:integration'
}
}
stage('Lint & Type Check') {
steps {
sh 'npm run lint'
sh 'npm run type-check'
}
}
}
}
stage('E2E Tests') {
steps {
sh 'npx playwright install --with-deps'
sh 'npm run build'
sh 'npx playwright test'
}
post {
always {
publishHTML([
reportDir: 'playwright-report',
reportFiles: 'index.html',
reportName: 'Playwright Report'
])
}
}
}
stage('Security Scan') {
steps {
sh 'npm audit --audit-level=high'
sh 'npx snyk test --severity-threshold=high'
}
}
stage('Quality Gate') {
steps {
script {
def coverage = readFile('coverage/coverage-summary.json')
def coverageData = new groovy.json.JsonSlurper().parseText(coverage)
def lineCoverage = coverageData.total.lines.pct
if (lineCoverage < 80) {
error "Coverage ${lineCoverage}% is below threshold 80%"
}
}
}
}
stage('Deploy to Staging') {
when {
branch 'develop'
}
steps {
echo 'Deploying to staging...'
// 部署步骤
}
}
stage('Deploy to Production') {
when {
branch 'main'
}
steps {
input message: 'Deploy to production?', ok: 'Deploy'
echo 'Deploying to production...'
// 部署步骤
}
}
}
post {
always {
cleanWs()
}
failure {
slackSend(
color: 'danger',
message: "Build failed: ${env.JOB_NAME} ${env.BUILD_NUMBER}"
)
}
success {
slackSend(
color: 'good',
message: "Build succeeded: ${env.JOB_NAME} ${env.BUILD_NUMBER}"
)
}
}
}
4. 测试选择策略
// scripts/run-affected-tests.ts
import { execSync } from 'child_process';
import * as fs from 'fs';
class AffectedTestRunner {
getAffectedFiles(): string[] {
// 从git获取变更文件
const output = execSync('git diff --name-only HEAD~1', {
encoding: 'utf-8',
});
return output.split('
').filter(Boolean);
}
getTestsForFiles(files: string[]): Set<string> {
const tests = new Set<string>();
for (const file of files) {
if (file.endsWith('.test.ts') || file.endsWith('.spec.ts')) {
// 文件已经是测试
tests.add(file);
} else if (file.endsWith('.ts')) {
// 查找关联的测试文件
const testFile = file.replace('.ts', '.test.ts');
if (fs.existsSync(testFile)) {
tests.add(testFile);
}
// 检查导入此文件的集成测试
const integrationTests = execSync(
`grep -r "from.*${file}" tests/integration/*.test.ts`,
{ encoding: 'utf-8' }
).split('
');
integrationTests.forEach(line => {
const match = line.match(/^([^:]+):/);
if (match) tests.add(match[1]);
});
}
}
return tests;
}
run() {
const affectedFiles = this.getAffectedFiles();
console.log('Affected files:', affectedFiles);
const testsToRun = this.getTestsForFiles(affectedFiles);
console.log('Tests to run:', testsToRun);
if (testsToRun.size === 0) {
console.log('No tests affected');
return;
}
// 仅运行受影响的测试
const testPattern = Array.from(testsToRun).join('|');
execSync(`npm test -- --testPathPattern="${testPattern}"`, {
stdio: 'inherit',
});
}
}
new AffectedTestRunner().run();
5. 不稳定测试检测
# .github/workflows/flaky-test-detection.yml
name: 不稳定测试检测
on:
schedule:
- cron: '0 2 * * *' # 每晚运行
jobs:
detect-flaky-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: 设置Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: 安装依赖
run: npm ci
- name: 运行测试10次
run: |
for i in {1..10}; do
echo "Run $i"
npm test -- --json --outputFile=results-$i.json || true
done
- name: 分析不稳定测试
run: node scripts/analyze-flaky-tests.js
// scripts/analyze-flaky-tests.js
const fs = require('fs');
const runs = Array.from({ length: 10 }, (_, i) =>
JSON.parse(fs.readFileSync(`results-${i + 1}.json`, 'utf-8'))
);
const testResults = new Map();
// 聚合结果
runs.forEach(run => {
run.testResults.forEach(suite => {
suite.assertionResults.forEach(test => {
const key = `${suite.name}::${test.title}`;
if (!testResults.has(key)) {
testResults.set(key, { passed: 0, failed: 0 });
}
const stats = testResults.get(key);
if (test.status === 'passed') {
stats.passed++;
} else {
stats.failed++;
}
});
});
});
// 识别不稳定测试
const flakyTests = [];
testResults.forEach((stats, test) => {
if (stats.passed > 0 && stats.failed > 0) {
flakyTests.push({
test,
passRate: (stats.passed / 10) * 100,
...stats,
});
}
});
if (flakyTests.length > 0) {
console.log('
不稳定测试检测到:');
flakyTests.forEach(({ test, passRate, passed, failed }) => {
console.log(` ${test}`);
console.log(` 通过率:${passRate}% (${passed}/10次运行)`);
});
// 如果不稳定测试太多,则失败
if (flakyTests.length > 5) {
process.exit(1);
}
}
6. 测试指标仪表板
// scripts/generate-test-metrics.ts
import * as fs from 'fs';
interface TestMetrics {
totalTests: number;
passedTests: number;
failedTests: number;
skippedTests: number;
duration: number;
coverage: number;
timestamp: string;
}
class MetricsCollector {
collectMetrics(): TestMetrics {
const testResults = JSON.parse(
fs.readFileSync('test-results.json', 'utf-8')
);
const coverage = JSON.parse(
fs.readFileSync('coverage/coverage-summary.json', 'utf-8')
);
return {
totalTests: testResults.numTotalTests,
passedTests: testResults.numPassedTests,
failedTests: testResults.numFailedTests,
skippedTests: testResults.numPendingTests,
duration: testResults.testResults.reduce(
(sum, r) => sum + r.perfStats.runtime,
0
),
coverage: coverage.total.lines.pct,
timestamp: new Date().toISOString(),
};
}
saveMetrics(metrics: TestMetrics) {
const history = this.loadHistory();
history.push(metrics);
// 保留最后30天
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
const filtered = history.filter(
m => new Date(m.timestamp).getTime() > cutoff
);
fs.writeFileSync(
'metrics-history.json',
JSON.stringify(filtered, null, 2)
);
}
loadHistory(): TestMetrics[] {
try {
return JSON.parse(fs.readFileSync('metrics-history.json', 'utf-8'));
} catch {
return [];
}
}
generateReport() {
const history = this.loadHistory();
console.log('
测试指标(最近7天):');
console.log('─'.repeat(60));
const recent = history.slice(-7);
const avgCoverage =
recent.reduce((sum, m) => sum + m.coverage, 0) / recent.length;
const avgDuration =
recent.reduce((sum, m) => sum + m.duration, 0) / recent.length;
console.log(`平均覆盖率:${avgCoverage.toFixed(2)}%`);
console.log(`平均持续时间:${(avgDuration / 1000).toFixed(2)}s`);
console.log(`总测试数:${recent[recent.length - 1].totalTests}`);
}
}
const collector = new MetricsCollector();
const metrics = collector.collectMetrics();
collector.saveMetrics(metrics);
collector.generateReport();
最佳实践
✅ 执行
- 先运行快速测试(单元 → 集成 → E2E)
- 并行化测试执行
- 缓存依赖
- 设置适当的超时
- 监控测试健康和不稳定性
- 实施质量门
- 使用测试选择策略
- 生成全面的报告
❌ 不要
- 顺序运行所有测试
- 忽略不稳定测试
- 跳过测试维护
- 允许测试相互依赖
- 每次提交都运行慢速测试
- 有失败测试时部署
- 忽略测试执行时间
- 跳过安全扫描
跟踪指标
- 测试覆盖率:行、分支、函数覆盖率
- 测试持续时间:执行时间趋势
- 通过率:通过测试的百分比
- 不稳定性:结果不一致的测试
- 测试计数:随时间增长
- 构建成功率:管道可靠性
工具
- CI/CD:GitHub Actions, GitLab CI, Jenkins, CircleCI
- 测试编排:Nx, Bazel, Lerna
- 报告:Allure, ReportPortal, TestRail
- 监控:Datadog, New Relic, Grafana
示例
另见:test-automation-framework, integration-testing, cicd-pipeline-setup 了解全面的CI/CD实现。