十二、【核心功能篇】测试用例列表与搜索:高效展示和查找海量用例
【核心功能篇】测试用例列表与搜索:高效展示和查找海量用例
- 前言
- 准备工作
- 第一步:更新 API 服务以支持分页和更完善的搜索
- 第二步:创建测试用例列表页面组件 (`src/views/testcase/TestCaseListView.vue`)
- 第三步:测试列表、搜索、筛选和分页
- 总结
前言
当测试用例数量逐渐增多,一个简单罗列所有用例的列表将变得非常低效和不友好。我们需要:
- 清晰的列表展示: 以表格形式清晰展示用例的关键信息。
- 分页加载: 避免一次性加载过多数据导致页面卡顿。
- 关键字搜索: 能够根据用例名称、描述等关键词快速查找。
- 条件筛选: 能够根据所属项目、模块、优先级、类型等条件进行筛选。
- 便捷的操作: 在列表行内直接提供编辑、删除等快捷操作入口。
我们将创建一个 TestCaseListView.vue
组件,并利用 Element Plus 的 ElTable
、ElPagination
和 ElForm
组件来实现这些功能。
准备工作
- 前端项目就绪:
test-platform/frontend
项目可以正常运行 (npm run dev
)。 - 后端 API 运行中: Django 后端服务运行(
python manage.py runserver
),测试用例的 API (/api/testcases/
) 可用,并且支持通过查询参数进行过滤 (如project_id
,module_id
,search
等) 和分页。 - Axios 和 API 服务已封装:
utils/request.ts
和api/testcase.ts
(包含getTestCaseList
函数) 已配置好。 - Element Plus 集成完毕。
- 测试用例编辑页面可用: 我们将从列表页跳转到编辑页。
第一步:更新 API 服务以支持分页和更完善的搜索
我们的后端 DRF TestCaseViewSet
默认就支持分页 (如果继承了 ModelViewSet
并配置了 pagination_class
),并且通过 SearchFilter
和 DjangoFilterBackend
可以实现搜索和过滤。我们需要确保前端的 API 服务函数能够传递这些参数,并正确处理分页响应。
1. 修改 frontend/src/api/testcase.ts
中的类型和函数:
DRF 分页响应通常包含 count
, next
, previous
, results
字段。
// test-platform/frontend/src/api/testcase.ts
import request from '@/utils/request'
import type { AxiosPromise } from 'axios'// TestCase 接口定义保持不变...
export interface TestCase {id: number;name: string;description: string | null;module: number;module_name?: string;project_id?: number;project_name?: string;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 interface PaginatedResponse<T> {count: number;next: string | null;previous: string | null;results: T[];
}// 更新:TestCaseListResponse 现在使用 PaginatedResponse
export type TestCaseListResponse = PaginatedResponse<TestCase>// 定义获取测试用例列表的参数类型
export interface GetTestCaseListParams {page?: number;page_size?: number; // 后端 DRF 通常用 page_sizesearch?: string; // 关键词搜索project_id?: number | null;module_id?: number | null;priority?: string | null;case_type?: string | null;ordering?: string; // 排序字段,例如 '-create_time'
}// 1. 获取测试用例列表 (支持分页、搜索、过滤、排序)
export function getTestCaseList(params?: GetTestCaseListParams): AxiosPromise<TestCaseListResponse> {return request({url: '/testcases/',method: 'get',params // 将所有参数传递给后端})
}// createTestCase, getTestCaseDetail, updateTestCase, deleteTestCase 函数保持不变...
// ...
关键变更:
PaginatedResponse<T>
: 定义了一个通用的分页响应接口。TestCaseListResponse
: 类型更新为PaginatedResponse<TestCase>
。GetTestCaseListParams
: 定义了更详细的查询参数类型,包括page
,page_size
,search
以及各种过滤条件。getTestCaseList
函数现在接收GetTestCaseListParams
类型的参数。
2. 确保后端 TestCaseViewSet
支持搜索和过滤:
在你的 Django 后端 api/views.py
中的 TestCaseViewSet
,你需要确保配置了相应的 filter_backends
和 search_fields
/ filterset_fields
。
# test-platform/api/views.py
from rest_framework import viewsets, filters # 确保导入 filters
from django_filters.rest_framework import DjangoFilterBackend # 确保导入 DjangoFilterBackend
# ... 其他导入 ...
from .models import TestCase
from .serializers import TestCaseSerializerclass TestCaseViewSet(viewsets.ModelViewSet):queryset = TestCase.objects.all().order_by('-update_time') # 默认排序serializer_class = TestCaseSerializerfilter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]# DjangoFilterBackend 的精确匹配字段filterset_fields = {'module__project_id': ['exact'], # /api/testcases/?module__project_id=1'module_id': ['exact'], # /api/testcases/?module_id=5'priority': ['exact'], # /api/testcases/?priority=P1'case_type': ['exact'], # /api/testcases/?case_type=api}# SearchFilter 的模糊搜索字段search_fields = ['name', 'description', 'precondition', 'steps_text', 'expected_result','maintainer'] # /api/testcases/?search=登录# OrderingFilter 允许排序的字段ordering_fields = ['id', 'name', 'priority', 'case_type', 'create_time', 'update_time']# pagination_class = YourCustomPagination # 如果使用了自定义分页器
重要:
-
要使用
DjangoFilterBackend
,你需要先安装django-filter
包。在你的 Django 项目的虚拟环境中运行:pip install django-filter
-
在
INSTALLED_APPS
中注册'django_filters'
。
# settings.pyINSTALLED_APPS = [# ... 其他应用 ...'rest_framework','django_filters', # 添加这一行'api',# ...]
-
filterset_fields
用于精确匹配,例如选择某个项目下的用例。我们这里用module__project_id
来通过模块关联到项目进行筛选。 -
search_fields
用于关键词的模糊搜索。 -
ordering_fields
允许前端指定排序。 -
DRF 的默认分页器 (
PageNumberPagination
) 使用page
和page_size
(或limit
/offset
如果是LimitOffsetPagination
) 参数。确保前端传递的参数名与后端分页器配置一致。
第二步:创建测试用例列表页面组件 (src/views/testcase/TestCaseListView.vue
)
这个组件将包含搜索/筛选表单、用例表格和分页控件。
在上一篇【核心功能】测试用例管理:设计强大的用例编辑界面,我们已经在frontend/src/router/index.ts
中添加了用例列表的路由信息,如下所示:
// test-platform/frontend/src/router/index.ts
// ... (在 Layout 的 children 中添加){path: '/testcases', // 用例列表页name: 'testcases',component: () => import('../views/testcase/TestCaseListView.vue'), //之前的文件路径是'../views/project/TestCaseListView.vue',这个是不对的,所以这里进行了更正。meta: { title: '用例管理', requiresAuth: true }},
// ...
a. 创建文件:
src/views/testcase/TestCaseListView.vue
尚不存在,现在来创建它。
b. 编写 TestCaseListView.vue
:
<!-- test-platform/frontend/src/views/testcase/TestCaseListView.vue -->
<template><div class="testcase-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: 180px"@change="onProjectChange"@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="module_id"><el-selectv-model="queryParams.module_id"placeholder="请选择模块"clearablefilterablestyle="width: 180px":disabled="!queryParams.project_id":loading="moduleSelectLoading"><el-option v-for="item in moduleOptions" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="优先级" prop="priority"><el-select v-model="queryParams.priority" placeholder="优先级" clearable style="width: 120px"><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-form-item label="类型" prop="case_type"><el-select v-model="queryParams.case_type" placeholder="用例类型" clearable style="width: 140px"><el-option label="功能测试" value="functional" /><el-option label="接口测试" value="api" /><el-option label="UI测试" value="ui" /></el-select></el-form-item><el-form-item label="关键词" prop="search"><el-input v-model="queryParams.search" placeholder="名称/描述/步骤等" clearable style="width: 200px" /></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="testCases" 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="150" show-overflow-tooltip /><el-table-column prop="module_name" label="所属模块" width="150" show-overflow-tooltip /><el-table-column prop="priority_display" label="优先级" width="100" sortable><template #default="scope"><el-tag :type="getPriorityTagType(scope.row.priority)">{{ scope.row.priority_display || scope.row.priority }}</el-tag></template></el-table-column><el-table-column prop="case_type_display" label="类型" width="120" sortable><template #default="scope">{{ scope.row.case_type_display || getCaseTypeText(scope.row.case_type) }}</template></el-table-column><el-table-column prop="maintainer" label="维护人" width="120" 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="totalCases > 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="totalCases"@size-change="handleSizeChange"@current-change="handlePageChange"/></el-card></div>
</template><script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' // 导入 useRoute
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 { getTestCaseList, deleteTestCase, type TestCase, type GetTestCaseListParams } from '@/api/testcase'
import { getProjectList, type Project } from '@/api/project'
import { getModuleList as fetchModulesApi, type Module as ApiModule } from '@/api/module' // 避免与组件内变量冲突const router = useRouter()
const route = useRoute() // 获取当前路由信息const pageLoading = ref(false) // 页面级加载,例如初始加载项目模块选项
const tableLoading = ref(false)
const queryFormRef = ref<FormInstance>()const testCases = ref<TestCase[]>([])
const totalCases = ref(0)const queryParams = reactive<GetTestCaseListParams>({page: 1,page_size: 10,search: '',project_id: null,module_id: null,priority: null,case_type: null,ordering: '-update_time', // 默认按更新时间降序
})const projectOptions = ref<Project[]>([])
const moduleOptions = ref<ApiModule[]>([])
const projectSelectLoading = ref(false)
const moduleSelectLoading = ref(false)// 获取测试用例列表
const fetchTestCases = async () => {tableLoading.value = truetry {const response = await getTestCaseList(queryParams)console.log('API返回数据:', response.data) // 添加这行用于调试// 检查返回的数据结构if (response.data && Array.isArray(response.data.results)) {testCases.value = response.data.resultstotalCases.value = response.data.count} else if (response.data && Array.isArray(response.data.entities)) {// 如果API返回的是entities而不是resultstestCases.value = response.data.entitiestotalCases.value = response.data.total} else if (Array.isArray(response.data)) {// 如果API直接返回数组testCases.value = response.datatotalCases.value = response.data.length} else {console.error('API返回的数据结构不符合预期:', response.data)testCases.value = []totalCases.value = 0}} catch (error) {console.error('获取测试用例列表失败:', error)testCases.value = []totalCases.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}
}// 根据选中的项目获取模块列表用于筛选器
const fetchModulesForSelect = async (projectId: number | null) => {moduleOptions.value = [] // 清空旧模块queryParams.module_id = null // 清空已选模块if (!projectId) return;moduleSelectLoading.value = truetry {const response = await fetchModulesApi(projectId) // 调用 api/module.ts 中的 getModuleListmoduleOptions.value = response.data} catch (error) {console.error('获取模块选项失败:', error)} finally {moduleSelectLoading.value = false}
}// 监听项目选择变化,动态加载模块
watch(() => queryParams.project_id, (newProjectId) => {fetchModulesForSelect(newProjectId)
})// 页面加载时初始化
onMounted(async () => {pageLoading.value = true;await fetchProjectsForSelect(); // 先加载项目选项// 检查路由查询参数中是否有 moduleId (例如从用例编辑页跳转过来)const routeModuleId = route.query.moduleId as string | undefined;if (routeModuleId) {queryParams.module_id = Number(routeModuleId);// 如果有 moduleId,尝试找到其对应的 projectId 并设置const moduleDetail = moduleOptions.value.find(m => m.id === queryParams.module_id) ||(await fetchModulesApi().then(res => res.data.find(m => m.id === Number(routeModuleId)))); // 如果初始 moduleOptions 为空,则重新获取所有模块来查找if(moduleDetail && moduleDetail.project){queryParams.project_id = moduleDetail.project;await fetchModulesForSelect(queryParams.project_id); // 确保选中项目的模块列表被加载queryParams.module_id = Number(routeModuleId); // 再次设置,因为 fetchModulesForSelect 会清空它}}await fetchTestCases();pageLoading.value = false;
})const handleSearch = () => {queryParams.page = 1 // 搜索时重置到第一页fetchTestCases()
}const handleReset = () => {queryFormRef.value?.resetFields() // Element Plus 表单重置方法// queryParams.project_id = null; // resetFields可能不会重置非表单组件绑定的值// queryParams.module_id = null;// moduleOptions.value = [];// 手动重置非表单元素绑定的queryParams属性queryParams.page = 1;queryParams.project_id = null; // 这会触发 watch 清空 module_id 和 moduleOptionsqueryParams.priority = null;queryParams.case_type = null;queryParams.search = '';// 重置后立即搜索fetchTestCases()
}const handlePageChange = (newPage: number) => {queryParams.page = newPagefetchTestCases()
}const handleSizeChange = (newSize: number) => {queryParams.page_size = newSizequeryParams.page = 1 // 切换每页大小时回到第一页fetchTestCases()
}const navigateToCreate = () => {router.push('/testcase/create')
}const handleEdit = (testCaseId: number) => {router.push(`/testcase/edit/${testCaseId}`)
}const handleDelete = async (testCaseId: number) => {try {await ElMessageBox.confirm('此操作将永久删除该测试用例,是否继续?', '警告', {confirmButtonText: '确定删除',cancelButtonText: '取消',type: 'warning',});tableLoading.value = true; // 表示正在删除await deleteTestCase(testCaseId);ElMessage.success('测试用例删除成功!');// 如果删除的是当前页的最后一条数据,且不是第一页,则可能需要跳转到前一页if (testCases.value.length === 1 && queryParams.page! > 1) {queryParams.page!--;}fetchTestCases(); // 重新加载列表} catch (error) {if (error !== 'cancel') { // 用户点击取消时不提示错误console.error('删除测试用例失败:', error);}} finally {tableLoading.value = false;}
}// 辅助函数
const formatDateTime = (dateTimeStr: string) => {if (!dateTimeStr) return ''return new Date(dateTimeStr).toLocaleString()
}
const getPriorityTagType = (priority: string) => {const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = { P0: 'danger', P1: 'warning', P2: '', P3: 'info' }return map[priority] || 'info'
}
const getCaseTypeText = (caseType: string) => {const map: Record<string, string> = { functional: '功能测试', api: '接口测试', ui: 'UI测试' };return map[caseType] || caseType;
}</script><style scoped lang="scss">
.testcase-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>
代码解释与关键点:
- 筛选表单 (
filter-card
):- 使用
el-form
和el-form-item
构建筛选区域。 - 项目选择器:
fetchProjectsForSelect
获取项目列表填充选项。 - 模块选择器:
fetchModulesForSelect
根据选中的项目动态加载模块选项。通过watch(() => queryParams.project_id, ...)
实现联动。 - 其他筛选条件如优先级、类型、关键词输入框。
- “搜索”按钮调用
handleSearch
,“重置”按钮调用handleReset
。
- 使用
- 用例表格 (
table-card
):- 顶部有“新建用例”按钮。
- 使用
el-table
展示用例数据。关键列包括 ID, 名称 (可点击跳转编辑), 所属项目/模块, 优先级 (带标签), 类型, 更新时间等。 show-overflow-tooltip
用于内容过长时显示省略号和tooltip。- “操作”列包含“编辑”和“删除”按钮。
- 分页控件 (
el-pagination
):- 当
totalCases > 0
时显示。 - 绑定了
queryParams.page
,queryParams.page_size
,totalCases
。 - 监听
@size-change
和@current-change
事件,分别调用handleSizeChange
和handlePageChange
来更新查询参数并重新获取数据。
- 当
- 数据获取与状态:
testCases
,totalCases
,loading
(分为pageLoading
和tableLoading
) 等响应式变量。queryParams
对象存储所有查询、筛选和分页参数。fetchTestCases()
是核心数据获取函数,它将queryParams
发送给getTestCaseList
API,并用返回的results
和count
更新本地状态。
- 初始化加载:
onMounted
中首先加载项目选项,然后检查路由查询参数moduleId
(用于支持从用例编辑页成功后跳转回列表并筛选到对应模块)。最后调用fetchTestCases
加载初始用例列表。
- 操作处理:
handleSearch
: 重置页码为1,然后获取数据。handleReset
: 重置表单和queryParams
(注意resetFields
对非表单组件绑定的数据可能无效,需要手动重置),然后获取数据。navigateToCreate
,handleEdit
: 使用router.push
跳转到用例创建/编辑页。handleDelete
: 弹出确认框,调用删除 API,成功后刷新列表。增加了删除当前页最后一条数据时页码处理的逻辑。
- 辅助函数: 用于格式化日期、显示优先级标签颜色、用例类型文本等。
第三步:测试列表、搜索、筛选和分页
-
确保后端服务运行正常,并且你已经创建了一些测试用例,分布在不同的项目和模块下,具有不同的优先级和类型。
-
启动前端开发服务器 (
npm run dev
)。 -
登录并访问用例列表页 (
http://127.0.0.1:5173/testcases
):
- 初始加载: 表格应显示第一页的测试用例数据,分页控件应正确显示总数。
- 分页测试: 点击页码、上一页/下一页、改变每页显示数量,观察表格数据和分页控件是否正确更新,Network 面板中的 API 请求参数 (
page
,page_size
) 是否正确。 - 筛选测试:
- 选择一个项目,模块选择器应动态加载该项目下的模块。
- 选择项目、模块、优先级、类型,点击“搜索”。观察表格数据是否按条件筛选,Network 请求参数是否正确。
- 关键词搜索测试: 输入关键词 (如用例名称的一部分),点击“搜索”。观察结果。
- 重置测试: 点击“重置”按钮,所有筛选条件应清空,列表应恢复显示所有用例的第一页。
- 操作测试:
- 点击“新建用例”按钮,应跳转到用例创建页。
- 点击某行用例的名称或“编辑”按钮,应跳转到该用例的编辑页,并能正确加载数据。
- 点击“删除”按钮,确认后,该用例应被删除,列表刷新。
- 从编辑/新建页跳转测试: 在用例编辑/新建成功后,会跳转回列表页,并尝试根据创建/编辑的用例所属模块进行筛选。验证此功能。
总结
在这篇文章中,我们成功地实现了测试用例的列表展示、分页、搜索和筛选:
- ✅ 更新了
api/testcase.ts
中的 API 服务函数和类型定义,以更好地支持分页、搜索和过滤参数,并处理 DRF 标准的分页响应结构。 - ✅ 确保了后端
TestCaseViewSet
配置了SearchFilter
和DjangoFilterBackend
以支持前端的搜索和筛选需求。 - ✅ 创建并实现了
TestCaseListView.vue
组件,其核心功能包括:- 一个包含项目、模块、优先级、类型选择器以及关键词输入框的筛选/搜索表单。
- 项目和模块选择器的联动加载。
- 使用
ElTable
清晰地展示测试用例的关键信息,并提供行内操作。 - 使用
ElPagination
实现前端分页控制,与后端分页数据同步。
- ✅ 实现了从列表页到用例创建/编辑页的跳转,以及从创建/编辑成功后返回列表并尝试定位到相关模块的逻辑。
现在,我们的测试平台在用例管理方面已经具备了查找、展示、新建、编辑、删除的完整闭环,可以高效地管理和维护测试用例库了。
在下一篇文章中,我们将继续完善核心功能,开始构建测试计划/测试套件的管理。这将涉及到如何从现有的用例库中选择用例,将它们组织成可执行的集合。