当前位置: 首页 > news >正文

十三、【核心功能篇】测试计划管理:组织和编排测试用例

【核心功能篇】测试计划管理:组织和编排测试用例

    • 前言
      • 准备工作
      • 第一部分:后端实现 (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) 允许我们将相关的测试用例组合成一个可执行的单元。

这篇文章将带你

  1. 在后端 Django 中设计和实现 TestPlan 数据模型及其 API。
  2. 在前端 Vue3 中创建测试计划的管理页面,包括列表展示。
  3. 设计并实现一个用户友好的界面,用于创建和编辑测试计划,特别是如何从现有用例库中选择测试用例并关联到计划中。

我们将使用 Element Plus 的 ElTransfer (穿梭框) 组件来实现测试用例的选择功能。

一个测试计划通常包含以下信息

  • 基本信息: 计划名称、描述、所属项目等。
  • 包含的测试用例: 一个计划会包含一个或多个选定的测试用例。
  • (可选) 执行策略、环境配置等: 这些我们暂时不在此篇详细展开,但会为数据模型留有余地。

我们的目标是让用户能够

  • 创建新的测试计划,并为其关联项目。
  • 从指定项目的测试用例库中,方便地选择一批用例加入到测试计划中。
  • 编辑已有的测试计划,可以修改基本信息或增删其包含的测试用例。
  • 查看测试计划列表,并能删除不再需要的计划。

准备工作

  1. 前端项目就绪: test-platform/frontend 项目可以正常运行 (npm run dev)。
  2. 后端 API 运行中: Django 后端服务运行(python manage.py runserver)。项目、模块、测试用例的 API 均可用。
  3. Axios 和 API 服务已封装: utils/request.tsapi/project.ts, api/module.ts, api/testcase.ts 已配置。
  4. 测试用例管理功能基本可用: 我们需要有测试用例数据才能将其添加到计划中。

第一部分:后端实现 (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 列表。
  • 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_casesproject 的访问,避免 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 的输出。
  • UpsertTestPlanDatatest_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

第五步:测试完整流程

  1. 确保前后端服务运行正常,CORS 和 API 可用。
  2. 通过侧边栏进入“测试计划”列表页:
    在这里插入图片描述
  3. 新建测试计划:
    • 点击“新建计划”按钮。
    • 填写计划名称、选择所属项目。
    • 项目选择后,穿梭框左侧应加载该项目下的测试用例。
    • 从左侧选择一些用例到右侧。
    • 点击“创建计划”。
    • 应提示成功并跳转回列表页,新创建的计划应显示在列表中,包含用例数正确。
  4. 编辑测试计划:
    • 点击某个计划的“编辑”按钮。
    • 表单数据应正确回填,特别是穿梭框中已选的用例应在右侧。
    • 修改计划信息,增删用例。
    • 点击“更新计划”。
    • 验证更新成功和数据正确性。
  5. 删除测试计划:
    • 点击删除,确认。
    • 计划应从列表中移除。
  6. 列表页筛选和分页测试。

总结

在这篇文章中,我们成功实现了测试平台中“测试计划/套件管理”的核心功能:

  • 后端:
    • 定义了 TestPlan Django 模型,包含与 Project 的外键和与 TestCase 的多对多关系。
    • 创建了 TestPlanSerializer,并使用 SerializerMethodField 来优化读取时关联测试用例的显示。
    • 创建了 TestPlanViewSet,支持按项目筛选,并通过 prefetch_related 优化了性能。
    • 注册了相应的 API 路由。
  • 前端:
    • 创建了 api/testplan.ts 服务文件,封装了测试计划的 CRUD API 调用。
    • 添加了测试计划相关页面的路由。
    • 实现了 TestPlanEditView.vue (新建/编辑测试计划页面):
      • 包含计划基本信息表单。
      • 使用 ElTransfer (穿梭框) 组件,实现了从指定项目下选择测试用例并关联到计划的功能。
      • 处理了项目选择与穿梭框数据源的联动加载。
      • 正确处理了编辑模式下数据的回填,特别是穿梭框已选用例的回显。
    • 实现了 TestPlanListView.vue (测试计划列表页面):
      • 包含按项目和名称搜索的筛选功能。
      • 使用表格展示计划列表,包括计划包含的用例数量。
      • 实现了分页功能。
      • 提供了新建、编辑、删除计划的操作入口。
    • 在主布局的侧边栏添加了“测试计划”的导航入口。
  • 指导了如何测试测试计划管理的完整 CRUD 流程。

通过本篇文章,我们的测试平台现在可以将零散的测试用例有效地组织起来,为后续的测试执行做好准备。

在下一篇文章中,我们将进入测试执行环节,设计后端如何接收执行指令,并实际去请求被测接口。

相关文章:

  • vue-11(命名路由和命名视图)
  • 【小米拥抱AI】小米开源视觉大模型—— MiMo-VL
  • 2,QT-Creator工具创建新项目教程
  • debian12.9或ubuntu,vagrant离线安装插件vagrant-libvirt
  • PHP与MYSQL结合中中的一些常用函数,HTTP协议定义,PHP进行文件编程,会话技术
  • Android第十二次面试-多线程和字符串算法总结
  • 健康检查:在 .NET 微服务模板中优雅配置 Health Checks
  • 基于微信小程序的云校园信息服务平台设计与实现(源码+定制+开发)云端校园服务系统开发 面向师生的校园事务小程序设计与实现 融合微信生态的智慧校园管理系统开发
  • python集成inotify-rsync实现跨服务器文件同步
  • Java对象的内存结构
  • Git仓库大文件清理指南
  • C++测开,自动化测试,业务(第一段实习)
  • 【PyQt5】PyQt5初探 - 一个简单的例程
  • 数据结构-排序-排序的七种算法(2)
  • Google Android 14设备和应用通知 受限制的设置 出于安全考虑......
  • Office办公文档软件安装包2024版
  • Java复习Day25
  • 性能优化 - 案例篇:缓冲区
  • Vue-1-前端框架Vue基础入门之一
  • Redis 缓存穿透、缓存击穿、缓存雪崩详解与解决方案
  • wordpress 国家列表/优化设计三年级下册数学答案
  • 杭州网站建设图片/网站推广的平台
  • 九江 网站建设公司/产品关键词怎么找
  • 南昌网站建设业务/软文范例大全800
  • 彩票网站模版/重庆seo主管
  • 莱芜百姓网/seo从零开始到精通200讲解