14 测试、构建与 README:证明项目不是只能在你电脑上演示
14 测试、构建与 README:证明项目不是只能在你电脑上演示
本章你会做出什么
你会加入格式检查、类型检查和自动测试,执行生产构建,并编写能让他人启动项目的 README。项目完成标准不再是“页面大概能点”,而是重复执行验收命令均通过。
先理解三个概念
1. 类型检查、Lint 和格式化
- TypeScript 类型检查发现字段、参数与状态使用错误。
- ESLint 检查容易导致问题的代码写法。
- Prettier 检查代码排版是否一致。
2. 自动测试
自动测试是可重复执行的验证代码。本项目重点测试业务风险:工单流程是否正确、权限撤销后是否真的无法读取、纯工具函数是否恢复筛选和路由权限。
3. 构建
开发服务器适合写代码,生产构建会输出优化后的文件。构建通过表示项目至少可以打包交付。
本章最终目录变化
servicedesk-practice/
README.md
eslint.config.js
.prettierrc
server/src/app.test.ts
client/src/router/access.test.ts
client/src/utils/ticket-query.test.ts
client/src/utils/business.test.ts
一步一步操作
第 1 步:安装质量工具
npm install -D eslint @eslint/js typescript-eslint vue-eslint-parser eslint-plugin-vue prettier
npm install -D -w server vitest supertest @types/supertest
npm install -D -w client vitest vue-tsc
第 2 步:增加根目录脚本
根 package.json 的 scripts 补齐为:
{
"scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev -w server\" \"npm run dev -w client\"",
"build": "npm run build -w server && npm run build -w client",
"test": "npm run test -w server && npm run test -w client",
"typecheck": "npm run typecheck -w server && npm run typecheck -w client",
"lint": "eslint client/src server/src",
"format:check": "prettier --check \"client/src/**/*.{ts,vue,css}\" \"server/src/**/*.ts\" \"*.md\""
}
}
server/package.json 和 client/package.json 各增加:
"test": "vitest run"
第 3 步:优先测试最重要的后端行为
用 Supertest 直接调用 createApp(store, config)。创建 server/src/app.test.ts,复制以下完整文件:
import request from 'supertest'
import { beforeEach, describe, expect, it } from 'vitest'
import { createApp } from './app.js'
import type { AppConfig } from './config.js'
import { MemoryStore } from './store/memory-store.js'
const config: AppConfig = {
port: 3001,
jwtSecret: 'test-secret',
dbDriver: 'memory',
mysql: { host: '', port: 3306, user: '', password: '', database: '' }
}
describe('ServiceDesk API workflow', () => {
let app: ReturnType<typeof createApp>
let store: MemoryStore
beforeEach(async () => {
store = new MemoryStore()
await store.initialize()
app = createApp(store, config)
})
async function login(username: string) {
const response = await request(app).post('/api/v1/auth/login').send({ username, password: '123456' })
expect(response.status).toBe(200)
return response.body.data.accessToken as string
}
it('runs the create, assign, resolve and confirm-close workflow with role boundaries', async () => {
const userToken = await login('user')
const supervisorToken = await login('supervisor')
const agentToken = await login('agent')
const created = await request(app)
.post('/api/v1/tickets')
.set('Authorization', `Bearer ${userToken}`)
.send({ title: '新员工账号权限申请', categoryId: 1, priority: 'MEDIUM', description: '入职后无法登录邮箱系统。' })
expect(created.status).toBe(200)
expect(created.body.data.status).toBe('PENDING')
const id = created.body.data.id as number
const denied = await request(app)
.post(`/api/v1/tickets/${id}/assign`)
.set('Authorization', `Bearer ${userToken}`)
.send({ assigneeId: 2 })
expect(denied.status).toBe(403)
const assigned = await request(app)
.post(`/api/v1/tickets/${id}/assign`)
.set('Authorization', `Bearer ${supervisorToken}`)
.send({ assigneeId: 2, remark: '请优先排查账号状态' })
expect(assigned.body.data.status).toBe('PROCESSING')
const resolved = await request(app)
.post(`/api/v1/tickets/${id}/transition`)
.set('Authorization', `Bearer ${agentToken}`)
.send({ action: 'SUBMIT_RESOLUTION', remark: '已开通邮箱权限并验证登录成功。' })
expect(resolved.body.data.status).toBe('WAITING_CONFIRM')
const closed = await request(app)
.post(`/api/v1/tickets/${id}/transition`)
.set('Authorization', `Bearer ${userToken}`)
.send({ action: 'CONFIRM_CLOSE' })
expect(closed.body.data.status).toBe('CLOSED')
const detail = await request(app).get(`/api/v1/tickets/${id}`).set('Authorization', `Bearer ${userToken}`)
expect(detail.body.data.logs).toHaveLength(4)
})
it('limits the operations dashboard to staff roles', async () => {
const userToken = await login('user')
const supervisorToken = await login('supervisor')
const forbidden = await request(app).get('/api/v1/dashboard/overview').set('Authorization', `Bearer ${userToken}`)
const allowed = await request(app).get('/api/v1/dashboard/overview').set('Authorization', `Bearer ${supervisorToken}`)
expect(forbidden.status).toBe(403)
expect(allowed.status).toBe(200)
expect(allowed.body.data.cards).toHaveProperty('overdue')
})
it('allows a staff member to cancel their own pending request', async () => {
const agentToken = await login('agent')
const created = await request(app)
.post('/api/v1/tickets')
.set('Authorization', `Bearer ${agentToken}`)
.send({ title: '个人软件安装申请', categoryId: 4, priority: 'LOW', description: '需要安装调试辅助工具。' })
const cancelled = await request(app)
.post(`/api/v1/tickets/${created.body.data.id}/transition`)
.set('Authorization', `Bearer ${agentToken}`)
.send({ action: 'CANCEL' })
expect(cancelled.status).toBe(200)
expect(cancelled.body.data.status).toBe('CANCELLED')
})
it('enforces configurable ticket viewing permissions for list and detail requests', async () => {
const userToken = await login('user')
const visible = await request(app).get('/api/v1/tickets?mine=true').set('Authorization', `Bearer ${userToken}`)
expect(visible.body.data.total).toBeGreaterThan(0)
const userRole = (await store.listRoles()).find((role) => role.code === 'USER')!
await store.updateRolePermissions(
userRole.id,
userRole.permissionCodes.filter((permission) => permission !== 'ticket:view_own')
)
const hidden = await request(app).get('/api/v1/tickets?mine=true').set('Authorization', `Bearer ${userToken}`)
const deniedDetail = await request(app).get('/api/v1/tickets/1').set('Authorization', `Bearer ${userToken}`)
expect(hidden.status).toBe(200)
expect(hidden.body.data.total).toBe(0)
expect(deniedDetail.status).toBe(403)
await store.updateRolePermissions(userRole.id, userRole.permissionCodes)
const restored = await request(app).get('/api/v1/tickets/1').set('Authorization', `Bearer ${userToken}`)
expect(restored.status).toBe(200)
})
it('does not allow an agent to inspect an unassigned ticket created by another user', async () => {
const agentToken = await login('agent')
const denied = await request(app).get('/api/v1/tickets/2').set('Authorization', `Bearer ${agentToken}`)
expect(denied.status).toBe(403)
})
it('seeds processed tickets with a status timeline matching their current state', async () => {
const supervisorToken = await login('supervisor')
const processing = await request(app).get('/api/v1/tickets/1').set('Authorization', `Bearer ${supervisorToken}`)
const closed = await request(app).get('/api/v1/tickets/4').set('Authorization', `Bearer ${supervisorToken}`)
expect(processing.body.data.logs.map((log: { toStatus: string }) => log.toStatus)).toEqual(['PROCESSING', 'PENDING'])
expect(closed.body.data.logs.map((log: { toStatus: string }) => log.toStatus)).toEqual([
'CLOSED',
'WAITING_CONFIRM',
'PROCESSING',
'PENDING'
])
})
})
其中第四个用例直接证明权限不是只隐藏按钮:撤销 ticket:view_own 后,即使已有 Token 仍无法读取列表和详情。
第 4 步:测试前端纯逻辑
路由过滤和 query 工具无需打开浏览器。创建 client/src/router/access.test.ts:
import { describe, expect, it } from 'vitest'
import { filterAccessibleRoutes, isKnownProtectedPath } from './access'
const routes = [
{ path: '/dashboard', meta: { permission: ['dashboard:view', 'dashboard:view_own'] } },
{ path: '/tickets/my', meta: { permission: 'ticket:view_own' } },
{ path: '/tickets/:id', meta: { permission: ['ticket:view_own', 'ticket:view_assigned', 'ticket:view_all'] } }
]
describe('permission route helpers', () => {
it('only makes routes available when one required permission is present', () => {
expect(filterAccessibleRoutes(routes, ['ticket:view_own']).map((route) => route.path)).toEqual([
'/tickets/my',
'/tickets/:id'
])
expect(filterAccessibleRoutes(routes, ['dashboard:view_own']).map((route) => route.path)).toEqual(['/dashboard'])
})
it('recognizes unavailable dynamic protected paths for forbidden routing', () => {
expect(isKnownProtectedPath('/tickets/102', routes)).toBe(true)
expect(isKnownProtectedPath('/not-a-page', routes)).toBe(false)
})
})
创建 client/src/utils/ticket-query.test.ts:
import { describe, expect, it } from 'vitest'
import { readTicketListQuery, writeTicketListQuery } from './ticket-query'
describe('ticket list query state', () => {
it('round trips the filter and pagination state kept in the URL', () => {
const query = writeTicketListQuery({
page: 3,
pageSize: 20,
keyword: ' 网络问题 ',
status: 'PROCESSING',
priority: 'HIGH',
assigneeId: 2
})
expect(readTicketListQuery(query)).toEqual({
page: 3,
pageSize: 20,
keyword: '网络问题',
status: 'PROCESSING',
priority: 'HIGH',
assigneeId: 2
})
})
it('uses safe defaults for invalid query values', () => {
expect(readTicketListQuery({ page: '-1', pageSize: '1000', status: 'UNKNOWN', assigneeId: 'abc' })).toEqual({
page: 1,
pageSize: 100,
keyword: '',
status: undefined,
priority: undefined,
assigneeId: undefined
})
})
})
创建 client/src/utils/business.test.ts:
import { describe, expect, it, vi } from 'vitest'
import { priorityLabel, slaStatus, statusLabel } from './business'
describe('ticket display helpers', () => {
it('maps backend enums to readable labels', () => {
expect(statusLabel('WAITING_CONFIRM')).toBe('待确认')
expect(priorityLabel('HIGH')).toBe('高')
})
it('shows all SLA states with a fixed clock', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-24T12:00:00Z'))
expect(slaStatus({ status: 'PROCESSING', dueAt: '2026-05-24T11:59:59Z' }).label).toBe('已超时')
expect(slaStatus({ status: 'PROCESSING', dueAt: '2026-05-24T13:00:00Z' }).label).toBe('即将超时')
expect(slaStatus({ status: 'PROCESSING', dueAt: '2026-05-24T15:00:00Z' }).label).toBe('正常')
expect(slaStatus({ status: 'CLOSED', dueAt: '2026-05-23T11:00:00Z' }).label).toBe('已结束')
vi.useRealTimers()
})
})
第 5 步:写 README
README 至少包含:
- 业务背景与技术栈。
- 已真实实现的功能清单。
- 安装、默认内存启动、MySQL 启动步骤。
- 四个演示账号与统一密码。
- 五分钟演示路径。
- 验收命令。
不要写没有完成的附件、通知、WebSocket 或上线部署。
关键文件完整代码:验收命令说明
将下面内容放入 README 的“验证命令”部分:
## 验证命令
```bash
npm run typecheck
npm run lint
npm run format:check
npm test
npm run build
```
- `typecheck`:验证前后端 TypeScript 类型。
- `lint`:检查常见代码问题。
- `format:check`:检查格式一致性。
- `test`:验证权限、流程和前端纯函数。
- `build`:验证可生成生产构建产物。
启动并验证
在项目根目录依次运行:
npm run typecheck
npm run lint
npm run format:check
npm test
npm run build
任何一条失败都先修复,再开始面试截图或简历编写。特别确认测试包含:
- 普通用户有读取权限时可查看本人工单。
- 移除
ticket:view_own后列表为空、详情返回 403。 - 处理人员不能查看非本人创建且未分配给自己的工单。
- 完整状态闭环通过。
- 动态路由过滤、query 恢复和 SLA 标签函数通过。
常见报错与原因
| 现象 | 原因 | 修正 |
|---|---|---|
| 测试偶尔因 SLA 时间失败 | 测试依赖当前真实时间 | 用固定未来/过去时间或伪造系统时间 |
| 构建失败但开发页面可开 | 类型错误未被开发热更新阻止 | 逐条修复 npm run typecheck 错误 |
| 格式检查失败 | 手工缩进不一致 | 运行 npx prettier --write "client/src/**/*.{ts,vue,css}" "server/src/**/*.ts" |
本章完成清单
- 五条验收命令均通过。
- 测试覆盖读取权限被撤销后的结果。
- README 的功能描述与实际代码一致。
- 我能解释为什么测试选择权限和流程作为重点。
面试时这一章能怎么讲
我对高风险业务点编写自动测试:完整工单流转和读取权限撤销后的接口结果;前端测试动态路由过滤、列表 query 恢复与 SLA 工具函数。同时通过类型检查、Lint、格式检查和构建脚本作为交付门槛。