test: 建立测试体系 - Vitest 单元+组件测试与测试策略文档
This commit is contained in:
@@ -47,6 +47,9 @@ npm run dev
|
|||||||
# 类型检查
|
# 类型检查
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
|
|
||||||
|
# 运行测试(Vitest 单元 + 组件测试)
|
||||||
|
npm test
|
||||||
|
|
||||||
# 生产构建
|
# 生产构建
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
@@ -83,8 +86,8 @@ src/
|
|||||||
|
|
||||||
- 作者:thzxx
|
- 作者:thzxx
|
||||||
- 组织:MetonaTeam
|
- 组织:MetonaTeam
|
||||||
- 开源协议:MIT License(见 [LICENSE](./LICENSE))
|
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
||||||
- 版本:0.2.0
|
- 版本:0.2.1
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -56,8 +56,8 @@ z = 补丁版本号(Patch)
|
|||||||
```
|
```
|
||||||
0.1.0 ← 初始版本(已发布)
|
0.1.0 ← 初始版本(已发布)
|
||||||
0.1.1 ← 体验打磨补丁(已发布):拖拽编码识别 · 行级右键菜单 · 尾部换行显示修正
|
0.1.1 ← 体验打磨补丁(已发布):拖拽编码识别 · 行级右键菜单 · 尾部换行显示修正
|
||||||
0.2.0 ← 导出差异报告(当前):HTML / 纯文本 / Markdown 三种格式
|
0.2.0 ← 导出差异报告(已发布):HTML / 纯文本 / Markdown 三种格式
|
||||||
0.2.1 ← 修复该版本 Bug
|
0.2.1 ← 建立测试体系(当前):Vitest 单元+组件测试 · 测试策略文档
|
||||||
0.3.0 ← 新增功能
|
0.3.0 ← 新增功能
|
||||||
...
|
...
|
||||||
0.y.z ← 长期停留,永不进入 1.x
|
0.y.z ← 长期停留,永不进入 1.x
|
||||||
|
|||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
# DiffLens 测试策略与方案
|
||||||
|
|
||||||
|
> 规范化 DiffLens 的测试体系:分层、选型、覆盖重点、运行与质量门禁。
|
||||||
|
> 配套文档:《应用开发与版本迭代规范》
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 目标与原则
|
||||||
|
|
||||||
|
- **保障正确性**:核心 diff 算法与报告生成必须被单元测试覆盖,防止回归
|
||||||
|
- **可回归**:每次迭代(`0.y.z`)在前都能稳定跑通测试
|
||||||
|
- **简单优先**:不做为测而测,优先覆盖纯逻辑与高风险模块
|
||||||
|
- **门禁前置**:`npm test` 纳入提交/推送前的强制检查
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 测试分层
|
||||||
|
|
||||||
|
| 层级 | 覆盖对象 | 工具 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 单元测试(UT) | 纯逻辑:diff 引擎、报告生成器 | Vitest |
|
||||||
|
| 组件测试 | React 组件:应用空态、工具栏交互 | Vitest + Testing Library |
|
||||||
|
| 集成(演进中) | Electron IPC / 多进程协作 | 预留 Playwright 或 Electron 测试 |
|
||||||
|
|
||||||
|
> 现阶段以**单元 + 组件**为主,覆盖最核心、最易回归的部分。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 技术栈
|
||||||
|
|
||||||
|
| 用途 | 选型 |
|
||||||
|
| --- | --- |
|
||||||
|
| 框架 | Vitest(与 Vite 生态一致,快) |
|
||||||
|
| 环境 | jsdom |
|
||||||
|
| React 测试 | @testing-library/react |
|
||||||
|
| 断言扩展 | @testing-library/jest-dom |
|
||||||
|
| 未来 E2E | Playwright(预留,暂不引入) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
vitest.config.ts # Vitest 配置(jsdom + 测试范围)
|
||||||
|
src/renderer/src/
|
||||||
|
├── test/setup.ts # 全局 setup:jest-dom 匹配器 + 自动 cleanup
|
||||||
|
├── diff/
|
||||||
|
│ ├── diffEngine.test.ts # diff 引擎单元测试
|
||||||
|
│ └── report.test.ts # 报告生成测试
|
||||||
|
└── __tests__/
|
||||||
|
└── App.test.tsx # 应用冒烟测试
|
||||||
|
```
|
||||||
|
|
||||||
|
> 约定:测试文件与被测文件同目录(`*.test.ts(x)`),或集中放各模块下 `__tests__`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 覆盖重点与用例
|
||||||
|
|
||||||
|
### 5.1 diff 引擎(`diffEngine.ts`)— 最高优先级
|
||||||
|
- 空文本 / 空对非空(全新增)
|
||||||
|
- 完全一致 → 全部 `unchanged`,无差异
|
||||||
|
- 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`)
|
||||||
|
- 词级:同一删除/新增配对为 `modified` 并产生左右 `segs`
|
||||||
|
- 忽略选项:`ignoreCase`、`trimWhitespace` 生效与关闭
|
||||||
|
- 行切分:结尾换行不产生多余空行
|
||||||
|
|
||||||
|
### 5.2 报告生成(`report.ts`)
|
||||||
|
- HTML:输出合法文档、含文件名/统计/类型高亮语义
|
||||||
|
- 纯文本:含增删改标记(`+ -`)
|
||||||
|
- Markdown:表格化、管道符转义保持表格结构
|
||||||
|
- 格式分发与扩展名映射(`REPORT_EXT`)
|
||||||
|
|
||||||
|
### 5.3 应用冒烟(`App.tsx`)
|
||||||
|
- 渲染应用名 `DiffLens`
|
||||||
|
- 未加载文件时展示左右空态面板
|
||||||
|
- 未加载文件时不显示导出入口(导出仅在加载后可用)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 运行命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # 运行全部测试(CI / 提交前)
|
||||||
|
npm run test:watch # 监听模式,开发中用
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 质量门禁
|
||||||
|
|
||||||
|
提交与推送前 **必须全部通过**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
任一失败即禁止提交;修复通过后再提交发布。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 覆盖率目标(演进基线)
|
||||||
|
|
||||||
|
| 模块 | 目标 |
|
||||||
|
| --- | --- |
|
||||||
|
| 逻辑层(diff / report) | ≥ 80% 行覆盖(当前以核心用例保证关键路径) |
|
||||||
|
| 组件层(App) | 覆盖主流程空态与关键交互 |
|
||||||
|
|
||||||
|
> 具体门槛随各模块成熟度逐步收紧;当前先保证**关键路径断言完整**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 演进方向(后续迭代)
|
||||||
|
|
||||||
|
- 接入 Playwright 对打包后的 Electron 应用做端到端验收
|
||||||
|
- 对 IPC 层(open / decode / save-report)补充集成测试
|
||||||
|
- 大文件性能基准测试(虚拟滚动落地后)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
© 2026 MetonaTeam · thzxx · DiffLens
|
||||||
Generated
+1157
-7
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"build:win": "electron-vite build && electron-builder --win",
|
"build:win": "electron-vite build && electron-builder --win",
|
||||||
"build:mac": "electron-vite build && electron-builder --mac",
|
"build:mac": "electron-vite build && electron-builder --mac",
|
||||||
@@ -27,6 +29,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||||
|
"@testing-library/jest-dom": "^6.10.0",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/diff": "^7.0.0",
|
"@types/diff": "^7.0.0",
|
||||||
"@types/node": "^22.5.0",
|
"@types/node": "^22.5.0",
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^18.3.5",
|
||||||
@@ -35,8 +39,10 @@
|
|||||||
"electron": "^33.0.0",
|
"electron": "^33.0.0",
|
||||||
"electron-builder": "^25.0.5",
|
"electron-builder": "^25.0.5",
|
||||||
"electron-vite": "^2.3.0",
|
"electron-vite": "^2.3.0",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"vite": "^5.4.6"
|
"vite": "^5.4.6",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
"esbuild@0.21.5": true,
|
"esbuild@0.21.5": true,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import App from '../App'
|
||||||
|
|
||||||
|
describe('App - 初始空态', () => {
|
||||||
|
it('渲染应用名称 DiffLens', () => {
|
||||||
|
render(<App />)
|
||||||
|
expect(screen.getByText('DiffLens')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('展示左右两个空态文件面板', () => {
|
||||||
|
render(<App />)
|
||||||
|
expect(screen.getByText(/选择左侧文件/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/选择右侧文件/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未加载文件时不显示导出入口', () => {
|
||||||
|
render(<App />)
|
||||||
|
expect(screen.queryByText('导出报告')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { computeDiff } from './diffEngine'
|
||||||
|
|
||||||
|
describe('computeDiff - 空文本', () => {
|
||||||
|
it('空 vs 空 不产生任何行', () => {
|
||||||
|
const { rows, summary } = computeDiff('', '')
|
||||||
|
expect(rows).toHaveLength(0)
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空 vs 非空 全部为新增行', () => {
|
||||||
|
const { rows, summary } = computeDiff('', 'a\nb')
|
||||||
|
expect(rows).toHaveLength(2)
|
||||||
|
expect(rows.every((r) => r.rowKind === 'added')).toBe(true)
|
||||||
|
expect(summary.inserted).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeDiff - 全同 / 无差异', () => {
|
||||||
|
it('内容完全相同:全部 unchanged', () => {
|
||||||
|
const { rows, summary } = computeDiff('line1\nline2', 'line1\nline2')
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
expect(rows.every((r) => r.rowKind === 'unchanged')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeDiff - 行级增删', () => {
|
||||||
|
it('新增行', () => {
|
||||||
|
const { rows, summary } = computeDiff('a', 'a\nb')
|
||||||
|
const added = rows.filter((r) => r.rowKind === 'added')
|
||||||
|
expect(added).toHaveLength(1)
|
||||||
|
expect(added[0].right.lineNo).toBe(2)
|
||||||
|
expect(added[0].right.text).toBe('b')
|
||||||
|
expect(added[0].left.lineNo).toBeNull()
|
||||||
|
expect(summary.inserted).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('删除行', () => {
|
||||||
|
const { rows, summary } = computeDiff('a\nb', 'a')
|
||||||
|
const removed = rows.filter((r) => r.rowKind === 'removed')
|
||||||
|
expect(removed).toHaveLength(1)
|
||||||
|
expect(removed[0].left.lineNo).toBe(2)
|
||||||
|
expect(removed[0].left.text).toBe('b')
|
||||||
|
expect(removed[0].right.lineNo).toBeNull()
|
||||||
|
expect(summary.deleted).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeDiff - 词级修改', () => {
|
||||||
|
it('同一删除/新增配对为 modified 并产生 segs', () => {
|
||||||
|
const { rows, summary } = computeDiff('hello world', 'hello moon')
|
||||||
|
expect(summary.modified).toBe(1)
|
||||||
|
const m = rows.find((r) => r.rowKind === 'modified')
|
||||||
|
expect(m).toBeDefined()
|
||||||
|
expect(m!.left.segs).not.toBeNull()
|
||||||
|
expect(m!.right.segs).not.toBeNull()
|
||||||
|
expect(m!.left.segs!.some((s) => s.kind === 'delete' && s.text.includes('world'))).toBe(true)
|
||||||
|
expect(m!.right.segs!.some((s) => s.kind === 'insert' && s.text.includes('moon'))).toBe(true)
|
||||||
|
expect(m!.left.lineNo).toBe(1)
|
||||||
|
expect(m!.right.lineNo).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('删除多于新增时长侧补空槽', () => {
|
||||||
|
const { rows } = computeDiff('a\nb', 'a')
|
||||||
|
const removed = rows.find((r) => r.rowKind === 'removed')
|
||||||
|
expect(removed!.right.text).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeDiff - 忽略选项', () => {
|
||||||
|
it('忽略大小写', () => {
|
||||||
|
const { summary } = computeDiff('Alpha', 'alpha', { ignoreCase: true })
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('忽略行首尾空白', () => {
|
||||||
|
const { summary } = computeDiff(' value ', 'value', { trimWhitespace: true })
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未开启忽略时同一纯净差异仍被识别', () => {
|
||||||
|
const { summary } = computeDiff(' value ', 'value')
|
||||||
|
expect(summary.changedLines).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeDiff - 行切分细节', () => {
|
||||||
|
it('结尾换行不产生多余空行', () => {
|
||||||
|
const { rows, summary } = computeDiff('a\n', 'a')
|
||||||
|
expect(rows).toHaveLength(1)
|
||||||
|
expect(rows[0].rowKind).toBe('unchanged')
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { computeDiff } from './diffEngine'
|
||||||
|
import { buildHtmlReport, buildTxtReport, buildMarkdownReport, buildReport, REPORT_EXT } from './report'
|
||||||
|
|
||||||
|
const ctx = { leftName: 'a.txt', rightName: 'b.txt', leftEncoding: 'UTF-8', rightEncoding: 'UTF-8' }
|
||||||
|
|
||||||
|
describe('buildHtmlReport', () => {
|
||||||
|
const { rows, summary } = computeDiff('line1\nline2', 'line1\nline3')
|
||||||
|
|
||||||
|
it('输出合法 HTML 文档并包含核心信息', () => {
|
||||||
|
const html = buildHtmlReport(rows, ctx, summary)
|
||||||
|
expect(html).toContain('<!doctype html>')
|
||||||
|
expect(html).toContain('DiffLens 差异报告')
|
||||||
|
expect(html).toContain('a.txt')
|
||||||
|
expect(html).toContain('b.txt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('包含差异统计与类型高亮语义', () => {
|
||||||
|
const html = buildHtmlReport(rows, ctx, summary)
|
||||||
|
expect(html).toContain('modified')
|
||||||
|
expect(html).toContain('<b>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildTxtReport', () => {
|
||||||
|
it('输出带增删改标记的行式文本', () => {
|
||||||
|
const { rows, summary } = computeDiff('a', 'a\nb')
|
||||||
|
const txt = buildTxtReport(rows, ctx, summary)
|
||||||
|
expect(txt).toContain('DiffLens')
|
||||||
|
expect(txt).toContain('+')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildMarkdownReport', () => {
|
||||||
|
it('输出表格化报告', () => {
|
||||||
|
const { rows, summary } = computeDiff('a', 'b')
|
||||||
|
const md = buildMarkdownReport(rows, ctx, summary)
|
||||||
|
expect(md).toContain('# DiffLens 差异报告')
|
||||||
|
expect(md).toContain('| 类型 |')
|
||||||
|
expect(md).toContain('| 左行号 |')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('管道符被转义以保持表格结构', () => {
|
||||||
|
const { rows, summary } = computeDiff('a|b', 'a')
|
||||||
|
const md = buildMarkdownReport(rows, ctx, summary)
|
||||||
|
expect(md).toContain('a\\|b')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildReport / REPORT_EXT', () => {
|
||||||
|
it('按格式分发', () => {
|
||||||
|
const { rows, summary } = computeDiff('x', 'y')
|
||||||
|
expect(buildReport(rows, ctx, summary, 'html')).toContain('<!doctype html>')
|
||||||
|
expect(buildReport(rows, ctx, summary, 'txt')).toContain('DiffLens')
|
||||||
|
expect(buildReport(rows, ctx, summary, 'md')).toContain('# DiffLens 差异报告')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('扩展名映射正确', () => {
|
||||||
|
expect(REPORT_EXT.html).toBe('html')
|
||||||
|
expect(REPORT_EXT.txt).toBe('txt')
|
||||||
|
expect(REPORT_EXT.md).toBe('md')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { afterEach } from 'vitest'
|
||||||
|
import { cleanup } from '@testing-library/react'
|
||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
|
|
||||||
|
// 每个用例结束自动卸载组件,避免多次 render 叠加污染 DOM
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@renderer': resolve('src/renderer/src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: false,
|
||||||
|
setupFiles: ['./src/renderer/src/test/setup.ts'],
|
||||||
|
include: ['src/renderer/src/**/*.{test,spec}.{ts,tsx}'],
|
||||||
|
exclude: ['node_modules', 'dist', 'out']
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user