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

发布于:2025-06-03 ⋅ 阅读:(22) ⋅ 点赞:(0)

前言

随着测试用例数量的增加,如何有效地组织和管理这些用例以进行特定目的的测试(例如回归测试、新功能测试)就变得至关重要。测试计划 (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 # 确保导入 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 列表。
  • 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_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_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 的输出。
  • 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-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

第五步:测试完整流程

  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 流程。

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

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


网站公告

今日签到

点亮在社区的每一天
去签到