【核心功能篇】测试计划管理:组织和编排测试用例
前言
随着测试用例数量的增加,如何有效地组织和管理这些用例以进行特定目的的测试(例如回归测试、新功能测试)就变得至关重要。测试计划 (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 # 确保导入 TestPlan
class 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 = TestPlan
fields = [
'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 = TestPlanSerializer
filter_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_details
id: 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-form
ref="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-select
v-model="formData.project"
placeholder="请选择所属项目"
filterable
style="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-transfer
v-model="formData.test_cases"
:data="availableTestCases"
:titles="['可选测试用例', '已选测试用例']"
:props="{ key: 'id', label: 'name' }"
filterable
filter-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); // 用于编辑时存储初始项目ID
const 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 = true
try {
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 = true
try {
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) return
pageLoading.value = true
try {
const response = await getTestPlanDetail(testPlanId.value)
const dataFromServer = response.data
formData.name = dataFromServer.name
formData.description = dataFromServer.description
formData.project = dataFromServer.project
initialProject.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) return
await 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 = true
const 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-select
v-model="queryParams.project_id"
placeholder="请选择项目"
clearable
style="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-popconfirm
title="确定要删除这个计划吗?"
@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-pagination
v-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 = true
try {
const response = await getTestPlanList(queryParams)
console.log('完整的测试计划返回数据:', response) // 打印完整响应
// 检查响应结构
if (response && response.data) {
// 直接检查 response.data 是否为数组
if (Array.isArray(response.data)) {
testPlans.value = response.data
totalPlans.value = response.data.length
}
// 检查标准分页格式
else if (response.data.results && Array.isArray(response.data.results)) {
testPlans.value = response.data.results
totalPlans.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 = true
try {
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 = 1
fetchTestPlans()
}
const handleReset = () => {
queryFormRef.value?.resetFields()
queryParams.project_id = null; // 手动重置 Select
queryParams.page = 1;
queryParams.search = '';
fetchTestPlans()
}
const handlePageChange = (newPage: number) => {
queryParams.page = newPage
fetchTestPlans()
}
const handleSizeChange = (newSize: number) => {
queryParams.page_size = newSize
queryParams.page = 1
fetchTestPlans()
}
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 流程。
通过本篇文章,我们的测试平台现在可以将零散的测试用例有效地组织起来,为后续的测试执行做好准备。
在下一篇文章中,我们将进入测试执行环节,设计后端如何接收执行指令,并实际去请求被测接口。