十三、【核心功能篇】测试计划管理:组织和编排测试用例
【核心功能篇】测试计划管理:组织和编排测试用例
- 前言
- 准备工作
- 第一部分:后端实现 (Django)
- 1. 定义 `TestPlan` 模型
- 2. 生成并应用数据库迁移
- 3. 创建 `TestPlanSerializer`
- 4. 创建 `TestPlanViewSet`
- 5. 注册路由
- 6. 注册到 Django Admin
- 第二部分:前端实现 (Vue3)
- 1. 创建 `TestPlan` 相关的 API 服务 (`src/api/testplan.ts`)
- 2. 添加测试计划的路由
- 3. 创建测试计划编辑页面 (`src/views/testplan/TestPlanEditView.vue`)
- 4. 创建测试计划列表页面 (`src/views/testplan/TestPlanListView.vue`)
- 5. 在主布局侧边栏添加入口
- 第五步:测试完整流程
- 总结
前言
随着测试用例数量的增加,如何有效地组织和管理这些用例以进行特定目的的测试(例如回归测试、新功能测试)就变得至关重要。测试计划 (Test Plan) 允许我们将相关的测试用例组合成一个可执行的单元。
这篇文章将带你:
- 在后端 Django 中设计和实现
TestPlan
数据模型及其 API。 - 在前端 Vue3 中创建测试计划的管理页面,包括列表展示。
- 设计并实现一个用户友好的界面,用于创建和编辑测试计划,特别是如何从现有用例库中选择测试用例并关联到计划中。
我们将使用 Element Plus 的 ElTransfer
(穿梭框) 组件来实现测试用例的选择功能。
一个测试计划通常包含以下信息:
- 基本信息: 计划名称、描述、所属项目等。
- 包含的测试用例: 一个计划会包含一个或多个选定的测试用例。
- (可选) 执行策略、环境配置等: 这些我们暂时不在此篇详细展开,但会为数据模型留有余地。
我们的目标是让用户能够:
- 创建新的测试计划,并为其关联项目。
- 从指定项目的测试用例库中,方便地选择一批用例加入到测试计划中。
- 编辑已有的测试计划,可以修改基本信息或增删其包含的测试用例。
- 查看测试计划列表,并能删除不再需要的计划。
准备工作
- 前端项目就绪:
test-platform/frontend
项目可以正常运行 (npm run dev
)。 - 后端 API 运行中: Django 后端服务运行(
python manage.py runserver
)。项目、模块、测试用例的 API 均可用。 - Axios 和 API 服务已封装:
utils/request.ts
及api/project.ts
,api/module.ts
,api/testcase.ts
已配置。 - 测试用例管理功能基本可用: 我们需要有测试用例数据才能将其添加到计划中。
第一部分:后端实现 (Django)
1. 定义 TestPlan
模型
打开 test-platform/api/models.py
,添加 TestPlan
模型:
# test-platform/api/models.py
# ... (BaseModel, Project, Module, TestCase 定义保持不变) ...class TestPlan(BaseModel): # 继承自我们的 BaseModel"""测试计划表"""project = models.ForeignKey(Project, on_delete=models.CASCADE, verbose_name="所属项目", related_name="test_plans")test_cases = models.ManyToManyField(TestCase, verbose_name="包含用例", related_name="test_plans_containing", blank=True)# creator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="创建人") # 如果有用户系统# status_choices = [ (0, '草稿'), (1, '待执行'), (2, '执行中'), (3, '已完成') ]# status = models.PositiveSmallIntegerField(choices=status_choices, default=0, verbose_name="计划状态")class Meta:verbose_name = "测试计划"verbose_name_plural = "测试计划列表"unique_together = ('project', 'name') # 同一项目下的测试计划名称应唯一ordering = ['-create_time']def __str__(self):return f"{self.project.name} - {self.name}"
关键点:
project
: 外键关联到Project
,一个测试计划属于一个项目。test_cases
: 多对多字段关联到TestCase
,一个计划可以包含多个用例,一个用例也可以属于多个计划。blank=True
允许创建时没有用例。
2. 生成并应用数据库迁移
在项目根目录 ( test-platform
,取决于你的 manage.py
位置) 的终端中运行:
python manage.py makemigrations api
python manage.py migrate
3. 创建 TestPlanSerializer
打开 test-platform/api/serializers.py
,添加:
# test-platform/api/serializers.py
# ... (ProjectSerializer, ModuleSerializer, TestCaseSerializer 定义保持不变) ...
from .models import TestPlan # 确保导入 TestPlanclass TestPlanSerializer(serializers.ModelSerializer):"""测试计划序列化器"""project_name = serializers.CharField(source='project.name', read_only=True)# test_cases 字段默认会序列化为关联 TestCase 的 ID 列表,这对于创建/更新是合适的# 如果在获取详情时希望看到用例的更多信息,可以考虑嵌套序列化或 SerializerMethodField# 使用 SerializerMethodField 在获取详情时返回用例的详细信息(例如 id 和 name)test_case_details = serializers.SerializerMethodField(read_only=True)class Meta:model = TestPlanfields = ['id', 'name', 'description', 'project', 'project_name', 'test_cases', 'test_case_details', 'create_time', 'update_time']extra_kwargs = {'test_cases': {'write_only': False, 'required': False, 'help_text': "关联的测试用例ID列表"},# 'test_cases' 在创建/更新时接收ID列表,在读取时也会显示ID列表。# 如果不希望读取时显示 test_cases ID 列表 (因为有了 test_case_details), 可以设置 'read_only': False, 'write_only': True# 但通常保留ID列表在读取时也是有用的。}def get_test_case_details(self, obj: TestPlan):# obj 是 TestPlan 实例# 返回一个包含所选测试用例的 id 和 name 的列表# 这样前端在显示已选测试用例时,除了ID还能看到名称# 注意:这可能会导致 N+1 查询问题,如果用例数量很多,需要优化 (例如使用 prefetch_related)return obj.test_cases.values('id', 'name') # .all() 返回 QuerySet, .values() 返回字典列表
关键点:
project_name
: 只读字段,显示项目名称。test_cases
:- 对于写操作 (POST/PUT/PATCH),DRF 的
ManyToManyField
默认期望接收一个主键 ID 列表。例如[1, 2, 3]
。 - 对于读操作 (GET),默认也会返回主键 ID 列表。
- 对于写操作 (POST/PUT/PATCH),DRF 的
test_case_details
: 使用SerializerMethodField
在 GET 请求的响应中额外提供关联测试用例的ID和名称。这对于前端在编辑测试计划时,回显已选测试用例的名称非常有用,而不必再次查询每个用例的名称。
4. 创建 TestPlanViewSet
打开 test-platform/api/views.py
,添加:
# test-platform/api/views.py
# ... (其他 ViewSet 定义保持不变) ...
from .models import TestPlan # 确保导入
from .serializers import TestPlanSerializer # 确保导入class TestPlanViewSet(viewsets.ModelViewSet):"""测试计划管理视图集"""queryset = TestPlan.objects.all().order_by('-update_time')serializer_class = TestPlanSerializerfilter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]filterset_fields = {'project_id': ['exact'], # /api/testplans/?project_id=1}search_fields = ['name', 'description']ordering_fields = ['id', 'name', 'project', 'update_time']def get_queryset(self):# 预取关联的 test_cases 来优化 get_test_case_details 序列化方法字段的性能return super().get_queryset().prefetch_related('test_cases', 'project')
关键点:
filterset_fields
: 允许通过project_id
筛选测试计划。get_queryset()
: 重写并使用prefetch_related('test_cases', 'project')
来优化序列化时对关联test_cases
和project
的访问,避免 N+1 查询。
5. 注册路由
打开 test-platform/api/urls.py
,注册 TestPlanViewSet
:
# test-platform/api/urls.py
# ... (其他 router.register 调用保持不变) ...
from .views import ProjectViewSet, ModuleViewSet, TestCaseViewSet, TestPlanViewSet # 导入 TestPlanViewSet# ...
router.register(r'testplans', TestPlanViewSet, basename='testplan') # 新增
# ...
6. 注册到 Django Admin
打开 test-platform/api/admin.py
:
# test-platform/api/admin.py
from django.contrib import admin
from .models import Project, Module, TestCase, TestPlan # 导入 TestPlan# ...
admin.site.register(TestPlan)
现在后端 API 已经准备好了。你可以启动 Django 服务,并通过可浏览 API (http://127.0.0.1:8000/api/testplans/
) 或 Postman 进行初步测试。
第二部分:前端实现 (Vue3)
1. 创建 TestPlan
相关的 API 服务 (src/api/testplan.ts
)
// test-platform/frontend/src/api/testplan.ts
import request from '@/utils/request'
import type { AxiosPromise } from 'axios'
import type { PaginatedResponse } from './testcase' // 复用分页类型export interface TestCaseBrief { // 用于 TestPlan 中 test_case_detailsid: number;name: string;
}export interface TestPlan {id: number;name: string;description: string | null;project: number;project_name?: string;test_cases: number[]; // 关联的测试用例ID列表test_case_details?: TestCaseBrief[]; // 后端序列化器提供create_time: string;update_time: string;
}export type TestPlanListResponse = PaginatedResponse<TestPlan>export interface UpsertTestPlanData {name: string;description?: string | null;project: number;test_cases?: number[]; // 提交时用例ID列表
}// 1. 获取测试计划列表
export function getTestPlanList(params?: { page?: number; page_size?: number; project_id?: number | null; search?: string }): AxiosPromise<TestPlanListResponse> {return request({url: '/testplans/',method: 'get',params})
}// 2. 创建测试计划
export function createTestPlan(data: UpsertTestPlanData): AxiosPromise<TestPlan> {return request({url: '/testplans/',method: 'post',data})
}// 3. 获取单个测试计划详情
export function getTestPlanDetail(testPlanId: number): AxiosPromise<TestPlan> {return request({url: `/testplans/${testPlanId}/`,method: 'get'})
}// 4. 更新测试计划
export function updateTestPlan(testPlanId: number, data: Partial<UpsertTestPlanData>): AxiosPromise<TestPlan> {return request({url: `/testplans/${testPlanId}/`,method: 'put',data})
}// 5. 删除测试计划
export function deleteTestPlan(testPlanId: number): AxiosPromise<void> {return request({url: `/testplans/${testPlanId}/`,method: 'delete'})
}
关键点:
TestPlan
接口中,test_cases
是数字数组 (ID 列表),test_case_details
是可选的对象数组 (包含 ID 和 name),对应后端 Serializer 的输出。UpsertTestPlanData
中test_cases
是提交给后端的用例 ID 列表。
2. 添加测试计划的路由
打开 frontend/src/router/index.ts
:
// test-platform/frontend/src/router/index.ts
// ... (在 Layout 的 children 中添加){path: '/testplans', // 测试计划列表页name: 'testplans',component: () => import('../views/testplan/TestPlanListView.vue'), // 待创建meta: { title: '测试计划', requiresAuth: true }},{path: '/testplan/create', // 新建测试计划name: 'testplanCreate',component: () => import('../views/testplan/TestPlanEditView.vue'), // 待创建meta: { title: '新建测试计划', requiresAuth: true }},{path: '/testplan/edit/:id', // 编辑测试计划name: 'testplanEdit',component: () => import('../views/testplan/TestPlanEditView.vue'),meta: { title: '编辑测试计划', requiresAuth: true },props: true},
// ...
3. 创建测试计划编辑页面 (src/views/testplan/TestPlanEditView.vue
)
这个页面的核心是测试用例的选择,我们将使用 Element Plus 的 ElTransfer
组件。
a. 创建文件:
在 src/views/
目录下创建 testplan
文件夹,并在其中创建 TestPlanEditView.vue
。
b. 编写 TestPlanEditView.vue
:
<!-- test-platform/frontend/src/views/testplan/TestPlanEditView.vue -->
<template><div class="testplan-edit-view" v-loading="pageLoading"><el-page-header @back="goBack" :content="pageTitle" class="page-header-custom" /><el-card class="form-card"><el-formref="testPlanFormRef":model="formData":rules="formRules"label-width="120px"label-position="right"><el-form-item label="计划名称" prop="name"><el-input v-model="formData.name" placeholder="请输入计划名称" /></el-form-item><el-form-item label="所属项目" prop="project"><el-selectv-model="formData.project"placeholder="请选择所属项目"filterablestyle="width: 100%;"@change="onProjectChange"@focus="fetchProjectsForSelect":loading="projectSelectLoading":disabled="isEditMode && !!initialProject" ><el-option v-for="item in projectOptions" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="计划描述" prop="description"><el-input v-model="formData.description" type="textarea" placeholder="请输入计划描述" /></el-form-item><el-form-item label="选择测试用例" prop="test_cases"><el-transferv-model="formData.test_cases":data="availableTestCases":titles="['可选测试用例', '已选测试用例']":props="{ key: 'id', label: 'name' }" filterablefilter-placeholder="搜索用例名称"style="width: 100%;":disabled="!formData.project || testCaseLoading"height="300px" ><template #default="{ option }"><span>{{ option.id }} - {{ option.name }}</span></template></el-transfer><div v-if="!formData.project" class="el-form-item__error" style="font-size:12px; color: #F56C6C; margin-top:5px;">请先选择所属项目以加载测试用例</div></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'
import type { FormInstance, FormRules } from 'element-plus'
import { createTestPlan, getTestPlanDetail, updateTestPlan, type UpsertTestPlanData,type TestPlan
} from '@/api/testplan'
import { getProjectList, type Project } from '@/api/project'
import { getTestCaseList as fetchAllTestCasesByProject, type TestCase } from '@/api/testcase'const route = useRoute()
const router = useRouter()const pageLoading = ref(false)
const submitLoading = ref(false)
const testPlanFormRef = ref<FormInstance>()const testPlanId = computed(() => route.params.id ? Number(route.params.id) : null)
const isEditMode = computed(() => !!testPlanId.value)
const pageTitle = computed(() => (isEditMode.value ? '编辑测试计划' : '新建测试计划'))const projectOptions = ref<Project[]>([])
const projectSelectLoading = ref(false)const availableTestCases = ref<TestCase[]>([]) // Transfer 左侧数据源
const testCaseLoading = ref(false)const initialFormData: UpsertTestPlanData = {name: '',description: null,project: undefined as number | undefined,test_cases: [],
}
const formData = reactive<UpsertTestPlanData>({ ...initialFormData })
const initialProject = ref<number | null>(null); // 用于编辑时存储初始项目IDconst formRules = reactive<FormRules>({name: [{ required: true, message: '计划名称不能为空', trigger: 'blur' }],project: [{ required: true, message: '请选择所属项目', trigger: 'change' }],// test_cases 穿梭框的值是数组,可以不直接校验,或校验其长度// test_cases: [{ type: 'array', required: true, message: '请至少选择一个测试用例', trigger: 'change' }]
})// 获取项目列表
const fetchProjectsForSelect = async () => {if (projectOptions.value.length > 0 && !isEditMode.value) return;projectSelectLoading.value = truetry {const response = await getProjectList({ page_size: 1000 }) // 获取所有项目projectOptions.value = response.data} catch (error) {console.error('获取项目列表失败:', error)} finally {projectSelectLoading.value = false}
}// 当项目选择变化时,加载该项目下的所有测试用例
const onProjectChange = async (projectId: number | undefined | null) => {availableTestCases.value = []formData.test_cases = []if (!projectId) {return}testCaseLoading.value = truetry {const response = await fetchAllTestCasesByProject({ module__project_id: projectId, page_size: 10000 }); console.log('Raw response from fetchAllTestCasesByProject:', response);console.log('Response data from fetchAllTestCasesByProject:', response.data);if (response && Array.isArray(response.data)) {console.log('Test cases data array:', response.data);availableTestCases.value = response.data.map(tc => {// 确保 tc 对象是你期望的结构if (typeof tc.id === 'undefined' || typeof tc.name === 'undefined') {console.warn('Test case object is missing id or name:', tc);}return {...tc,// 如果 ElTransfer 的 :props="{ key: 'id', label: 'name' }" 已设置,// 那么这里不需要显式添加 key 和 label,除非你想覆盖。// key: tc.id,// label: tc.name};});console.log('Mapped availableTestCases:', availableTestCases.value);} else {// 如果 response.data 不是数组 (例如是 null, undefined, 或其他对象结构)console.warn('fetchAllTestCasesByProject did not return an array. Data:', response ? response.data : 'No response data');availableTestCases.value = [];}} catch (error) {console.error(`获取项目 ${projectId} 的测试用例失败:`, error)ElMessage.error('加载测试用例失败')availableTestCases.value = []; // 确保出错时清空} finally {testCaseLoading.value = false}
}// 加载测试计划详情 (编辑模式)
const loadTestPlanDetail = async () => {if (!isEditMode.value || !testPlanId.value) returnpageLoading.value = truetry {const response = await getTestPlanDetail(testPlanId.value)const dataFromServer = response.dataformData.name = dataFromServer.nameformData.description = dataFromServer.descriptionformData.project = dataFromServer.projectinitialProject.value = dataFromServer.project; // 记录初始项目ID// 先加载该项目下的所有可选测试用例await onProjectChange(formData.project)// 然后设置已选的测试用例 (确保是 ID 数组)formData.test_cases = dataFromServer.test_cases || [] // 如果 test_case_details 存在且包含有效数据,也可以用它来辅助,但 el-transfer v-model 直接用 ID 数组} catch (error) {ElMessage.error('获取测试计划详情失败')console.error(error)} finally {pageLoading.value = false}
}onMounted(async () => {await fetchProjectsForSelect() // 先加载项目选项if (isEditMode.value) {await loadTestPlanDetail()}
})const handleSubmit = async () => {if (!testPlanFormRef.value) returnawait testPlanFormRef.value.validate(async (valid) => {if (valid) {if (!formData.project) {ElMessage.error('请选择所属项目'); // 再次确认return;}if (formData.test_cases && formData.test_cases.length === 0) {ElMessage.warning('尚未选择任何测试用例,确定要保存吗?');// 可以选择在这里 return,或者让用户创建一个空的测试计划}submitLoading.value = trueconst dataToSubmit: UpsertTestPlanData = {name: formData.name,description: formData.description,project: formData.project!,test_cases: formData.test_cases || [],}try {if (isEditMode.value && testPlanId.value) {await updateTestPlan(testPlanId.value, dataToSubmit)ElMessage.success('测试计划更新成功!')} else {await createTestPlan(dataToSubmit)ElMessage.success('测试计划创建成功!')}router.push({ name: 'testplans' }) // 跳转到列表页} catch (error) {console.error('测试计划操作失败:', error)} finally {submitLoading.value = false}} else {ElMessage.error('请检查表单填写是否正确!')return false}})
}const goBack = () => {router.back()
}
</script><style scoped lang="scss">
.testplan-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;
}
// 调整 Transfer 组件样式,使其内部可滚动
:deep(.el-transfer-panel) {height: 300px; // 与 :height="300px" 对应
}
:deep(.el-transfer-panel__body) {height: calc(100% - 40px); // 减去头部的40px左右
}
:deep(.el-transfer-panel__list) {height: 100%;overflow-y: auto; // 允许列表内容滚动
}
</style>
代码解释与关键点:
- 表单字段: 名称、所属项目 (下拉选择)、描述。
ElTransfer
(穿梭框) 用于选择测试用例:v-model="formData.test_cases"
: 双向绑定已选择的测试用例的 ID 数组。:data="availableTestCases"
: 左侧可选测试用例的数据源。这个数组的每个元素应该是对象,并包含key
(用例ID) 和label
(用例名称) 属性,或者通过:props
指定。:props="{ key: 'id', label: 'name' }"
: 告诉ElTransfer
组件,数据源对象中用id
作为key
,用name
作为label
。:titles="['可选测试用例', '已选测试用例']"
: 设置左右两侧面板的标题。filterable
: 允许在穿梭框内部搜索。height="300px"
: 设置穿梭框的高度。注意: 可能需要配合 SCSS 中的:deep
选择器调整内部列表的高度以实现真正的滚动。:disabled="!formData.project || testCaseLoading"
: 当未选择项目或用例正在加载时,禁用穿梭框。- 自定义渲染 (
<template #default="{ option }">
): 可以自定义每个条目的显示内容,这里显示 “ID - 名称”。
- 项目选择联动 (
onProjectChange
): 当用户选择了项目后,调用fetchAllTestCasesByProject
API (在api/testcase.ts
中) 获取该项目下的所有测试用例,并填充到availableTestCases
中作为穿梭框的左侧数据源。注意,这里假设测试用例数量不多,一次性加载。如果用例非常多,需要实现穿梭框的远程搜索或分页加载。 - 编辑模式加载 (
loadTestPlanDetail
):- 获取计划详情后,设置表单的基础信息。
- 调用
onProjectChange(formData.project)
来加载该计划所属项目下的所有可选测试用例。 - 将
dataFromServer.test_cases
(已关联的用例ID列表) 赋值给formData.test_cases
,这样穿梭框会自动将这些用例移动到右侧已选区域。 - 编辑时禁用项目选择:
initialProject.value
用于记录初始项目ID,并在编辑模式下禁用项目选择框,因为通常不建议在编辑测试计划时更改其所属项目(这会使已选的用例失效)。如果确实需要更改项目,流程会更复杂(需要提示用户已选用例将被清空等)。
- 提交 (
handleSubmit
):- 将
formData
(包括test_cases
ID 列表) 发送给后端。
- 将
4. 创建测试计划列表页面 (src/views/testplan/TestPlanListView.vue
)
这个页面与 TestCaseListView.vue
类似,包含筛选、表格和分页。
a. 创建文件:
b. 编写 TestPlanListView.vue
:
<!-- test-platform/frontend/src/views/testplan/TestPlanListView.vue -->
<template><div class="testplan-list-view" v-loading="pageLoading"><el-card class="filter-card"><el-form :inline="true" :model="queryParams" ref="queryFormRef" @submit.prevent="handleSearch"><el-form-item label="所属项目" prop="project_id"><el-selectv-model="queryParams.project_id"placeholder="请选择项目"clearablestyle="width: 200px"@focus="fetchProjectsForSelect":loading="projectSelectLoading"><el-option v-for="item in projectOptions" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="计划名称" prop="search"><el-input v-model="queryParams.search" placeholder="搜索计划名称/描述" clearable style="width: 220px" /></el-form-item><el-form-item><el-button type="primary" :icon="SearchIcon" @click="handleSearch">搜索</el-button><el-button :icon="RefreshIcon" @click="handleReset">重置</el-button></el-form-item></el-form></el-card><el-card class="table-card"><template #header><div class="card-header"><span>测试计划列表</span><el-button type="primary" :icon="PlusIcon" @click="navigateToCreate">新建计划</el-button></div></template><el-table :data="testPlans" v-loading="tableLoading" style="width: 100%" empty-text="暂无测试计划数据"><el-table-column prop="id" label="ID" width="80" sortable /><el-table-column prop="name" label="计划名称" min-width="200" show-overflow-tooltip sortable><template #default="scope"><el-link type="primary" @click="handleEdit(scope.row.id)">{{ scope.row.name }}</el-link></template></el-table-column><el-table-column prop="project_name" label="所属项目" width="180" show-overflow-tooltip /><el-table-column label="包含用例数" width="120"><template #default="scope">{{ scope.row.test_cases?.length || 0 }}</template></el-table-column><el-table-column prop="description" label="描述" min-width="250" show-overflow-tooltip /><el-table-column prop="update_time" label="最后更新" width="170" sortable><template #default="scope">{{ formatDateTime(scope.row.update_time) }}</template></el-table-column><el-table-column label="操作" width="150" fixed="right"><template #default="scope"><el-button size="small" type="warning" :icon="EditIcon" @click="handleEdit(scope.row.id)">编辑</el-button><el-popconfirmtitle="确定要删除这个计划吗?"@confirm="handleDelete(scope.row.id)"><template #reference><el-button size="small" type="danger" :icon="DeleteIcon">删除</el-button></template></el-popconfirm></template></el-table-column></el-table><el-paginationv-if="totalPlans > 0"class="pagination-container":current-page="queryParams.page":page-size="queryParams.page_size":page-sizes="[10, 20, 50, 100]"layout="total, sizes, prev, pager, next, jumper":total="totalPlans"@size-change="handleSizeChange"@current-change="handlePageChange"/></el-card></div>
</template><script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import { Search as SearchIcon, Refresh as RefreshIcon, Plus as PlusIcon, Edit as EditIcon, Delete as DeleteIcon } from '@element-plus/icons-vue'
import { getTestPlanList, deleteTestPlan, type TestPlan } from '@/api/testplan'
import { getProjectList, type Project } from '@/api/project'const router = useRouter()const pageLoading = ref(false)
const tableLoading = ref(false)
const queryFormRef = ref<FormInstance>()const testPlans = ref<TestPlan[]>([])
const totalPlans = ref(0)const queryParams = reactive({page: 1,page_size: 10,project_id: null as number | null,search: '',
})const projectOptions = ref<Project[]>([])
const projectSelectLoading = ref(false)const fetchTestPlans = async () => {tableLoading.value = truetry {const response = await getTestPlanList(queryParams)console.log('完整的测试计划返回数据:', response) // 打印完整响应// 检查响应结构if (response && response.data) {// 直接检查 response.data 是否为数组if (Array.isArray(response.data)) {testPlans.value = response.datatotalPlans.value = response.data.length} // 检查标准分页格式else if (response.data.results && Array.isArray(response.data.results)) {testPlans.value = response.data.resultstotalPlans.value = response.data.count || response.data.results.length}// 其他可能的数据格式else {console.error('未识别的API返回格式:', response.data)testPlans.value = []totalPlans.value = 0}} else {console.error('API响应无效:', response)testPlans.value = []totalPlans.value = 0}} catch (error) {console.error('获取测试计划列表失败:', error)testPlans.value = []totalPlans.value = 0} finally {tableLoading.value = false}
}const fetchProjectsForSelect = async () => {if (projectOptions.value.length > 0) return;projectSelectLoading.value = truetry {const response = await getProjectList({ page_size: 1000 })projectOptions.value = response.data} catch (error) {console.error('获取项目选项失败:', error)} finally {projectSelectLoading.value = false}
}onMounted(async () => {pageLoading.value = true;await fetchProjectsForSelect();await fetchTestPlans();pageLoading.value = false;
})const handleSearch = () => {queryParams.page = 1fetchTestPlans()
}const handleReset = () => {queryFormRef.value?.resetFields()queryParams.project_id = null; // 手动重置 SelectqueryParams.page = 1;queryParams.search = '';fetchTestPlans()
}const handlePageChange = (newPage: number) => {queryParams.page = newPagefetchTestPlans()
}const handleSizeChange = (newSize: number) => {queryParams.page_size = newSizequeryParams.page = 1fetchTestPlans()
}const navigateToCreate = () => {router.push({ name: 'testplanCreate' })
}const handleEdit = (id: number) => {router.push({ name: 'testplanEdit', params: { id } })
}const handleDelete = async (id: number) => {try {await ElMessageBox.confirm('此操作将永久删除该测试计划,是否继续?', '警告', {confirmButtonText: '确定删除',cancelButtonText: '取消',type: 'warning',});tableLoading.value = true;await deleteTestPlan(id);ElMessage.success('测试计划删除成功!');if (testPlans.value.length === 1 && queryParams.page! > 1) {queryParams.page!--;}fetchTestPlans();} catch (error) {if (error !== 'cancel') {console.error('删除测试计划失败:', error);}} finally {tableLoading.value = false;}
}const formatDateTime = (dateTimeStr: string) => {if (!dateTimeStr) return ''return new Date(dateTimeStr).toLocaleString()
}
</script><style scoped lang="scss">
.testplan-list-view {padding: 20px;.filter-card {margin-bottom: 20px;}.table-card {.card-header {display: flex;justify-content: space-between;align-items: center;}}.pagination-container {margin-top: 20px;display: flex;justify-content: flex-end;}
}
</style>
5. 在主布局侧边栏添加入口
打开 frontend/src/layout/index.vue
,在侧边栏菜单中添加“测试计划”的入口:
<!-- test-platform/frontend/src/layout/index.vue -->
// ...<el-menu-item index="/testcases"><el-icon><List /></el-icon><span>用例管理</span></el-menu-item><el-menu-item index="/testplans"> <!-- 新增测试计划入口 --><el-icon><Memo /></el-icon> <!-- Memo 是一个合适的图标 --><span>测试计划</span></el-menu-item><el-menu-item index="/reports">
// ...// 导入 Memo 图标
import { ArrowDown, HomeFilled, Folder, List, DataAnalysis, Memo } from '@element-plus/icons-vue' // 添加 Memo
第五步:测试完整流程
- 确保前后端服务运行正常,CORS 和 API 可用。
- 通过侧边栏进入“测试计划”列表页:
- 新建测试计划:
- 点击“新建计划”按钮。
- 填写计划名称、选择所属项目。
- 项目选择后,穿梭框左侧应加载该项目下的测试用例。
- 从左侧选择一些用例到右侧。
- 点击“创建计划”。
- 应提示成功并跳转回列表页,新创建的计划应显示在列表中,包含用例数正确。
- 编辑测试计划:
- 点击某个计划的“编辑”按钮。
- 表单数据应正确回填,特别是穿梭框中已选的用例应在右侧。
- 修改计划信息,增删用例。
- 点击“更新计划”。
- 验证更新成功和数据正确性。
- 删除测试计划:
- 点击删除,确认。
- 计划应从列表中移除。
- 列表页筛选和分页测试。
总结
在这篇文章中,我们成功实现了测试平台中“测试计划/套件管理”的核心功能:
- ✅ 后端:
- 定义了
TestPlan
Django 模型,包含与Project
的外键和与TestCase
的多对多关系。 - 创建了
TestPlanSerializer
,并使用SerializerMethodField
来优化读取时关联测试用例的显示。 - 创建了
TestPlanViewSet
,支持按项目筛选,并通过prefetch_related
优化了性能。 - 注册了相应的 API 路由。
- 定义了
- ✅ 前端:
- 创建了
api/testplan.ts
服务文件,封装了测试计划的 CRUD API 调用。 - 添加了测试计划相关页面的路由。
- 实现了
TestPlanEditView.vue
(新建/编辑测试计划页面):- 包含计划基本信息表单。
- 使用
ElTransfer
(穿梭框) 组件,实现了从指定项目下选择测试用例并关联到计划的功能。 - 处理了项目选择与穿梭框数据源的联动加载。
- 正确处理了编辑模式下数据的回填,特别是穿梭框已选用例的回显。
- 实现了
TestPlanListView.vue
(测试计划列表页面):- 包含按项目和名称搜索的筛选功能。
- 使用表格展示计划列表,包括计划包含的用例数量。
- 实现了分页功能。
- 提供了新建、编辑、删除计划的操作入口。
- 在主布局的侧边栏添加了“测试计划”的导航入口。
- 创建了
- ✅ 指导了如何测试测试计划管理的完整 CRUD 流程。
通过本篇文章,我们的测试平台现在可以将零散的测试用例有效地组织起来,为后续的测试执行做好准备。
在下一篇文章中,我们将进入测试执行环节,设计后端如何接收执行指令,并实际去请求被测接口。