Skip to content

测试报告

生成清晰、可操作的测试报告和分析

测试报告的价值

HTML 测试报告

Jest HTML Reporter

javascript
// jest.config.js
module.exports = {
  reporters: [
    'default',
    ['jest-html-reporter', {
      pageTitle: 'Test Report',
      outputPath: 'test-report.html',
      includeFailureMsg: true,
      includeConsoleLog: true,
      theme: 'darkTheme',
      dateFormat: 'yyyy-mm-dd HH:MM:ss',
      sort: 'status',
      executionTimeWarningThreshold: 5,
      customScriptPath: './test-report-scripts.js'
    }]
  ]
};

// test-report-scripts.js
window.addEventListener('load', () => {
  // 添加自定义图表
  const canvas = document.createElement('canvas');
  canvas.id = 'testTrendChart';
  document.querySelector('.summary').appendChild(canvas);
  
  const ctx = canvas.getContext('2d');
  new Chart(ctx, {
    type: 'line',
    data: {
      labels: ['Week 1', 'Week 2', 'Week 3', 'Week 4'],
      datasets: [{
        label: 'Pass Rate',
        data: [85, 88, 92, 95],
        borderColor: 'rgb(75, 192, 192)',
        tension: 0.1
      }]
    }
  });
});

Allure Report

java
// TestNG + Allure 配置
@Listeners({AllureTestNg.class})
public class ApiTestBase {
    
    @BeforeMethod
    public void setUpMethod(Method method) {
        Test test = method.getAnnotation(Test.class);
        if (test != null) {
            Allure.epic("API Testing");
            Allure.feature(method.getDeclaringClass().getSimpleName());
            Allure.story(test.description());
        }
    }
    
    @Test(description = "用户注册测试")
    @Severity(SeverityLevel.CRITICAL)
    @Description("验证用户可以使用有效信息成功注册")
    @Link(name = "需求", url = "https://jira.example.com/browse/REQ-123")
    @TmsLink("TC-456")
    public void testUserRegistration() {
        // 测试步骤
        step("准备测试数据", () -> {
            UserData userData = UserData.builder()
                .email("test@example.com")
                .password("SecurePass123!")
                .build();
            Allure.addAttachment("User Data", "application/json", 
                JsonUtils.toJson(userData));
        });
        
        step("发送注册请求", () -> {
            Response response = apiClient.post("/users", userData);
            Allure.addAttachment("Response", response.getBody().asString());
            assertThat(response.getStatusCode()).isEqualTo(201);
        });
        
        step("验证响应数据", () -> {
            User user = response.as(User.class);
            assertThat(user.getEmail()).isEqualTo(userData.getEmail());
            assertThat(user.getId()).isNotNull();
        });
    }
    
    @Step("{0}")
    private void step(String description, Runnable runnable) {
        runnable.run();
    }
}
bash
# 生成 Allure 报告
mvn clean test
allure generate allure-results --clean -o allure-report
allure open allure-report

Playwright HTML Report

typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  reporter: [
    ['html', { 
      outputFolder: 'playwright-report',
      open: 'never',
      host: '0.0.0.0',
      port: 9223
    }],
    ['json', { outputFile: 'test-results.json' }],
    ['junit', { outputFile: 'junit.xml' }],
    ['./custom-reporter.ts']
  ],
  use: {
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure'
  }
});

// custom-reporter.ts
import { Reporter, TestCase, TestResult } from '@playwright/test/reporter';

class CustomReporter implements Reporter {
  private results: Map<string, TestResult> = new Map();
  
  onTestEnd(test: TestCase, result: TestResult) {
    this.results.set(test.id, result);
    
    if (result.status === 'failed') {
      console.log(`❌ Failed: ${test.title}`);
      console.log(`   Error: ${result.error?.message}`);
      
      // 发送到监控系统
      this.sendToMonitoring({
        test: test.title,
        error: result.error?.message,
        duration: result.duration
      });
    }
  }
  
  onEnd() {
    const summary = this.generateSummary();
    this.generateCustomReport(summary);
  }
  
  private generateSummary() {
    const total = this.results.size;
    const passed = [...this.results.values()].filter(r => r.status === 'passed').length;
    const failed = [...this.results.values()].filter(r => r.status === 'failed').length;
    const skipped = [...this.results.values()].filter(r => r.status === 'skipped').length;
    
    return {
      total,
      passed,
      failed,
      skipped,
      passRate: (passed / total * 100).toFixed(2)
    };
  }
  
  private generateCustomReport(summary: any) {
    const html = `
      <!DOCTYPE html>
      <html>
      <head>
        <title>E2E Test Report</title>
        <style>
          body { font-family: Arial, sans-serif; margin: 20px; }
          .summary { background: #f5f5f5; padding: 20px; border-radius: 8px; }
          .passed { color: #4caf50; }
          .failed { color: #f44336; }
          .chart { width: 400px; height: 300px; margin: 20px 0; }
        </style>
      </head>
      <body>
        <h1>E2E Test Report</h1>
        <div class="summary">
          <h2>Summary</h2>
          <p>Total Tests: ${summary.total}</p>
          <p class="passed">Passed: ${summary.passed}</p>
          <p class="failed">Failed: ${summary.failed}</p>
          <p>Pass Rate: ${summary.passRate}%</p>
        </div>
        <canvas id="chart" class="chart"></canvas>
        <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
        <script>
          const ctx = document.getElementById('chart').getContext('2d');
          new Chart(ctx, {
            type: 'doughnut',
            data: {
              labels: ['Passed', 'Failed', 'Skipped'],
              datasets: [{
                data: [${summary.passed}, ${summary.failed}, ${summary.skipped}],
                backgroundColor: ['#4caf50', '#f44336', '#ff9800']
              }]
            }
          });
        </script>
      </body>
      </html>
    `;
    
    fs.writeFileSync('custom-report.html', html);
  }
}

export default CustomReporter;

测试覆盖率报告

Istanbul/NYC 覆盖率

json
// package.json
{
  "scripts": {
    "test:coverage": "nyc --reporter=html --reporter=text --reporter=lcov npm test",
    "coverage:check": "nyc check-coverage --lines 80 --functions 80 --branches 80"
  },
  "nyc": {
    "include": ["src/**/*.js"],
    "exclude": ["**/*.test.js", "**/*.spec.js"],
    "reporter": ["html", "text", "lcov"],
    "all": true,
    "cache": true,
    "report-dir": "./coverage",
    "temp-dir": "./.nyc_output",
    "check-coverage": true,
    "lines": 80,
    "statements": 80,
    "functions": 80,
    "branches": 80,
    "watermarks": {
      "lines": [80, 95],
      "functions": [80, 95],
      "branches": [80, 95],
      "statements": [80, 95]
    }
  }
}

覆盖率趋势追踪

javascript
// coverage-trend.js
const fs = require('fs');
const path = require('path');

class CoverageTrend {
  constructor(historyFile = 'coverage-history.json') {
    this.historyFile = historyFile;
    this.history = this.loadHistory();
  }
  
  loadHistory() {
    if (fs.existsSync(this.historyFile)) {
      return JSON.parse(fs.readFileSync(this.historyFile, 'utf8'));
    }
    return [];
  }
  
  saveHistory() {
    fs.writeFileSync(this.historyFile, JSON.stringify(this.history, null, 2));
  }
  
  addCoverageData() {
    const coverageSummary = JSON.parse(
      fs.readFileSync('coverage/coverage-summary.json', 'utf8')
    );
    
    const data = {
      date: new Date().toISOString(),
      commit: process.env.GIT_COMMIT || 'unknown',
      branch: process.env.GIT_BRANCH || 'unknown',
      coverage: {
        lines: coverageSummary.total.lines.pct,
        statements: coverageSummary.total.statements.pct,
        functions: coverageSummary.total.functions.pct,
        branches: coverageSummary.total.branches.pct
      }
    };
    
    this.history.push(data);
    this.saveHistory();
    
    return data;
  }
  
  generateTrendReport() {
    const html = `
      <!DOCTYPE html>
      <html>
      <head>
        <title>Coverage Trend</title>
        <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
      </head>
      <body>
        <h1>Code Coverage Trend</h1>
        <canvas id="coverageChart" width="800" height="400"></canvas>
        <script>
          const data = ${JSON.stringify(this.history)};
          const ctx = document.getElementById('coverageChart').getContext('2d');
          
          new Chart(ctx, {
            type: 'line',
            data: {
              labels: data.map(d => new Date(d.date).toLocaleDateString()),
              datasets: [
                {
                  label: 'Lines',
                  data: data.map(d => d.coverage.lines),
                  borderColor: 'rgb(255, 99, 132)',
                  fill: false
                },
                {
                  label: 'Branches',
                  data: data.map(d => d.coverage.branches),
                  borderColor: 'rgb(54, 162, 235)',
                  fill: false
                },
                {
                  label: 'Functions',
                  data: data.map(d => d.coverage.functions),
                  borderColor: 'rgb(75, 192, 192)',
                  fill: false
                },
                {
                  label: 'Statements',
                  data: data.map(d => d.coverage.statements),
                  borderColor: 'rgb(153, 102, 255)',
                  fill: false
                }
              ]
            },
            options: {
              responsive: true,
              plugins: {
                title: {
                  display: true,
                  text: 'Code Coverage Over Time'
                }
              },
              scales: {
                y: {
                  beginAtZero: true,
                  max: 100
                }
              }
            }
          });
        </script>
      </body>
      </html>
    `;
    
    fs.writeFileSync('coverage-trend.html', html);
  }
}

// 使用
const trend = new CoverageTrend();
trend.addCoverageData();
trend.generateTrendReport();

性能测试报告

K6 性能报告

javascript
// k6-html-report.js
import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js";
import { textSummary } from "https://jslib.k6.io/k6-summary/0.0.1/index.js";

export function handleSummary(data) {
  return {
    "performance-report.html": htmlReport(data),
    "summary.txt": textSummary(data, { indent: " ", enableColors: false }),
    "summary.json": JSON.stringify(data, null, 2),
    stdout: textSummary(data, { indent: " ", enableColors: true })
  };
}

// 自定义报告生成
export function generateCustomReport(data) {
  const metrics = data.metrics;
  
  const report = {
    timestamp: new Date().toISOString(),
    summary: {
      total_requests: metrics.http_reqs.count,
      failed_requests: metrics.http_req_failed.count,
      avg_response_time: metrics.http_req_duration.avg,
      p95_response_time: metrics.http_req_duration.p(95),
      p99_response_time: metrics.http_req_duration.p(99),
      throughput: metrics.http_reqs.rate
    },
    thresholds: data.thresholds,
    checks: data.checks
  };
  
  // 生成 HTML 报告
  const html = `
    <!DOCTYPE html>
    <html>
    <head>
      <title>Performance Test Report</title>
      <style>
        .metric { 
          display: inline-block; 
          margin: 10px; 
          padding: 20px; 
          background: #f5f5f5; 
          border-radius: 8px; 
        }
        .metric h3 { margin: 0 0 10px 0; }
        .value { font-size: 2em; font-weight: bold; }
        .passed { color: #4caf50; }
        .failed { color: #f44336; }
      </style>
    </head>
    <body>
      <h1>Performance Test Report</h1>
      <p>Generated at: ${report.timestamp}</p>
      
      <div class="metrics">
        <div class="metric">
          <h3>Total Requests</h3>
          <div class="value">${report.summary.total_requests}</div>
        </div>
        <div class="metric">
          <h3>Failed Requests</h3>
          <div class="value ${report.summary.failed_requests > 0 ? 'failed' : 'passed'}">
            ${report.summary.failed_requests}
          </div>
        </div>
        <div class="metric">
          <h3>Avg Response Time</h3>
          <div class="value">${report.summary.avg_response_time.toFixed(2)}ms</div>
        </div>
        <div class="metric">
          <h3>P95 Response Time</h3>
          <div class="value">${report.summary.p95_response_time.toFixed(2)}ms</div>
        </div>
        <div class="metric">
          <h3>Throughput</h3>
          <div class="value">${report.summary.throughput.toFixed(2)} req/s</div>
        </div>
      </div>
      
      <h2>Response Time Distribution</h2>
      <canvas id="responseTimeChart" width="600" height="300"></canvas>
      
      <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
      <script>
        // 响应时间分布图
        const ctx = document.getElementById('responseTimeChart').getContext('2d');
        new Chart(ctx, {
          type: 'bar',
          data: {
            labels: ['0-100ms', '100-200ms', '200-500ms', '500-1000ms', '>1000ms'],
            datasets: [{
              label: 'Request Count',
              data: [/* 实际分布数据 */],
              backgroundColor: '#2196F3'
            }]
          }
        });
      </script>
    </body>
    </html>
  `;
  
  return html;
}

JMeter 报告

xml
<!-- jmeter-report-config.xml -->
<jmeterTestPlan version="1.2">
  <hashTree>
    <ResultCollector guiclass="StatVisualizer" testclass="ResultCollector" testname="Aggregate Report">
      <boolProp name="ResultCollector.error_logging">false</boolProp>
      <objProp>
        <name>saveConfig</name>
        <value class="SampleSaveConfiguration">
          <time>true</time>
          <latency>true</latency>
          <timestamp>true</timestamp>
          <success>true</success>
          <label>true</label>
          <code>true</code>
          <message>true</message>
          <threadName>true</threadName>
          <dataType>true</dataType>
          <encoding>false</encoding>
          <assertions>true</assertions>
          <subresults>true</subresults>
          <responseData>false</responseData>
          <samplerData>false</samplerData>
          <xml>false</xml>
          <fieldNames>true</fieldNames>
          <responseHeaders>false</responseHeaders>
          <requestHeaders>false</requestHeaders>
          <responseDataOnError>false</responseDataOnError>
          <saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage>
          <assertionsResultsToSave>0</assertionsResultsToSave>
          <bytes>true</bytes>
          <sentBytes>true</sentBytes>
          <url>true</url>
          <threadCounts>true</threadCounts>
          <idleTime>true</idleTime>
          <connectTime>true</connectTime>
        </value>
      </objProp>
    </ResultCollector>
  </hashTree>
</jmeterTestPlan>
bash
# 生成 HTML 报告
jmeter -n -t test-plan.jmx -l results.jtl -e -o html-report/

# 生成 Dashboard 报告
jmeter -g results.jtl -o dashboard-report/

集成测试报告

TestCafe 报告

javascript
// .testcaferc.js
module.exports = {
  reporters: [
    {
      name: 'spec'
    },
    {
      name: 'json',
      output: 'reports/report.json'
    },
    {
      name: 'xunit',
      output: 'reports/report.xml'
    },
    {
      name: 'html',
      output: 'reports/report.html'
    },
    {
      name: 'custom-reporter'
    }
  ]
};

// custom-reporter.js
export default function () {
  return {
    noColors: true,
    
    reportTaskStart(startTime, userAgents, testCount) {
      this.startTime = startTime;
      this.testCount = testCount;
      console.log(`Starting tests: ${testCount} tests`);
    },
    
    reportFixtureStart(name, path, meta) {
      this.currentFixture = name;
    },
    
    reportTestDone(name, testRunInfo, meta) {
      const hasErr = testRunInfo.errs.length > 0;
      const status = testRunInfo.skipped ? 'skipped' : hasErr ? 'failed' : 'passed';
      
      console.log(`${status.toUpperCase()}: ${this.currentFixture} - ${name}`);
      
      if (hasErr) {
        testRunInfo.errs.forEach(err => {
          console.error(`  Error: ${err.errMsg}`);
        });
      }
    },
    
    reportTaskDone(endTime, passed, warnings, result) {
      const duration = endTime - this.startTime;
      const minutes = Math.floor(duration / 60000);
      const seconds = ((duration % 60000) / 1000).toFixed(0);
      
      console.log('\n==== Test Summary ====');
      console.log(`Duration: ${minutes}m ${seconds}s`);
      console.log(`Total: ${this.testCount}`);
      console.log(`Passed: ${passed}`);
      console.log(`Failed: ${this.testCount - passed}`);
      console.log(`Pass Rate: ${(passed / this.testCount * 100).toFixed(2)}%`);
      
      // 生成详细报告
      this.generateDetailedReport(result);
    },
    
    generateDetailedReport(result) {
      const report = {
        summary: {
          total: this.testCount,
          passed: result.passedCount,
          failed: result.failedCount,
          skipped: result.skippedCount,
          duration: result.durationMs
        },
        fixtures: result.fixtures.map(fixture => ({
          name: fixture.name,
          path: fixture.path,
          tests: fixture.tests.map(test => ({
            name: test.name,
            status: test.skipped ? 'skipped' : test.errs.length > 0 ? 'failed' : 'passed',
            duration: test.durationMs,
            errors: test.errs
          }))
        }))
      };
      
      fs.writeFileSync('detailed-report.json', JSON.stringify(report, null, 2));
    }
  };
}

测试仪表板

React 测试仪表板

tsx
// TestDashboard.tsx
import React, { useState, useEffect } from 'react';
import { Line, Doughnut, Bar } from 'react-chartjs-2';
import { Card, Grid, Typography, Box } from '@mui/material';

interface TestResults {
  total: number;
  passed: number;
  failed: number;
  skipped: number;
  duration: number;
  coverage: {
    lines: number;
    branches: number;
    functions: number;
    statements: number;
  };
}

const TestDashboard: React.FC = () => {
  const [results, setResults] = useState<TestResults | null>(null);
  const [trend, setTrend] = useState<any[]>([]);
  
  useEffect(() => {
    // 获取最新测试结果
    fetch('/api/test-results/latest')
      .then(res => res.json())
      .then(data => setResults(data));
    
    // 获取历史趋势
    fetch('/api/test-results/trend')
      .then(res => res.json())
      .then(data => setTrend(data));
  }, []);
  
  if (!results) return <div>Loading...</div>;
  
  const passRate = (results.passed / results.total * 100).toFixed(2);
  
  const statusData = {
    labels: ['Passed', 'Failed', 'Skipped'],
    datasets: [{
      data: [results.passed, results.failed, results.skipped],
      backgroundColor: ['#4caf50', '#f44336', '#ff9800']
    }]
  };
  
  const coverageData = {
    labels: ['Lines', 'Branches', 'Functions', 'Statements'],
    datasets: [{
      label: 'Coverage %',
      data: [
        results.coverage.lines,
        results.coverage.branches,
        results.coverage.functions,
        results.coverage.statements
      ],
      backgroundColor: '#2196f3'
    }]
  };
  
  const trendData = {
    labels: trend.map(t => new Date(t.date).toLocaleDateString()),
    datasets: [{
      label: 'Pass Rate',
      data: trend.map(t => t.passRate),
      borderColor: '#4caf50',
      fill: false
    }]
  };
  
  return (
    <Box p={3}>
      <Typography variant="h4" gutterBottom>
        Test Results Dashboard
      </Typography>
      
      <Grid container spacing={3}>
        <Grid item xs={12} md={3}>
          <Card>
            <Box p={2}>
              <Typography variant="h6">Total Tests</Typography>
              <Typography variant="h3">{results.total}</Typography>
            </Box>
          </Card>
        </Grid>
        
        <Grid item xs={12} md={3}>
          <Card>
            <Box p={2}>
              <Typography variant="h6">Pass Rate</Typography>
              <Typography variant="h3" color="primary">{passRate}%</Typography>
            </Box>
          </Card>
        </Grid>
        
        <Grid item xs={12} md={3}>
          <Card>
            <Box p={2}>
              <Typography variant="h6">Duration</Typography>
              <Typography variant="h3">{(results.duration / 1000).toFixed(2)}s</Typography>
            </Box>
          </Card>
        </Grid>
        
        <Grid item xs={12} md={3}>
          <Card>
            <Box p={2}>
              <Typography variant="h6">Coverage</Typography>
              <Typography variant="h3">{results.coverage.lines}%</Typography>
            </Box>
          </Card>
        </Grid>
        
        <Grid item xs={12} md={6}>
          <Card>
            <Box p={2}>
              <Typography variant="h6">Test Status Distribution</Typography>
              <Doughnut data={statusData} />
            </Box>
          </Card>
        </Grid>
        
        <Grid item xs={12} md={6}>
          <Card>
            <Box p={2}>
              <Typography variant="h6">Code Coverage</Typography>
              <Bar data={coverageData} options={{ indexAxis: 'y' }} />
            </Box>
          </Card>
        </Grid>
        
        <Grid item xs={12}>
          <Card>
            <Box p={2}>
              <Typography variant="h6">Pass Rate Trend</Typography>
              <Line data={trendData} />
            </Box>
          </Card>
        </Grid>
      </Grid>
    </Box>
  );
};

export default TestDashboard;

Grafana 集成

json
// grafana-dashboard.json
{
  "dashboard": {
    "title": "Test Metrics Dashboard",
    "panels": [
      {
        "title": "Test Execution Time",
        "targets": [
          {
            "expr": "test_execution_duration_seconds",
            "legendFormat": "{{test_suite}}"
          }
        ],
        "type": "graph"
      },
      {
        "title": "Test Pass Rate",
        "targets": [
          {
            "expr": "rate(test_passed_total[5m]) / rate(test_total[5m]) * 100"
          }
        ],
        "type": "gauge",
        "options": {
          "minValue": 0,
          "maxValue": 100,
          "thresholds": [
            { "value": 80, "color": "yellow" },
            { "value": 95, "color": "green" }
          ]
        }
      },
      {
        "title": "Code Coverage Trend",
        "targets": [
          {
            "expr": "code_coverage_percentage",
            "legendFormat": "{{type}}"
          }
        ],
        "type": "graph"
      }
    ]
  }
}

测试报告通知

Slack 通知

javascript
// slack-reporter.js
const { WebClient } = require('@slack/web-api');

class SlackReporter {
  constructor(token, channel) {
    this.client = new WebClient(token);
    this.channel = channel;
  }
  
  async sendTestReport(results) {
    const passRate = (results.passed / results.total * 100).toFixed(2);
    const status = passRate >= 95 ? ':white_check_mark:' : 
                   passRate >= 80 ? ':warning:' : ':x:';
    
    const blocks = [
      {
        type: 'header',
        text: {
          type: 'plain_text',
          text: `${status} Test Results - ${new Date().toLocaleDateString()}`
        }
      },
      {
        type: 'section',
        fields: [
          {
            type: 'mrkdwn',
            text: `*Total Tests:*\n${results.total}`
          },
          {
            type: 'mrkdwn',
            text: `*Pass Rate:*\n${passRate}%`
          },
          {
            type: 'mrkdwn',
            text: `*Duration:*\n${(results.duration / 1000).toFixed(2)}s`
          },
          {
            type: 'mrkdwn',
            text: `*Coverage:*\n${results.coverage}%`
          }
        ]
      }
    ];
    
    if (results.failed > 0) {
      blocks.push({
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `*Failed Tests:*\n${results.failures.map(f => `• ${f.name}`).join('\n')}`
        }
      });
    }
    
    blocks.push({
      type: 'actions',
      elements: [
        {
          type: 'button',
          text: {
            type: 'plain_text',
            text: 'View Full Report'
          },
          url: `https://ci.example.com/job/${process.env.BUILD_ID}/testReport`
        }
      ]
    });
    
    await this.client.chat.postMessage({
      channel: this.channel,
      blocks: blocks
    });
  }
}

// 使用
const reporter = new SlackReporter(process.env.SLACK_TOKEN, '#test-results');
reporter.sendTestReport(testResults);

Email 报告

python
# email_reporter.py
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.image import MIMEImage
import matplotlib.pyplot as plt
import io

class EmailReporter:
    def __init__(self, smtp_server, smtp_port, username, password):
        self.smtp_server = smtp_server
        self.smtp_port = smtp_port
        self.username = username
        self.password = password
    
    def generate_report_html(self, results):
        """生成 HTML 报告"""
        pass_rate = (results['passed'] / results['total']) * 100
        
        html = f"""
        <html>
        <head>
            <style>
                body {{ font-family: Arial, sans-serif; }}
                .header {{ background-color: #2196F3; color: white; padding: 20px; }}
                .summary {{ margin: 20px; }}
                .metric {{ display: inline-block; margin: 10px; padding: 15px; 
                          background-color: #f5f5f5; border-radius: 5px; }}
                .passed {{ color: #4CAF50; }}
                .failed {{ color: #F44336; }}
            </style>
        </head>
        <body>
            <div class="header">
                <h1>Test Execution Report</h1>
                <p>{results['date']}</p>
            </div>
            
            <div class="summary">
                <h2>Summary</h2>
                <div class="metric">
                    <h3>Total Tests</h3>
                    <p style="font-size: 24px;">{results['total']}</p>
                </div>
                <div class="metric">
                    <h3>Pass Rate</h3>
                    <p style="font-size: 24px;" class="{'passed' if pass_rate >= 95 else 'failed'}">
                        {pass_rate:.2f}%
                    </p>
                </div>
                <div class="metric">
                    <h3>Duration</h3>
                    <p style="font-size: 24px;">{results['duration']:.2f}s</p>
                </div>
            </div>
            
            <div style="margin: 20px;">
                <h2>Test Results Chart</h2>
                <img src="cid:chart" alt="Test Results Chart" />
            </div>
            
            {self._generate_failures_section(results.get('failures', []))}
        </body>
        </html>
        """
        
        return html
    
    def generate_chart(self, results):
        """生成测试结果图表"""
        labels = ['Passed', 'Failed', 'Skipped']
        sizes = [results['passed'], results['failed'], results['skipped']]
        colors = ['#4CAF50', '#F44336', '#FF9800']
        
        fig, ax = plt.subplots(figsize=(8, 6))
        ax.pie(sizes, labels=labels, colors=colors, autopct='%1.1f%%')
        ax.set_title('Test Results Distribution')
        
        # 保存到内存
        img_buffer = io.BytesIO()
        plt.savefig(img_buffer, format='png')
        img_buffer.seek(0)
        
        return img_buffer
    
    def send_report(self, recipients, results):
        """发送测试报告邮件"""
        msg = MIMEMultipart('related')
        msg['Subject'] = f"Test Report - {results['date']}"
        msg['From'] = self.username
        msg['To'] = ', '.join(recipients)
        
        # HTML 内容
        html_content = self.generate_report_html(results)
        msg.attach(MIMEText(html_content, 'html'))
        
        # 图表附件
        chart_buffer = self.generate_chart(results)
        chart_image = MIMEImage(chart_buffer.read())
        chart_image.add_header('Content-ID', '<chart>')
        msg.attach(chart_image)
        
        # 发送邮件
        with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
            server.starttls()
            server.login(self.username, self.password)
            server.send_message(msg)

总结

高质量的测试报告应该:

  • 📊 可视化: 使用图表展示关键指标
  • 🎯 可操作: 提供具体的改进建议
  • 📈 趋势分析: 展示历史数据和趋势
  • 🔔 及时通知: 自动发送报告给相关人员
  • 🔍 详细信息: 包含失败测试的详细信息

通过完善的测试报告体系,让测试结果真正驱动质量改进。

SOLO Development Guide