【核心功能篇】测试用例管理:设计用例新增&编辑界面
-
- 前言
-
- 准备工作
- [第一步:创建测试用例相关的 API 服务 (`src/api/testcase.ts`)](#第一步:创建测试用例相关的 API 服务 (
src/api/testcase.ts
)) - 第二步:创建测试用例编辑页面组件 (`src/views/testcase/TestCaseEditView.vue`)
- 第三步:配置测试用例编辑页的路由
- 第四步:测试用例新增&编辑功能
- 总结
前言
一个好的测试用例编辑界面应该具备以下特点:
- 清晰直观: 用户能够快速理解各个字段的含义和作用。
- 高效易用: 能够方便地输入和修改信息,特别是对于重复性的测试步骤。
- 结构化: 能够清晰地展示和管理测试步骤等复杂结构。
- 可扩展性: (虽然本篇可能不完全覆盖)未来可以方便地增加对参数化、数据驱动、自定义关键字等高级功能的支持。
在我们的 Django 后端模型 (api/models.py
中的 TestCase
) 中,我们暂时将 steps_text
定义为一个 TextField
,用于存储文本描述的测试步骤。为了实现一个更结构化的步骤编辑界面,前端在提交时需要将这些步骤组织成一种格式(例如 JSON 数组或特定分隔符的文本),而后端在保存或解析时需要能处理这种格式。
这篇文章将带你:
- 规划测试用例编辑页面的整体布局和交互。
- 设计并实现一个能够动态添加、删除和编辑测试步骤的表单区域。
- 将测试用例的创建和编辑功能与后端 API 进行联调。
我们将重点放在前端界面的设计与实现,以及与后端 API 的数据交互上。
准备工作
- 前端项目就绪:
test-platform/frontend
项目可以正常运行 (npm run dev
)。 - 后端 API 运行中: Django 后端服务运行(
python manage.py runserver
),测试用例的 API (/api/testcases/
) 可用。 - Axios 和 API 服务已封装:
utils/request.ts
和api/project.ts
,api/module.ts
已配置好。 - 项目和模块管理功能可用: 我们需要先有项目和模块,才能创建测试用例。
- Element Plus 集成完毕。
第一步:创建测试用例相关的 API 服务 (src/api/testcase.ts
)
与项目和模块类似,我们先为测试用例创建 API 服务文件。
typescript
// test-platform/frontend/src/api/testcase.ts
import request from '@/utils/request'
import type { AxiosPromise } from 'axios'
// 与后端 TestCase model 和 TestCaseSerializer 对应
export interface TestCase {
id: number;
name: string;
description: string | null;
module: number; // 所属模块 ID
module_name?: string; // 可选,如果 API 返回
project_id?: number; // 可选,如果 API 返回
project_name?: string; // 可选,如果 API 返回
priority: 'P0' | 'P1' | 'P2' | 'P3';
priority_display?: string;
precondition: string | null;
steps_text: string; // 后端存储的是合并后的文本
expected_result: string;
case_type: 'functional' | 'api' | 'ui';
case_type_display?: string;
maintainer: string | null;
create_time: string;
update_time: string;
}
export type TestCaseListResponse = TestCase[] // 假设列表直接返回数组
// 创建或更新测试用例时发送的数据类型
export interface UpsertTestCaseData {
name: string;
description?: string | null;
module: number; // 必须
priority?: 'P0' | 'P1' | 'P2' | 'P3';
precondition?: string | null;
steps_text: string; // 前端会将步骤数组合并为这个文本
expected_result?: string;
case_type?: 'functional' | 'api' | 'ui';
maintainer?: string | null;
}
// 1. 获取测试用例列表 (支持按模块或项目过滤)
export function getTestCaseList(params?: { module_id?: number, project_id?: number, search?: string }): AxiosPromise<TestCaseListResponse> {
return request({
url: '/testcases/',
method: 'get',
params
})
}
// 2. 创建测试用例
export function createTestCase(data: UpsertTestCaseData): AxiosPromise<TestCase> {
return request({
url: '/testcases/',
method: 'post',
data
})
}
// 3. 获取单个测试用例详情
export function getTestCaseDetail(testCaseId: number): AxiosPromise<TestCase> {
return request({
url: `/testcases/${testCaseId}/`,
method: 'get'
})
}
// 4. 更新测试用例
export function updateTestCase(testCaseId: number, data: Partial<UpsertTestCaseData>): AxiosPromise<TestCase> {
return request({
url: `/testcases/${testCaseId}/`,
method: 'put', // 或者 patch
data
})
}
// 5. 删除测试用例
export function deleteTestCase(testCaseId: number): AxiosPromise<void> {
return request({
url: `/testcases/${testCaseId}/`,
method: 'delete'
})
}
关键点:
UpsertTestCaseData
中的steps_text
字段,前端会将动态编辑的多个步骤描述合并成一个字符串传递给它。- 类型定义应尽量与后端 DRF Serializer 的输入输出保持一致。
第二步:创建测试用例编辑页面组件 (src/views/testcase/TestCaseEditView.vue
)
我们将创建一个新的路由页面专门用于新建和编辑测试用例。
a. 创建文件:
在 src/views/
目录下创建 testcase
文件夹,并在其中创建 TestCaseEditView.vue
。
b. 编写 TestCaseEditView.vue
的基本结构和表单:
vue
<!-- test-platform/frontend/src/views/testcase/TestCaseEditView.vue -->
<template>
<div class="testcase-edit-view" v-loading="pageLoading">
<el-page-header @back="goBack" :content="pageTitle" class="page-header-custom" />
<el-card class="form-card">
<el-form
ref="testCaseFormRef"
:model="formData"
:rules="formRules"
label-width="120px"
label-position="right"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="用例名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入用例名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="所属模块" prop="module">
<!-- 这里需要一个模块选择器,先用 Input 占位,后续改进 -->
<el-select
v-model="formData.module"
placeholder="请选择所属模块"
filterable
style="width: 100%;"
@focus="fetchModulesForSelect"
:loading="moduleSelectLoading"
>
<el-option
v-for="item in moduleOptions"
:key="item.id"
:label="`${item.project_name} - ${item.name}`"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="用例描述" prop="description">
<el-input v-model="formData.description" type="textarea" placeholder="请输入用例描述" />
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="优先级" prop="priority">
<el-select v-model="formData.priority" placeholder="请选择优先级" style="width: 100%;">
<el-option label="P0 - 最高" value="P0" />
<el-option label="P1 - 高" value="P1" />
<el-option label="P2 - 中" value="P2" />
<el-option label="P3 - 低" value="P3" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="用例类型" prop="case_type">
<el-select v-model="formData.case_type" placeholder="请选择用例类型" style="width: 100%;">
<el-option label="功能测试" value="functional" />
<el-option label="接口测试" value="api" />
<el-option label="UI测试" value="ui" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="维护人" prop="maintainer">
<el-input v-model="formData.maintainer" placeholder="请输入维护人名称" />
</el-form-item>
<el-form-item label="前置条件" prop="precondition">
<el-input v-model="formData.precondition" type="textarea" :rows="2" placeholder="请输入前置条件" />
</el-form-item>
<!-- 测试步骤区域 -->
<el-form-item label="测试步骤" prop="steps_text_ignored"> <!-- steps_text_ignored 仅用于触发表单项样式 -->
<div class="steps-editor">
<div v-for="(step, index) in formData.steps" :key="index" class="step-item">
<el-input
v-model="step.description"
type="textarea"
:autosize="{ minRows: 1, maxRows: 4 }"
placeholder="请输入步骤描述"
class="step-input"
/>
<el-button
type="danger"
:icon="Delete"
circle
size="small"
@click="removeStep(index)"
class="step-action-btn"
v-if="formData.steps.length > 1"
/>
</div>
<el-button type="primary" :icon="Plus" @click="addStep" plain size="small">
添加步骤
</el-button>
</div>
<!-- 隐藏的表单项,用于实际提交 steps_text,由 steps 数组生成 -->
<el-input v-model="computedStepsText" style="display: none;"></el-input>
</el-form-item>
<el-form-item label="预期结果" prop="expected_result">
<el-input v-model="formData.expected_result" type="textarea" :rows="3" placeholder="请输入预期结果" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
{{ isEditMode ? '更新用例' : '创建用例' }}
</el-button>
<el-button @click="goBack">取消</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElPageHeader } from 'element-plus' // 确保导入 ElPageHeader
import type { FormInstance, FormRules } from 'element-plus'
import { Plus, Delete } from '@element-plus/icons-vue'
import {
createTestCase,
getTestCaseDetail,
updateTestCase,
type UpsertTestCaseData,
type TestCase
} from '@/api/testcase'
import { getModuleList, type Module as ApiModule } from '@/api/module' // 获取所有模块用于选择器
interface FormStep {
description: string;
}
interface TestCaseFormData extends Omit<UpsertTestCaseData, 'steps_text'> {
steps: FormStep[]; // 前端用步骤数组来编辑
}
const route = useRoute()
const router = useRouter()
const pageLoading = ref(false)
const submitLoading = ref(false)
const testCaseFormRef = ref<FormInstance>()
const testCaseId = computed(() => route.params.id ? Number(route.params.id) : null)
const isEditMode = computed(() => !!testCaseId.value)
const pageTitle = computed(() => (isEditMode.value ? '编辑测试用例' : '新建测试用例'))
// 所属模块选择器的数据
const moduleOptions = ref<ApiModule[]>([])
const moduleSelectLoading = ref(false)
const initialFormData: TestCaseFormData = {
name: '',
description: null,
module: undefined as number | undefined, // 确保初始为 undefined 以便 placeholder 显示
priority: 'P1',
precondition: null,
steps: [{ description: '' }], // 至少有一个空步骤
expected_result: '',
case_type: 'functional',
maintainer: null,
}
const formData = reactive<TestCaseFormData>({ ...initialFormData })
const formRules = reactive<FormRules>({
name: [{ required: true, message: '用例名称不能为空', trigger: 'blur' }],
module: [{ required: true, message: '请选择所属模块', trigger: 'change' }],
priority: [{ required: true, message: '请选择优先级', trigger: 'change' }],
steps: [{ // 对 steps 数组的校验 (虽然我们主要校验合并后的 steps_text)
type: 'array',
required: true,
validator: (rule, value, callback) => {
if (!value || value.length === 0 || value.every((step: FormStep) => !step.description.trim())) {
callback(new Error('测试步骤不能为空'));
} else {
callback();
}
},
trigger: 'change'
}],
expected_result: [{ required: true, message: '预期结果不能为空', trigger: 'blur' }],
})
// 将步骤数组转换为提交给后端的 steps_text 字符串
const computedStepsText = computed(() => {
return formData.steps.map(step => step.description.trim()).filter(desc => desc).join('\n');
})
// 加载模块列表给选择器
const fetchModulesForSelect = async () => {
if (moduleOptions.value.length > 0 && !isEditMode.value) return; // 避免重复加载 (新建时如果已有数据则不重载)
// 编辑时可能需要强制重载,或当模块列表不常变时缓存
moduleSelectLoading.value = true;
try {
// 这里获取所有模块,实际项目中可能需要分页或搜索
// 如果模块非常多,这里需要优化,例如使用远程搜索的 Select
const response = await getModuleList(); // 这个 getModuleList 需要支持不传 projectId 获取所有
moduleOptions.value = response.data;
} catch (error) {
console.error('获取模块列表失败:', error);
ElMessage.error('获取模块列表失败');
} finally {
moduleSelectLoading.value = false;
}
}
// 加载用例详情 (编辑模式)
const loadTestCaseDetail = async () => {
if (!isEditMode.value || !testCaseId.value) return
pageLoading.value = true
try {
const response = await getTestCaseDetail(testCaseId.value)
const dataFromServer = response.data
// 回填表单数据
formData.name = dataFromServer.name
formData.description = dataFromServer.description
formData.module = dataFromServer.module
formData.priority = dataFromServer.priority
formData.precondition = dataFromServer.precondition
formData.expected_result = dataFromServer.expected_result
formData.case_type = dataFromServer.case_type
formData.maintainer = dataFromServer.maintainer
// 将 steps_text 解析回步骤数组
if (dataFromServer.steps_text) {
formData.steps = dataFromServer.steps_text.split('\n').map(desc => ({ description: desc }))
if (formData.steps.length === 0) { // 保证至少有一个空步骤输入框
formData.steps.push({ description: '' });
}
} else {
formData.steps = [{ description: '' }]
}
// 确保模块选择器中有当前模块的选项,如果没有,需要手动获取一次模块列表(或者在 fetchModulesForSelect 中处理)
if (formData.module && !moduleOptions.value.find(m => m.id === formData.module)) {
await fetchModulesForSelect(); // 重新获取模块列表以确保包含当前模块
}
} catch (error) {
ElMessage.error('获取用例详情失败')
console.error(error)
} finally {
pageLoading.value = false
}
}
onMounted(async () => {
await fetchModulesForSelect(); // 先加载模块选项
if (isEditMode.value) {
await loadTestCaseDetail()
}
})
// 动态步骤管理
const addStep = () => {
formData.steps.push({ description: '' })
}
const removeStep = (index: number) => {
if (formData.steps.length > 1) {
formData.steps.splice(index, 1)
} else {
ElMessage.warning('至少需要一个测试步骤')
}
}
const handleSubmit = async () => {
if (!testCaseFormRef.value) return
await testCaseFormRef.value.validate(async (valid) => {
if (valid) {
// 再次校验 steps 是否真的有内容(因为 formRules 对数组的校验可能不够精细)
if (!computedStepsText.value.trim()) {
ElMessage.error('测试步骤描述不能为空');
return;
}
submitLoading.value = true
const dataToSubmit: UpsertTestCaseData = {
name: formData.name,
description: formData.description,
module: formData.module!, // module 是必填的,这里可以用 ! 断言
priority: formData.priority,
precondition: formData.precondition,
steps_text: computedStepsText.value, // 使用合并后的文本
expected_result: formData.expected_result,
case_type: formData.case_type,
maintainer: formData.maintainer,
}
try {
if (isEditMode.value && testCaseId.value) {
await updateTestCase(testCaseId.value, dataToSubmit)
ElMessage.success('测试用例更新成功!')
} else {
await createTestCase(dataToSubmit)
ElMessage.success('测试用例创建成功!')
}
// 成功后可以跳转到用例列表页或详情页
// router.push(`/testcases/list?moduleId=${formData.module}`) // 假设有列表页
router.push({ name: 'testcases', query: { moduleId: formData.module } }) // 假设列表页路由名为 'testcases'
} catch (error) {
console.error('用例操作失败:', error)
// 全局错误提示已处理
} finally {
submitLoading.value = false
}
} else {
ElMessage.error('请检查表单填写是否正确!')
return false
}
})
}
const goBack = () => {
router.back()
}
</script>
<style scoped lang="scss">
.testcase-edit-view {
padding: 20px;
}
.page-header-custom {
margin-bottom: 20px;
background-color: #fff;
padding: 16px 24px;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
}
.form-card {
padding: 20px;
}
.steps-editor {
width: 100%;
.step-item {
display: flex;
align-items: center;
margin-bottom: 10px;
.step-input {
flex-grow: 1;
margin-right: 10px;
}
.step-action-btn {
// flex-shrink: 0; // 防止按钮被压缩
}
}
}
</style>
代码解释与关键点:
- 整体布局: 使用
ElPageHeader
提供返回和标题,ElCard
包裹表单。 - 表单字段: 根据
TestCase
模型定义了各个输入项。- 所属模块 (
formData.module
):- 使用
el-select
。 moduleOptions
通过调用getModuleList()
API (在api/module.ts
中) 来获取所有模块(这里暂时获取所有,实际项目可能需要优化为按项目筛选或远程搜索)。fetchModulesForSelect
在组件挂载时以及 Select 获得焦点时(如果选项为空)被调用。- 重要:
api/module.ts
中的getModuleList
函数需要能够不传projectId
时返回所有模块,或者你需要一个新的 API 来获取所有模块。这里假设getModuleList()
可以无参数调用以获取所有模块。
- 使用
- 所属模块 (
- 动态测试步骤 (
formData.steps
):formData.steps
是一个响应式数组,每个元素是{ description: string }
对象。- 使用
v-for
渲染每个步骤的输入框和删除按钮。 addStep()
方法向数组中添加一个新的空步骤。removeStep(index)
方法从数组中删除指定索引的步骤(至少保留一个)。computedStepsText
: 这是一个计算属性,它将formData.steps
数组中的description
合并成一个用换行符\n
分隔的字符串。这个计算属性的值将用于赋值给提交给后端的steps_text
字段。我们在模板中添加了一个隐藏的el-input
绑定到它,主要是为了方便调试时查看,实际提交时我们直接用这个计算属性的值。- 表单校验 (
formRules.steps
): 我们为steps
数组添加了一个自定义校验器,确保至少有一个步骤并且步骤描述不全为空。
- 编辑模式 (
isEditMode
):- 通过
route.params.id
判断当前是新建还是编辑模式。 pageTitle
动态显示。onMounted
中,如果是编辑模式,则调用loadTestCaseDetail()
。
- 通过
loadTestCaseDetail()
:- 调用
getTestCaseDetail
API 获取用例数据。 - 回填表单各个字段。
- 解析
steps_text
: 将从后端获取的steps_text
字符串按换行符分割,转换回formData.steps
数组。 - 模块选择器回显: 确保在编辑时,如果
formData.module
有值,moduleOptions
中包含该选项,否则 Select 可能无法正确显示已选模块。如果moduleOptions
中没有,则重新调用fetchModulesForSelect
。
- 调用
handleSubmit()
:- 表单校验。
- 特别校验
computedStepsText
确保合并后的步骤文本不为空。 - 构造提交给后端的数据
dataToSubmit
,其中steps_text
使用computedStepsText.value
。 - 根据
isEditMode
调用createTestCase
或updateTestCase
API。 - 成功后跳转到用例列表页 (我们暂时假设用例列表页的路由名为
testcases
,并且可以通过moduleId
查询参数筛选)。
修改 frontend/src/api/module.ts
中的 getModuleList
:
为了让模块选择器能获取所有模块,我们需要修改 api/module.ts
中的 getModuleList
函数,使其在不传递 projectId
时能获取所有模块。
假设后端 /api/modules/
在没有 project_id
参数时返回所有模块,那么前端 getModuleList
可以这样:
typescript
// test-platform/frontend/src/api/module.ts
// ...
// 1. 获取模块列表 (支持按项目ID过滤,不传则获取所有)
export function getModuleList(projectId?: number): AxiosPromise<ModuleListResponse> {
const params: { project_id?: number } = {};
if (projectId) {
params.project_id = projectId;
}
return request({
url: '/modules/',
method: 'get',
params // 如果 projectId 未定义,则 params 为空对象,不传 project_id 参数
})
}
// ...
后端 DRF ModuleViewSet
的相应调整:
确保 ModuleViewSet
的 get_queryset
方法在 project_id
未提供时返回所有模块。目前的实现(在上一篇文章中修改的)已经是这样了,如果 project_id
为 None
,则不过滤。
python
# api/views.py -> ModuleViewSet
class ModuleViewSet(viewsets.ModelViewSet):
# ...
def get_queryset(self):
queryset = super().get_queryset()
project_id = self.request.query_params.get('project_id', None) # 修改这里,如果没传,project_id 为 None
if project_id is not None: # 只有当 project_id 实际传递了才过滤
try:
queryset = queryset.filter(project_id=int(project_id))
except ValueError:
pass
return queryset.order_by('-create_time')
第三步:配置测试用例编辑页的路由
打开 frontend/src/router/index.ts
,添加新建和编辑测试用例的路由。
typescript
// test-platform/frontend/src/router/index.ts
// ... (在 Layout 的 children 中添加)
{
path: '/testcases', // 用例列表页 (我们将在下一篇创建)
name: 'testcases',
component: () => import('../views/project/TestCaseListView.vue'),
meta: { title: '用例管理', requiresAuth: true }
},
{
path: '/testcase/create', // 新建用例
name: 'testcaseCreate',
component: () => import('../views/testcase/TestCaseEditView.vue'),
meta: { title: '新建测试用例', requiresAuth: true }
},
{
path: '/testcase/edit/:id', // 编辑用例,:id 是用例ID
name: 'testcaseEdit',
component: () => import('../views/testcase/TestCaseEditView.vue'),
meta: { title: '编辑测试用例', requiresAuth: true },
props: true // 将路由参数 id 作为 props 传递给组件 (虽然我们组件内主要用 route.params)
},
// ...
说明:
- 我们为新建 (
/testcase/create
) 和编辑 (/testcase/edit/:id
) 都指向了同一个TestCaseEditView.vue
组件。组件内部通过route.params.id
是否存在来区分模式。 - 为编辑路由启用了
props: true
,虽然我们当前组件实现主要依赖useRoute()
,但这是一个好习惯。
第四步:测试用例新增&编辑功能
-
确保前后端服务运行,CORS 和 API 正常。
-
测试新建用例:
-
访问
http://127.0.0.1:5173/testcase/create
。 -
填写表单,包括选择所属模块,添加几个测试步骤。
-
点击"创建用例"。
-
观察 Network 面板的 API 请求 (
POST /api/testcases/
),查看请求体中的steps_text
是否是合并后的字符串。
-
看是否成功创建并跳转 (跳转目标页
TestCaseListView.vue
尚不存在,会显示空白,但 API 调用是会成功的)。
-
去 Django Admin 或通过 API 确认用例已创建,
steps_text
已保存。
-
-
测试编辑用例 (需要先通过 API 或 Django Admin 创建一个用例):
-
在上面我创建了一个 ID 为 3 的用例。
-
访问
http://127.0.0.1:5173/testcase/edit/3
。 -
页面应加载该用例的数据,表单应被回填,测试步骤应被正确解析并显示。
-
修改数据,例如增删步骤,修改其他字段。
-
点击"更新用例"。
-
观察 Network 面板的 API 请求 (
PUT /api/testcases/3/
)。 -
确认更新成功。
-
总结
在这篇文章中,我们攻克了测试用例管理中复杂的编辑界面设计与实现:
- ✅ 为测试用例创建了相应的 API 服务函数 (
api/testcase.ts
) 和 TypeScript 类型。 - ✅ 设计并实现了
TestCaseEditView.vue
组件,用于新建和编辑测试用例,其核心特性包括:- 一个包含用例名称、描述、所属模块选择器、优先级、类型、前置条件、预期结果等字段的综合表单。
- 一个动态的测试步骤编辑区域,用户可以方便地添加、删除和编辑多个步骤描述。
- 将前端编辑的步骤数组通过计算属性
computedStepsText
合并为后端steps_text
字段所需的单一字符串。 - 在编辑模式下,能够从后端 API 获取用例详情,并将
steps_text
解析回步骤数组以供编辑。
- ✅ 配置了新建和编辑测试用例的路由。
- ✅ 指导了如何测试新建和编辑用例的完整流程,并与后端 API 进行了联调。
- ✅ 解决了模块选择器的数据加载和编辑时选项回显的问题。
测试用例的创建和编辑是测试平台的核心功能,一个良好设计的界面能极大地提升测试人员的效率。
在下一篇文章中,我们将实现测试用例的列表展示与搜索功能 (TestCaseListView.vue
),让用户能够方便地查看、筛选和查找已创建的测试用例,并从列表跳转到编辑页面。