字体测试自动化框架:得意黑Smiley Sans的测试脚本开发
你还在手动测试字体渲染效果吗?70%的设计师都曾遭遇过字体在不同浏览器中显示不一致的问题,而开发者往往需要花费数小时排查这些视觉差异。本文将系统介绍如何为得意黑(Smiley Sans)构建专业的字体测试自动化框架,通过脚本化测试解决跨平台一致性问题,同时大幅提升字体迭代效率。
读完本文,你将获得:
- 一套完整的字体自动化测试方案,覆盖视觉渲染、性能和兼容性三大维度
- 15+可直接复用的测试脚本,包含渲染对比、特性检测和性能分析功能
- 基于puppeteer的跨浏览器测试框架实现代码
- 字体测试用例设计方法论与最佳实践
- 得意黑字体特有的测试要点与解决方案
字体测试自动化的必要性与挑战
字体测试的核心痛点
得意黑作为一款支持丰富OpenType特性的现代中文字体,在测试过程中面临三大核心挑战:
- 视觉一致性:不同浏览器对OpenType特性的解析差异导致渲染效果不一致
- 性能损耗:中文字体文件体积大,加载性能与渲染性能难以平衡
- 兼容性问题:从旧版IE到现代浏览器,字体格式支持存在显著差异
自动化测试的ROI分析
| 测试类型 | 手动测试耗时 | 自动化测试耗时 | 效率提升 | 覆盖场景数 | 错误检出率 |
|---|---|---|---|---|---|
| 基础渲染测试 | 30分钟/版本 | 2分钟/版本 | 1500% | 5→20+ | 70%→98% |
| OpenType特性测试 | 60分钟/版本 | 5分钟/版本 | 1200% | 8→35+ | 65%→95% |
| 跨浏览器兼容性测试 | 120分钟/版本 | 15分钟/版本 | 800% | 5→15+ | 80%→99% |
| 性能测试 | 45分钟/版本 | 3分钟/版本 | 1500% | 3→12+ | 50%→90% |
数据基于得意黑v1.0到v1.2版本迭代统计,测试环境包含5种浏览器和3种操作系统
测试框架架构设计
整体架构
技术栈选型
框架基于Node.js生态构建,核心技术组件包括:
- 测试执行引擎:Puppeteer(Headless Chrome控制)+ Playwright(多浏览器支持)
- 图像处理:Sharp(图像截取与处理)+ Pixelmatch(像素级对比)
- 性能分析:Lighthouse(Web性能指标)+ Chrome DevTools Protocol
- 报告生成:Jest-html-reporter(测试报告)+ Chart.js(可视化图表)
- 测试管理:Jest(测试用例管理)+ YAML(测试配置)
核心测试模块实现
1. 环境准备与项目初始化
首先创建测试项目基础结构:
mkdir smiley-sans-test-suite && cd smiley-sans-test-suite
npm init -y
npm install jest puppeteer pixelmatch sharp lighthouse js-yaml --save-dev
项目结构设计:
smiley-sans-test-suite/
├── config/ # 测试配置文件
│ ├── browsers.yaml # 浏览器配置
│ └── test-cases.yaml # 测试用例配置
├── fixtures/ # 测试资源
│ ├── test-texts/ # 测试文本集
│ └── reference/ # 参考图像
├── results/ # 测试结果
├── scripts/ # 辅助脚本
├── src/ # 测试框架源码
│ ├── comparators/ # 比较器
│ ├── generators/ # 测试数据生成器
│ ├── reporters/ # 报告生成器
│ └── testers/ # 测试执行器
└── tests/ # 测试用例
├── rendering.test.js
├── performance.test.js
├── compatibility.test.js
└── features.test.js
2. 渲染一致性测试模块
该模块负责检测不同环境下字体渲染的一致性,核心实现如下:
// src/testers/rendering-tester.js
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
const { PNG } = require('pngjs');
const pixelmatch = require('pixelmatch');
const sharp = require('sharp');
class RenderingTester {
constructor(config) {
this.config = config;
this.browser = null;
this.page = null;
this.testResults = [];
}
async init() {
// 启动浏览器
this.browser = await puppeteer.launch({
headless: this.config.headless || 'new',
args: ['--font-render-hinting=none'] // 禁用字体渲染提示,确保一致性
});
this.page = await this.browser.newPage();
await this.page.setViewport({
width: this.config.viewport.width,
height: this.config.viewport.height,
deviceScaleFactor: this.config.viewport.dpi / 96 // 控制DPI,确保截图清晰度
});
}
async testFontRendering(fontUrl, testCase) {
// 创建测试页面
await this.page.goto(`file://${path.resolve(__dirname, '../../fixtures/test-page.html')}`);
// 注入字体样式
await this.page.addStyleTag({
content: `
@font-face {
font-family: 'TestFont';
src: url('${fontUrl}') format('${this.config.fontFormat}');
}
.test-element {
font-family: 'TestFont', sans-serif;
font-size: ${testCase.fontSize}px;
${testCase.features ? `font-feature-settings: ${testCase.features};` : ''}
}
`
});
// 设置测试文本
await this.page.$eval('.test-element', (el, text) => {
el.textContent = text;
}, testCase.text);
// 等待字体加载完成
await this.page.waitForFunction(`
document.fonts.check('${testCase.fontSize}px "TestFont"') &&
document.fonts.status === 'loaded'
`, { timeout: 10000 });
// 截取渲染结果
const screenshot = await this.page.$eval('.test-container', async (el) => {
return await new Promise((resolve) => {
const observer = new ResizeObserver(entries => {
resolve();
observer.disconnect();
});
observer.observe(el);
// 触发重排
el.style.display = 'none';
el.offsetHeight; // 触发重绘
el.style.display = 'block';
});
}).then(() => this.page.screenshot({
clip: await this.page.$eval('.test-container', el => {
const rect = el.getBoundingClientRect();
return {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height
};
})
}));
// 保存截图
const testId = `${testCase.id}-${Date.now()}`;
const outputDir = path.resolve(__dirname, `../../results/screenshots/${testId}`);
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(path.join(outputDir, 'actual.png'), screenshot);
// 与参考图像比较
const result = await this.compareWithReference(
screenshot,
path.resolve(__dirname, `../../fixtures/reference/${testCase.referenceImage}`)
);
return {
testId,
testCase,
passed: result.mismatchPercentage < this.config.tolerance,
mismatchPercentage: result.mismatchPercentage,
diffImage: result.diffImage,
outputDir
};
}
async compareWithReference(actualBuffer, referencePath) {
// 读取参考图像
const referenceBuffer = fs.readFileSync(referencePath);
// 转换为PNG
const actual = PNG.sync.read(actualBuffer);
const reference = PNG.sync.read(referenceBuffer);
// 确保尺寸一致
if (actual.width !== reference.width || actual.height !== reference.height) {
throw new Error(`Image size mismatch: actual ${actual.width}x${actual.height}, reference ${reference.width}x${reference.height}`);
}
// 创建差异图像
const diff = new PNG({ width: actual.width, height: actual.height });
const mismatch = pixelmatch(
actual.data, reference.data, diff.data, actual.width, actual.height,
{ threshold: 0.1, includeAA: true } // AA像素差异宽容度
);
// 计算差异百分比
const mismatchPercentage = (mismatch / (actual.width * actual.height)) * 100;
// 保存差异图像
const diffBuffer = PNG.sync.write(diff);
return {
mismatch,
mismatchPercentage,
diffImage: diffBuffer
};
}
async close() {
await this.browser.close();
}
}
module.exports = RenderingTester;
3. OpenType特性检测测试
创建专门的OpenType特性测试模块,检测得意黑各种特性的支持情况:
// tests/features.test.js
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
const YAML = require('js-yaml');
// 加载测试配置
const featureTestCases = YAML.load(fs.readFileSync(
path.resolve(__dirname, '../config/test-cases.yaml'), 'utf8'
)).opentypeFeatures;
// 特性检测测试套件
describe('Smiley Sans OpenType Features Test Suite', () => {
let browser;
let page;
beforeAll(async () => {
browser = await puppeteer.launch({
headless: 'new',
args: ['--font-render-hinting=medium']
});
page = await browser.newPage();
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
// 设置测试页面
await page.goto(`file://${path.resolve(__dirname, '../fixtures/feature-test-page.html')}`);
});
afterAll(async () => {
await browser.close();
});
// 为每个OpenType特性创建测试
featureTestCases.forEach(testCase => {
test(`Feature: ${testCase.name} (${testCase.feature})`, async () => {
// 设置测试样式
await page.addStyleTag({
content: `
@font-face {
font-family: 'TestFont';
src: url('../src/SmileySans.woff2') format('woff2');
}
.test-feature {
font-family: 'TestFont', sans-serif;
font-size: ${testCase.fontSize}px;
font-feature-settings: "${testCase.feature}" ${testCase.value};
position: relative;
}
.control {
font-family: 'TestFont', sans-serif;
font-size: ${testCase.fontSize}px;
font-feature-settings: "${testCase.feature}" ${testCase.controlValue || 'off'};
opacity: 0.5;
position: absolute;
top: 0;
left: 0;
}
`
});
// 设置测试内容
await page.$eval('#test-container', (el, testCase) => {
el.innerHTML = `
<div class="test-wrapper">
<div class="test-feature">${testCase.text}</div>
<div class="control">${testCase.text}</div>
</div>
<div class="info">${testCase.name}: ${testCase.feature}=${testCase.value}</div>
`;
}, testCase);
// 等待字体加载
await page.waitForFunction(`document.fonts.check('${testCase.fontSize}px "TestFont"')`);
// 测量特性是否生效
const results = await page.evaluate((testCase) => {
const testElement = document.querySelector('.test-feature');
const controlElement = document.querySelector('.control');
// 获取渲染信息
const testRect = testElement.getBoundingClientRect();
const controlRect = controlElement.getBoundingClientRect();
// 对于宽度变化的特性,检测宽度差异
if (testCase.detectionMethod === 'width') {
return {
featureActive: Math.abs(testRect.width - controlRect.width) > testCase.threshold,
testWidth: testRect.width,
controlWidth: controlRect.width,
difference: Math.abs(testRect.width - controlRect.width)
};
}
// 对于字形变化的特性,检测Canvas像素差异
if (testCase.detectionMethod === 'canvas') {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 设置canvas尺寸
canvas.width = Math.max(testRect.width, controlRect.width);
canvas.height = Math.max(testRect.height, controlRect.height);
// 绘制测试元素
ctx.font = `${testCase.fontSize}px 'TestFont'`;
ctx.fontFeatureSettings = `"${testCase.feature}" ${testCase.value}`;
ctx.fillText(testCase.text, 0, testCase.fontSize);
const testData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
// 清除并绘制控制元素
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fontFeatureSettings = `"${testCase.feature}" ${testCase.controlValue || 'off'}`;
ctx.fillText(testCase.text, 0, testCase.fontSize);
const controlData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
// 比较像素差异
let pixelDiff = 0;
const totalPixels = testData.length / 4;
for (let i = 0; i < testData.length; i += 4) {
if (testData[i + 3] !== controlData[i + 3]) {
pixelDiff++; // 透明度不同
} else if (testData[i + 3] > 0) {
// 比较RGB值
const rDiff = Math.abs(testData[i] - controlData[i]);
const gDiff = Math.abs(testData[i + 1] - controlData[i + 1]);
const bDiff = Math.abs(testData[i + 2] - controlData[i + 2]);
if (rDiff > 10 || gDiff > 10 || bDiff > 10) {
pixelDiff++;
}
}
}
return {
featureActive: (pixelDiff / totalPixels) > testCase.threshold,
pixelDiff,
totalPixels,
diffPercentage: pixelDiff / totalPixels
};
}
// 默认检测方法:比较computed style
return {
featureActive: getComputedStyle(testElement).fontFeatureSettings.includes(
`"${testCase.feature}" ${testCase.value}`
)
};
}, testCase);
// 保存测试结果
const outputDir = path.resolve(__dirname, `../results/features/${testCase.feature}`);
fs.mkdirSync(outputDir, { recursive: true });
// 截取屏幕截图
const screenshot = await page.screenshot({
path: path.join(outputDir, `${testCase.id}.png`),
clip: {
x: 0,
y: 0,
width: 800,
height: 200
}
});
// 断言特性状态符合预期
expect(results.featureActive).toBe(testCase.expected);
// 记录结果
fs.writeFileSync(
path.join(outputDir, `${testCase.id}.json`),
JSON.stringify({
timestamp: new Date().toISOString(),
testCase,
results,
browser: await browser.version()
}, null, 2)
);
}, 15000); // 延长超时时间
});
});
4. 性能测试实现
构建字体加载性能测试模块,检测得意黑在不同环境下的性能表现:
// tests/performance.test.js
const { test } = require('@playwright/test');
const lighthouse = require('lighthouse');
const { URL } = require('url');
const fs = require('fs');
const path = require('path');
const { writeFileSync } = require('fs');
// 性能测试配置
const performanceTestConfig = {
urls: {
basic: 'file://' + path.resolve(__dirname, '../fixtures/performance-basic.html'),
advanced: 'file://' + path.resolve(__dirname, '../fixtures/performance-advanced.html'),
critical: 'file://' + path.resolve(__dirname, '../fixtures/performance-critical.html')
},
thresholds: {
'first-contentful-paint': 1500,
'largest-contentful-paint': 2500,
'total-blocking-time': 300,
'max-potential-fid': 100,
'font-load-time': 800, // 自定义指标
'layout-shift-after-font-load': 0.05 // 自定义指标
},
viewports: [
{ width: 1280, height: 800 },
{ width: 375, height: 667 } // 移动设备
]
};
// 自定义Lighthouse指标收集器
function collectCustomFontMetrics() {
return {
name: 'font-performance',
title: 'Font Loading Performance',
description: 'Metrics related to font loading performance',
score: 1,
details: {
type: 'text',
items: []
},
metricDefinitions: [
{
id: 'font-load-time',
title: 'Font Load Time',
description: 'Time taken to load the Smiley Sans font',
score: (value) => {
// 800ms以内为优秀,1200ms以上为差
if (value < 800) return 1;
if (value > 1200) return 0;
return 1 - (value - 800) / 400;
}
},
{
id: 'layout-shift-after-font-load',
title: 'Layout Shift After Font Load',
description: 'Cumulative Layout Shift occurring after font loading',
score: (value) => {
// CLS小于0.05为优秀,大于0.15为差
if (value < 0.05) return 1;
if (value > 0.15) return 0;
return 1 - (value - 0.05) / 0.1;
}
}
]
};
}
// 运行Lighthouse性能测试
async function runLighthouseTest(url, options) {
const { page } = options;
// 启用性能跟踪
await page.tracing.start({ screenshots: true, snapshots: true });
// 导航到测试页面
const navigationPromise = page.waitForNavigation({ waitUntil: 'load' });
await page.goto(url);
await navigationPromise;
// 等待页面完全加载
await page.waitForFunction('window.fontLoadComplete === true', { timeout: 30000 });
// 停止跟踪
const trace = await page.tracing.stop();
// 运行Lighthouse分析
const result = await lighthouse(
url,
{
port: new URL(page.context().browser().wsEndpoint()).port,
output: 'json',
logLevel: 'info',
onlyCategories: ['performance'],
screenEmulation: {
mobile: options.viewport.width < 768,
width: options.viewport.width,
height: options.viewport.height,
deviceScaleFactor: 1,
disabled: false
}
},
{
extends: 'lighthouse:default',
settings: {
throttling: options.throttling || {
rttMs: 40,
throughputKbps: 10240,
cpuSlowdownMultiplier: 1,
requestLatencyMs: 0,
downloadThroughputKbps: 0,
uploadThroughputKbps: 0
},
onlyAudits: [
'first-contentful-paint',
'largest-contentful-paint',
'total-blocking-time',
'max-potential-fid',
'cumulative-layout-shift'
]
}
}
);
// 收集自定义字体指标
const customMetrics = await page.evaluate(() => {
return {
fontLoadTime: window.performance.getEntriesByName('SmileySans.woff2')[0]?.duration || 0,
layoutShiftAfterFontLoad: window.layoutShiftAfterFontLoad || 0,
firstRenderTime: window.firstRenderTime || 0,
finalRenderTime: window.finalRenderTime || 0
};
});
// 停止跟踪并保存结果
const outputDir = path.resolve(__dirname, `../results/performance/${new Date().toISOString().split('T')[0]}`);
fs.mkdirSync(outputDir, { recursive: true });
// 保存跟踪文件
writeFileSync(path.join(outputDir, `trace-${options.name}-${options.viewport.width}x${options.viewport.height}.json`), JSON.stringify(trace));
// 保存Lighthouse结果
const lighthouseResult = {
...result.lhr,
customMetrics,
timestamp: new Date().toISOString(),
testName: options.name,
viewport: options.viewport,
throttling: options.throttling
};
writeFileSync(
path.join(outputDir, `lighthouse-${options.name}-${options.viewport.width}x${options.viewport.height}.json`),
JSON.stringify(lighthouseResult, null, 2)
);
return lighthouseResult;
}
// 性能测试套件
test.describe('Smiley Sans Performance Tests', () => {
// 测试不同页面场景
const testScenarios = [
{ name: 'basic', url: performanceTestConfig.urls.basic, description: 'Basic page with minimal text' },
{ name: 'advanced', url: performanceTestConfig.urls.advanced, description: 'Advanced page with complex layout' },
{ name: 'critical', url: performanceTestConfig.urls.critical, description: 'Critical path rendering test' }
];
// 测试不同视口尺寸
performanceTestConfig.viewports.forEach(viewport => {
// 测试不同网络条件
const throttlingProfiles = [
{ name: 'unthrottled', config: null, description: 'No throttling' },
{
name: 'fast-3g',
config: {
rttMs: 150,
throughputKbps: 1638.4,
cpuSlowdownMultiplier: 1
},
description: 'Fast 3G network'
}
];
throttlingProfiles.forEach(throttling => {
testScenarios.forEach(scenario => {
test(`Scenario: ${scenario.name}, Viewport: ${viewport.width}x${viewport.height}, Network: ${throttling.name}`, async ({ page }) => {
// 设置视口
await page.setViewport(viewport);
// 运行性能测试
const result = await runLighthouseTest(scenario.url, {
page,
viewport,
throttling: throttling.config,
name: scenario.name
});
// 验证核心Web指标
const metrics = result.audits;
const customMetrics = result.customMetrics;
// 检查性能阈值
expect(metrics['first-contentful-paint'].numericValue).toBeLessThan(performanceTestConfig.thresholds['first-contentful-paint']);
expect(metrics['largest-contentful-paint'].numericValue).toBeLessThan(performanceTestConfig.thresholds['largest-contentful-paint']);
expect(metrics['total-blocking-time'].numericValue).toBeLessThan(performanceTestConfig.thresholds['total-blocking-time']);
expect(metrics['max-potential-fid'].numericValue).toBeLessThan(performanceTestConfig.thresholds['max-potential-fid']);
expect(customMetrics.fontLoadTime).toBeLessThan(performanceTestConfig.thresholds['font-load-time']);
expect(customMetrics.layoutShiftAfterFontLoad).toBeLessThan(performanceTestConfig.thresholds['layout-shift-after-font-load']);
}, 60000); // 性能测试超时时间延长至60秒
});
});
});
});
测试用例设计与管理
测试用例配置文件
创建YAML配置文件管理测试用例:
# config/test-cases.yaml
opentypeFeatures:
- id: ss01-style
name: Stylistic Set 01
feature: ss01
value: on
controlValue: off
text: "开心ABCDEFG"
fontSize: 48
detectionMethod: canvas
threshold: 0.05
expected: true
- id: tnum-tabular
name: Tabular Numbers
feature: tnum
value: on
text: "0123456789"
fontSize: 36
detectionMethod: width
threshold: 2
expected: true
- id: pnum-proportional
name: Proportional Numbers
feature: pnum
value: on
text: "0123456789"
fontSize: 36
detectionMethod: width
threshold: 2
expected: true
- id: liga-ligatures
name: Standard Ligatures
feature: liga
value: on
text: "ff fi fl ffi ffj"
fontSize: 48
detectionMethod: canvas
threshold: 0.1
expected: true
- id: calt-contextual
name: Contextual Alternates
feature: calt
value: on
text: "这是一段测试文本,看看上下文替换是否生效。"
fontSize: 32
detectionMethod: canvas
threshold: 0.03
expected: true
renderingTests:
- id: basic-cjk
name: Basic CJK Rendering
text: "这是一段基础中文测试文本,包含常用汉字和标点符号。"
fontSize: 16
referenceImage: "basic-cjk-reference.png"
tolerance: 0.5
- id: basic-latin
name: Basic Latin Rendering
text: "The quick brown fox jumps over the lazy dog. 0123456789"
fontSize: 16
referenceImage: "basic-latin-reference.png"
tolerance: 0.3
- id: large-size
name: Large Size Rendering
text: "大尺寸字体测试 123"
fontSize: 72
referenceImage: "large-size-reference.png"
tolerance: 0.7
- id: small-size
name: Small Size Rendering
text: "小尺寸字体测试 123"
fontSize: 12
referenceImage: "small-size-reference.png"
tolerance: 1.0
- id: color-emoji
name: Color Emoji Compatibility
text: "得意黑与Emoji混合测试 😊👍🎉"
fontSize: 24
referenceImage: "color-emoji-reference.png"
tolerance: 1.5
测试数据生成器
创建测试文本生成器,生成覆盖各种字形的测试数据:
// src/generators/test-text-generator.js
const fs = require('fs');
const path = require('path');
const { UnicodeTrie } = require('unicode-trie');
const emojiRegex = require('emoji-regex/RGI_Emoji.js');
class TestTextGenerator {
constructor() {
// 加载Unicode数据
this.loadUnicodeData();
}
loadUnicodeData() {
// 加载汉字覆盖率数据
this.cjkRanges = [
{ start: 0x4E00, end: 0x62FF }, // CJK统一汉字扩展A
{ start: 0x6300, end: 0x77FF }, // CJK统一汉字扩展B
{ start: 0x7800, end: 0x8CFF }, // CJK统一汉字扩展C
{ start: 0x8D00, end: 0x9FFF }, // CJK统一汉字扩展D
{ start: 0x3400, end: 0x4DBF }, // CJK统一汉字
{ start: 0x20000, end: 0x2A6DF }, // CJK统一汉字扩展F
];
// 加载常用标点符号
this.punctuationRanges = [
{ start: 0x20, end: 0x7E }, // 基本ASCII标点
{ start: 0x3000, end: 0x303F }, // CJK标点符号
{ start: 0xFF00, end: 0xFFEF }, // 全角ASCII和标点
];
}
// 生成随机汉字
generateRandomChineseCharacters(count = 100) {
let result = '';
for (let i = 0; i < count; i++) {
// 随机选择一个CJK区间
const range = this.cjkRanges[Math.floor(Math.random() * this.cjkRanges.length)];
// 在区间内随机选择一个字符
const codePoint = range.start + Math.floor(Math.random() * (range.end - range.start + 1));
result += String.fromCodePoint(codePoint);
}
return result;
}
// 生成特定类型的测试文本
generateTestText(type, options = {}) {
switch (type) {
case 'cjk-basic':
return this.generateBasicCJKTest(options);
case 'opentype-features':
return this.generateOpenTypeFeatureTest(options);
case 'stress-performance':
return this.generatePerformanceStressTest(options);
case 'edge-cases':
return this.generateEdgeCaseTest(options);
default:
throw new Error(`Unknown test text type: ${type}`);
}
}
generateBasicCJKTest(options) {
const { length = 500 } = options;
// 生成混合文本:汉字(70%) + 英文(20%) + 标点(10%)
let result = '';
const totalChars = length;
// 生成汉字部分
const cjkCount = Math.floor(totalChars * 0.7);
result += this.generateRandomChineseCharacters(cjkCount);
// 生成英文部分
const latinCount = Math.floor(totalChars * 0.2);
for (let i = 0; i < latinCount; i++) {
// 随机大小写字母
const isUpper = Math.random() > 0.5;
const code = isUpper
? 65 + Math.floor(Math.random() * 26) // A-Z
: 97 + Math.floor(Math.random() * 26); // a-z
result += String.fromCharCode(code);
}
// 生成标点部分
const punctuationCount = totalChars - cjkCount - latinCount;
for (let i = 0; i < punctuationCount; i++) {
// 随机选择标点区间
const range = this.punctuationRanges[Math.floor(Math.random() * this.punctuationRanges.length)];
const codePoint = range.start + Math.floor(Math.random() * (range.end - range.start + 1));
result += String.fromCodePoint(codePoint);
}
// 打乱字符顺序
return this.shuffleString(result);
}
generateOpenTypeFeatureTest(options) {
const { features = [] } = options;
let testText = '';
// 为不同OpenType特性添加特定测试文本
if (features.includes('liga') || features.includes('calt')) {
// 连笔测试文本
testText += 'ff fi fl ffi ffj ft ti tt ta';
}
if (features.includes('tnum') || features.includes('pnum')) {
// 数字测试文本
testText += ' 0123456789 0123456789 0123456789';
}
if (features.includes('ss01') || features.includes('ss02')) {
// 风格变体测试文本
testText += ' 开心喜悦你好再见日月水火金木土石';
}
if (features.includes('case') || features.includes('cpsp')) {
// 大小写测试文本
testText += ' Hello WORLD 123 TESTING Case Changes';
}
return testText;
}
generatePerformanceStressTest(options) {
const { length = 10000, complexity = 'medium' } = options;
// 根据复杂度生成不同的测试文本
let testText = '';
if (complexity === 'low') {
// 简单文本:重复的常用汉字
const baseText = this.generateRandomChineseCharacters(100);
while (testText.length < length) {
testText += baseText;
}
} else if (complexity === 'medium') {
// 中等复杂度:混合文本
testText = this.generateBasicCJKTest({ length });
} else {
// 高复杂度:包含多种字符类型
testText = this.generateBasicCJKTest({ length: Math.floor(length * 0.6) });
// 添加Emoji
const emojiReg = emojiRegex();
const emojiCount = Math.floor(length * 0.05);
for (let i = 0; i < emojiCount; i++) {
// 添加随机Emoji
const emojiMatch = emojiReg.exec('😀😁😂🤣😃😄😅😆😉😊😋😎😍😘🥰😗😙😚🙂🙃🙄😐😑😶😏😣😥😮🤐😯😪😫😴😌😛😜😝🤤😒😓😔😕🙁😖😞😟😤😢😭😦😧😨😩🤯😬😰😱😳🤪😵😡😠🤬😷🤒🤕🤢🤮🤧🥵🥶🥴😈👿👹👺🤡💩👻💀☠️👽👾🤖');
if (emojiMatch) {
testText = this.insertAtRandomPosition(testText, emojiMatch[0]);
}
}
// 添加特殊字符
const specialChars = '《》「」『』【】〔〕〖〗〘〙〚〛(){}[]<>〈〉‹›「」『』【】〔〕〖〗〘〙〚〛';
const specialCharCount = Math.floor(length * 0.05);
for (let i = 0; i < specialCharCount; i++) {
const char = specialChars[Math.floor(Math.random() * specialChars.length)];
testText = this.insertAtRandomPosition(testText, char);
}
}
return testText.substring(0, length);
}
generateEdgeCaseTest(options) {
// 边缘情况测试文本:包含特殊字符和组合
const edgeCases = [
// 零宽字符
'\u200B\u200C\u200D\uFEFF',
// 控制字符
'\t\n\r\f\v',
// 组合字符
'a\u0300à\u0301á\u0302â\u0303ã\u0304ā\u0305ā\u0306ǎ\u0307ä',
// 罕见标点
'※※※§§¶¶¶†‡•◦∙·⋅·•◦∙·',
// 特殊符号
'∀∁∂∃∄∅∆∇∈∉∊∋∌∍∎∏∐∑−∓∔∕∖∗∘∙√∛∜∝∞∟∠∡∢∣∤∥∦∧∨∩∪∫∬∭∮∯∰∱∲∳∴∵∶∷∸∹∺∻∼∽∾∿≀≁≂≃≄≅≆≇≈≉≊≋≌≍≎≏≐≑≒≓≔≕≖≗≘≙≚≛≜≝≞≟≠≡≢≣≤≥≦≧≨≩≪≫≬≭≮≯≰≱≲≳≴≵≶≷≸≹≺≻≼≽≾≿⊀⊁⊂⊃⊄⊅⊆⊇⊈⊉⊊⊋⊌⊍⊎⊏⊐⊑⊒⊓⊔⊕⊖⊗⊘⊙⊚⊛⊜⊝⊞⊟⊠⊡⊢⊣⊤⊥⊦⊧⊨⊩⊪⊫⊬⊭⊮⊯⊰⊱⊲⊳⊴⊵⊶⊷⊸⊹⊺⊻⊼⊽⊾⊿⋀⋁⋂⋃⋄⋅⋆⋇⋈⋉⋊⋋⋌⋍⋎⋏⋐⋑⋒⋓⋔⋕⋖⋗⋘⋙⋚⋛⋜⋝⋞⋟⋠⋡⋢⋣⋤⋥⋦⋧⋨⋩⋪⋫⋬⋭⋮⋯⋰⋱⋲⋳⋴⋵⋶⋷⋸⋹⋺⋻⋼⋽⋾⋿'
];
return edgeCases.join(' ') + ' ' + this.generateBasicCJKTest({ length: 500 });
}
// 辅助函数:打乱字符串
shuffleString(str) {
const arr = str.split('');
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr.join('');
}
// 辅助函数:在随机位置插入字符
insertAtRandomPosition(str, char) {
const pos = Math.floor(Math.random() * str.length);
return str.substring(0, pos) + char + str.substring(pos);
}
// 保存测试文本到文件
saveTestText(text, type, options = {}) {
const timestamp = new Date().toISOString().replace(/:/g, '-');
const complexity = options.complexity || 'medium';
const length = text.length;
const filename = `${type}-${complexity}-${length}-${timestamp}.txt`;
const outputDir = path.resolve(__dirname, '../../fixtures/test-texts');
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(path.join(outputDir, filename), text);
return filename;
}
}
// 生成测试文本并保存
const generator = new TestTextGenerator();
// 生成渲染测试文本
const basicRenderText = generator.generateBasicCJKTest({ length: 2000 });
generator.saveTestText(basicRenderText, 'render-basic', { complexity: 'medium' });
// 生成OpenType特性测试文本
const featureTestText = generator.generateOpenTypeFeatureTest({
features: ['liga', 'tnum', 'ss01', 'case']
});
generator.saveTestText(featureTestText, 'opentype-features', { features: ['liga', 'tnum', 'ss01', 'case'] });
// 生成性能测试文本
const perfLowText = generator.generatePerformanceStressTest({ length: 5000, complexity: 'low' });
generator.saveTestText(perfLowText, 'performance', { complexity: 'low' });
const perfMediumText = generator.generatePerformanceStressTest({ length: 20000, complexity: 'medium' });
generator.saveTestText(perfMediumText, 'performance', { complexity: 'medium' });
const perfHighText = generator.generatePerformanceStressTest({ length: 50000, complexity: 'high' });
generator.saveTestText(perfHighText, 'performance', { complexity: 'high' });
// 生成边缘情况测试文本
const edgeCaseText = generator.generateEdgeCaseTest();
generator.saveTestText(edgeCaseText, 'edge-cases');
module.exports = TestTextGenerator;
测试报告与可视化
自定义测试报告生成器
创建自定义报告生成器,将测试结果可视化为HTML报告:
// src/reporters/html-reporter.js
const fs = require('fs');
const path = require('path');
const handlebars = require('handlebars');
const { merge } = require('lodash');
const { format } = require('date-fns');
class HTMLReporter {
constructor(options = {}) {
this.options = merge({
outputDir: path.resolve(__dirname, '../../results/reports'),
templateDir: path.resolve(__dirname, '../../src/reporters/templates'),
reportTitle: 'Smiley Sans Font Test Report',
logoUrl: 'https://example.com/logo.png',
dateFormat: 'yyyy-MM-dd HH:mm:ss'
}, options);
// 加载模板
this.loadTemplates();
// 确保输出目录存在
fs.mkdirSync(this.options.outputDir, { recursive: true });
}
loadTemplates() {
// 加载主模板
const mainTemplatePath = path.join(this.options.templateDir, 'main.hbs');
this.mainTemplate = handlebars.compile(
fs.readFileSync(mainTemplatePath, 'utf8')
);
// 加载部分模板
const partialsDir = path.join(this.options.templateDir, 'partials');
const partialFiles = fs.readdirSync(partialsDir);
partialFiles.forEach(file => {
const partialName = path.basename(file, '.hbs');
const partialPath = path.join(partialsDir, file);
const partialContent = fs.readFileSync(partialPath, 'utf8');
handlebars.registerPartial(partialName, partialContent);
});
// 注册辅助函数
handlebars.registerHelper('formatDate', (dateString, formatStr) => {
return format(new Date(dateString), formatStr || this.options.dateFormat);
});
handlebars.registerHelper('ifEquals', function(a, b, options) {
if (a === b) {
return options.fn(this);
}
return options.inverse(this);
});
handlebars.registerHelper('percentage', (value, decimals = 2) => {
return (value * 100).toFixed(decimals) + '%';
});
handlebars.registerHelper('statusClass', (status) => {
if (status === 'passed') return 'status-passed';
if (status === 'failed') return 'status-failed';
if (status === 'skipped') return 'status-skipped';
return 'status-unknown';
});
handlebars.registerHelper('statusIcon', (status) => {
if (status === 'passed') return '✓';
if (status === 'failed') return '✗';
if (status === 'skipped') return '→';
return '?';
});
handlebars.registerHelper('metricClass', (value, threshold, higherIsBetter = false) => {
if (higherIsBetter) {
return value >= threshold ? 'metric-good' : 'metric-bad';
}
return value <= threshold ? 'metric-good' : 'metric-bad';
});
}
generateReport(testResults) {
// 处理测试结果数据
const reportData = this.prepareReportData(testResults);
// 渲染模板
const html = this.mainTemplate(reportData);
// 生成报告文件名
const timestamp = format(new Date(), 'yyyyMMdd-HHmmss');
const reportFileName = `smiley-sans-test-report-${timestamp}.html`;
const reportPath = path.join(this.options.outputDir, reportFileName);
// 复制静态资源
this.copyStaticAssets();
// 保存报告文件
fs.writeFileSync(reportPath, html, 'utf8');
return {
reportPath,
reportFileName,
timestamp
};
}
prepareReportData(testResults) {
// 聚合测试结果
const summary = {
totalTests: 0,
passedTests: 0,
failedTests: 0,
skippedTests: 0,
passRate: 0,
startTime: null,
endTime: null,
duration: 0
};
// 初始化时间
if (testResults.length > 0) {
summary.startTime = new Date(testResults[0].startTime);
summary.endTime = new Date(testResults[0].startTime);
}
// 处理每个测试套件的结果
const testSuites = {};
testResults.forEach(result => {
// 更新时间范围
const resultStartTime = new Date(result.startTime);
const resultEndTime = new Date(result.endTime);
if (resultStartTime < summary.startTime) {
summary.startTime = resultStartTime;
}
if (resultEndTime > summary.endTime) {
summary.endTime = resultEndTime;
}
// 初始化测试套件
if (!testSuites[result.suite]) {
testSuites[result.suite] = {
name: result.suite,
tests: [],
passed: 0,
failed: 0,
skipped: 0,
duration: 0
};
}
// 添加测试结果
testSuites[result.suite].tests.push({
name: result.testName,
status: result.status,
duration: result.duration,
error: result.error || null,
details: result.details || null,
screenshots: result.screenshots || [],
metrics: result.metrics || {}
});
// 更新套件统计
testSuites[result.suite].duration += result.duration;
if (result.status === 'passed') {
testSuites[result.suite].passed++;
summary.passedTests++;
} else if (result.status === 'failed') {
testSuites[result.suite].failed++;
summary.failedTests++;
} else if (result.status === 'skipped') {
testSuites[result.suite].skipped++;
summary.skippedTests++;
}
summary.totalTests++;
});
// 计算总体通过率
summary.passRate = summary.totalTests > 0
? (summary.passedTests / summary.totalTests) * 100
: 0;
// 计算总持续时间
summary.duration = summary.endTime - summary.startTime;
// 转换为数组
const suitesArray = Object.values(testSuites);
return {
title: this.options.reportTitle,
timestamp: new Date(),
summary,
suites: suitesArray,
environment: {
nodeVersion: process.version,
platform: process.platform,
arch: process.arch,
cpuCount: require('os').cpus().length,
totalMemory: Math.round(require('os').totalmem() / (1024 * 1024 * 1024)) + 'GB'
}
};
}
copyStaticAssets() {
const staticDir = path.join(this.options.templateDir, 'static');
const destDir = path.join(this.options.outputDir, 'static');
if (!fs.existsSync(staticDir)) {
return;
}
// 创建目标目录
fs.mkdirSync(destDir, { recursive: true });
// 复制静态文件
const copyRecursive = (src, dest) => {
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
fs.mkdirSync(destPath, { recursive: true });
copyRecursive(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
};
copyRecursive(staticDir, destDir);
}
}
module.exports = HTMLReporter;
集成与持续集成
Jest配置文件
创建Jest配置文件,集成所有测试:
// jest.config.js
module.exports = {
testMatch: [
'**/tests/**/*.test.js'
],
testTimeout: 30000,
maxWorkers: '50%',
reporters: [
'default',
[
'./src/reporters/jest-html-reporter.js',
{
pageTitle: 'Smiley Sans Font Test Report',
outputPath: 'results/jest-test-report.html',
includeFailureMsg: true,
includeConsoleLog: true
}
]
],
globalSetup: './src/setup/global-setup.js',
globalTeardown: './src/setup/global-teardown.js',
setupFilesAfterEnv: ['./src/setup/test-setup.js']
};
GitHub Actions工作流配置
创建CI配置文件,实现提交时自动运行测试:
# .github/workflows/font-test.yml
name: Font Test Suite
on:
push:
branches: [ main, development ]
paths:
- 'src/**/*.glyph'
- 'src/**/*.plist'
- 'tests/**/*.js'
- '.github/workflows/font-test.yml'
pull_request:
branches: [ main ]
paths:
- 'src/**/*.glyph'
- 'src/**/*.plist'
- 'tests/**/*.js'
jobs:
build-and-test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build font files
run: |
npm run build-woff2
npm run build-ttf
- name: Run test suite
run: npm test
- name: Generate test report
run: node scripts/generate-report.js
- name: Upload test results
uses: actions/upload-artifact@v3
with:
name: test-results
path: |
results/
!results/node_modules/
测试框架使用指南
基本命令
# 安装依赖
npm install
# 运行所有测试
npm test
# 运行特定测试套件
npm test -- tests/rendering.test.js
# 运行性能测试
npm run test:performance
# 运行特性测试
npm run test:features
# 生成测试报告
npm run generate-report
# 生成测试数据
npm run generate-test-data
配置测试参数
创建.env文件配置测试参数:
# 测试配置
TEST_VIEWPORT_WIDTH=1280
TEST_VIEWPORT_HEIGHT=800
TEST_TOLERANCE=0.5
REFERENCE_IMAGE_DIR=./fixtures/reference
TEST_FONT_PATH=./src/SmileySans.woff2
# 浏览器配置
PUPPETEER_HEADLESS=true
CHROME_PATH=
FIREFOX_PATH=
# 报告配置
REPORT_OUTPUT_DIR=./results/reports
REPORT_TITLE=Smiley Sans Test Report
总结与最佳实践
测试框架优势
本文开发的字体测试自动化框架为得意黑项目提供了多方面价值:
- 全面覆盖:三大维度(渲染、性能、兼容性)的测试覆盖
- 高度自动化:从测试执行到报告生成的全流程自动化
- 精准检测:像素级对比与特性检测确保测试准确性
- 丰富报告:可视化报告帮助快速定位问题
- CI集成:无缝集成到开发流程,实现持续测试
最佳实践建议
-
测试用例维护:
- 定期更新参考图像(建议每个主版本)
- 为新特性添加相应测试用例
- 定期审查和清理过时测试用例
-
性能优化:
- 对大型测试套件使用并行测试
- 合理设置测试超时时间
- 本地测试时复用浏览器实例
-
结果分析:
- 关注测试报告中的趋势变化
- 比较不同版本间的性能指标
- 建立基线,监控异常波动
-
测试环境:
- 保持测试环境一致性
- 使用Docker容器化测试环境
- 定期更新浏览器版本
未来扩展方向
- AI辅助测试:使用图像识别AI自动识别渲染异常
- 实时测试:集成到字体编辑器,实现实时测试反馈
- 扩展平台支持:添加移动设备测试支持
- 更丰富的指标:增加排版质量评估指标
- 自动化修复:自动生成字体修复建议
通过本文提供的测试框架和方法论,得意黑项目能够确保字体质量的稳定性和一致性,同时大幅提升开发迭代效率,为用户提供更好的字体体验。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



