399 lines
8.7 KiB
Markdown
399 lines
8.7 KiB
Markdown
# 单元测试指南
|
|
|
|
本项目已添加前后端单元测试支持,以提升代码质量和可维护性。
|
|
|
|
## 后端测试 (Go - Goravel)
|
|
|
|
基于 [Goravel 测试框架](https://www.goravel.dev/zh_CN/testing/getting-started.html),使用 `testify/suite` 组织测试。
|
|
|
|
### 目录结构
|
|
|
|
```
|
|
tests/
|
|
├── test_case.go # 测试基类
|
|
├── feature/ # 功能测试(集成测试)
|
|
│ ├── main_test.go # 测试入口
|
|
│ ├── example_test.go
|
|
│ └── ...
|
|
├── unit/ # 单元测试
|
|
│ ├── main_test.go # 测试入口
|
|
│ ├── token_service_test.go
|
|
│ ├── tree_service_test.go
|
|
│ ├── ip_matcher_test.go
|
|
│ └── pagination_test.go
|
|
└── services/ # 服务层测试
|
|
└── main_test.go
|
|
```
|
|
|
|
### 运行测试
|
|
|
|
```bash
|
|
# 运行所有测试
|
|
go test ./tests/...
|
|
|
|
# 运行单元测试
|
|
go test ./tests/unit/...
|
|
|
|
# 运行功能测试
|
|
go test ./tests/feature/...
|
|
|
|
# 显示详细输出
|
|
go test -v ./tests/...
|
|
|
|
# 运行特定测试
|
|
go test -v -run TestTokenService ./tests/unit/...
|
|
|
|
# 生成覆盖率报告
|
|
go test -coverprofile=coverage.out ./tests/...
|
|
go tool cover -html=coverage.out -o coverage.html
|
|
```
|
|
|
|
### 创建测试
|
|
|
|
使用 Artisan 命令创建测试:
|
|
|
|
```bash
|
|
go run . artisan make:test unit/MyServiceTest
|
|
go run . artisan make:test feature/MyFeatureTest
|
|
```
|
|
|
|
### 测试示例
|
|
|
|
```go
|
|
// tests/unit/token_service_test.go
|
|
package unit
|
|
|
|
import (
|
|
"testing"
|
|
"github.com/stretchr/testify/suite"
|
|
"goravel/tests"
|
|
)
|
|
|
|
type TokenServiceTestSuite struct {
|
|
suite.Suite
|
|
tests.TestCase
|
|
}
|
|
|
|
func TestTokenServiceTestSuite(t *testing.T) {
|
|
suite.Run(t, new(TokenServiceTestSuite))
|
|
}
|
|
|
|
func (s *TokenServiceTestSuite) SetupTest() {
|
|
// 每个测试前执行
|
|
}
|
|
|
|
func (s *TokenServiceTestSuite) TearDownTest() {
|
|
// 每个测试后执行
|
|
}
|
|
|
|
func (s *TokenServiceTestSuite) TestHashToken() {
|
|
// 测试 token 哈希
|
|
s.Equal(64, len(hashToken("test")))
|
|
}
|
|
|
|
func (s *TokenServiceTestSuite) TestGenerateRandomToken() {
|
|
token1 := generateRandomToken()
|
|
token2 := generateRandomToken()
|
|
|
|
s.Len(token1, 40)
|
|
s.NotEqual(token1, token2)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 前端测试 (Vitest)
|
|
|
|
### 目录结构
|
|
|
|
前端测试支持两种组织方式:
|
|
|
|
#### 方式一:共置模式(当前使用)✅ 推荐
|
|
|
|
测试文件放在被测模块旁边的 `__tests__` 目录:
|
|
|
|
```
|
|
html/src/
|
|
├── composables/
|
|
│ ├── useCrud.js
|
|
│ ├── useDebounce.js
|
|
│ └── __tests__/ # 测试放在模块旁边
|
|
│ ├── useCrud.test.js
|
|
│ └── useDebounce.test.js
|
|
├── utils/
|
|
│ ├── validation.js
|
|
│ ├── storage.js
|
|
│ └── __tests__/
|
|
│ ├── validation.test.js
|
|
│ └── storage.test.js
|
|
└── components/
|
|
└── __tests__/
|
|
└── ...
|
|
```
|
|
|
|
**优点:**
|
|
- 测试文件与源码紧密关联,便于查找和维护
|
|
- 符合 Vue/React 社区最佳实践
|
|
- 模块独立性强,便于移动或删除
|
|
|
|
#### 方式二:集中模式
|
|
|
|
所有测试放在根目录的 `tests` 目录:
|
|
|
|
```
|
|
html/
|
|
├── src/
|
|
│ ├── composables/
|
|
│ ├── utils/
|
|
│ └── components/
|
|
└── tests/ # 所有测试集中存放
|
|
├── unit/
|
|
│ ├── composables/
|
|
│ │ ├── useCrud.test.js
|
|
│ │ └── useDebounce.test.js
|
|
│ └── utils/
|
|
│ ├── validation.test.js
|
|
│ └── storage.test.js
|
|
└── integration/
|
|
└── ...
|
|
```
|
|
|
|
**优点:**
|
|
- 测试代码与源码分离
|
|
- 与后端测试结构一致
|
|
|
|
### 运行测试
|
|
|
|
```bash
|
|
cd html
|
|
|
|
# 交互式监听模式
|
|
npm test
|
|
|
|
# 单次运行
|
|
npm run test:run
|
|
|
|
# 生成覆盖率报告
|
|
npm run test:coverage
|
|
```
|
|
|
|
### 当前测试文件
|
|
|
|
| 测试文件 | 覆盖内容 | 测试数量 |
|
|
|----------|----------|----------|
|
|
| `composables/__tests__/useDebounce.test.js` | 防抖功能 | 12 |
|
|
| `composables/__tests__/useCrud.test.js` | CRUD 操作 | 19 |
|
|
| `utils/__tests__/validation.test.js` | 验证器函数 | 41 |
|
|
| `utils/__tests__/storage.test.js` | Storage 工具 | 13 |
|
|
|
|
### 测试示例
|
|
|
|
```javascript
|
|
// src/utils/__tests__/validation.test.js
|
|
import { describe, it, expect } from 'vitest'
|
|
import { validators } from '../validation'
|
|
|
|
describe('validators', () => {
|
|
describe('required', () => {
|
|
it('应该拒绝空字符串', () => {
|
|
expect(validators.required('')).not.toBe(true)
|
|
})
|
|
|
|
it('应该接受有效字符串', () => {
|
|
expect(validators.required('hello')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('email', () => {
|
|
it('应该接受有效邮箱', () => {
|
|
expect(validators.email('test@example.com')).toBe(true)
|
|
})
|
|
})
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## 测试最佳实践
|
|
|
|
### 1. 命名约定
|
|
|
|
```go
|
|
// Go: TestSuiteName + TestMethodName
|
|
func (s *TokenServiceTestSuite) TestHashToken_ValidInput()
|
|
|
|
// JavaScript: describe + it
|
|
describe('validators', () => {
|
|
describe('required', () => {
|
|
it('应该拒绝空字符串', () => {})
|
|
})
|
|
})
|
|
```
|
|
|
|
### 2. 表格驱动测试 (Go)
|
|
|
|
```go
|
|
func (s *TokenServiceTestSuite) TestHashToken() {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected int
|
|
}{
|
|
{"正常 token", "test-token", 64},
|
|
{"空 token", "", 64},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
s.Run(tt.name, func() {
|
|
got := hashToken(tt.input)
|
|
s.Len(got, tt.expected)
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Mock 使用 (JavaScript)
|
|
|
|
```javascript
|
|
import { vi } from 'vitest'
|
|
|
|
// Mock 模块
|
|
vi.mock('element-plus', () => ({
|
|
ElMessage: {
|
|
success: vi.fn(),
|
|
error: vi.fn()
|
|
}
|
|
}))
|
|
|
|
// 验证调用
|
|
expect(ElMessage.success).toHaveBeenCalledWith('操作成功')
|
|
```
|
|
|
|
### 4. 异步测试
|
|
|
|
```javascript
|
|
// async/await
|
|
it('应该处理异步操作', async () => {
|
|
const result = await asyncFunction()
|
|
expect(result).toBe('expected')
|
|
})
|
|
|
|
// Fake timers
|
|
beforeEach(() => vi.useFakeTimers())
|
|
afterEach(() => vi.restoreAllMocks())
|
|
```
|
|
|
|
---
|
|
|
|
## CI/CD 集成
|
|
|
|
### GitHub Actions 示例
|
|
|
|
```yaml
|
|
# .github/workflows/test.yml
|
|
name: Tests
|
|
|
|
on: [push, pull_request]
|
|
|
|
jobs:
|
|
backend-tests:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: actions/setup-go@v4
|
|
with:
|
|
go-version: '1.24'
|
|
- run: go test -v ./tests/...
|
|
|
|
frontend-tests:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '20'
|
|
- run: cd html && npm install
|
|
- run: cd html && npm run test:run
|
|
```
|
|
|
|
---
|
|
|
|
## 测试覆盖率目标
|
|
|
|
| 模块类型 | 当前状态 | 目标覆盖率 |
|
|
|----------|----------|------------|
|
|
| 工具函数 | ✅ 已添加 | 80%+ |
|
|
| Composables | ✅ 已添加 (TypeScript) | 70%+ |
|
|
| Services | ✅ 已添加 | 60%+ |
|
|
| Controllers | ✅ 已添加(集成测试) | 40%+ |
|
|
|
|
---
|
|
|
|
## Controller 集成测试
|
|
|
|
后端 Controller 集成测试位于 `tests/feature/` 目录:
|
|
|
|
### 测试文件
|
|
|
|
| 文件 | 覆盖内容 |
|
|
|------|----------|
|
|
| `admin_api_test.go` | 管理员登录、信息、列表、角色、菜单、部门、日志等 |
|
|
| `blacklist_api_test.go` | 黑名单 CRUD、IP格式验证、批量删除 |
|
|
| `permission_test.go` | 权限列表、角色权限绑定 |
|
|
|
|
### 运行集成测试
|
|
|
|
```bash
|
|
# 运行所有集成测试(需要 Docker)
|
|
go test -v ./tests/feature/...
|
|
|
|
# 运行特定测试
|
|
go test -v -run TestAdminApiTestSuite ./tests/feature/...
|
|
go test -v -run TestBlacklistApiTestSuite ./tests/feature/...
|
|
```
|
|
|
|
### 测试示例
|
|
|
|
```go
|
|
func (s *AdminApiTestSuite) TestLogin_Success() {
|
|
body := strings.NewReader(`{"username":"admin","password":"admin123"}`)
|
|
resp, err := s.Http(s.T()).
|
|
WithHeader("Content-Type", "application/json").
|
|
Post("/api/admin/login", body)
|
|
|
|
s.Require().NoError(err)
|
|
resp.AssertSuccessful()
|
|
|
|
content, err := resp.Content()
|
|
s.Require().NoError(err)
|
|
|
|
var result map[string]any
|
|
json.Unmarshal([]byte(content), &result)
|
|
s.Equal(float64(200), result["code"])
|
|
}
|
|
```
|
|
|
|
### 注意事项
|
|
|
|
- 集成测试需要 Docker 环境(自动创建测试数据库和 Redis)
|
|
- 每个测试会执行 `RefreshDatabase()` 重置数据库
|
|
- 使用 `Seed()` 填充测试数据
|
|
|
|
---
|
|
|
|
## 常见问题
|
|
|
|
### Q: 前端测试应该放在哪里?
|
|
|
|
**A:** 推荐使用共置模式(`__tests__` 目录),这是 Vue/React 社区的最佳实践。
|
|
|
|
### Q: 后端测试为什么放在 tests 目录?
|
|
|
|
**A:** 这是 Goravel 框架的推荐做法,参考 [官方文档](https://www.goravel.dev/zh_CN/testing/getting-started.html)。
|
|
|
|
### Q: 如何添加新测试?
|
|
|
|
**A:**
|
|
- 后端:`go run . artisan make:test unit/MyTest`
|
|
- 前端:在对应模块的 `__tests__` 目录创建 `.test.js` 文件
|