Developer Guide

实战教程:开发 CRUD 功能

从零开始开发一个完整的增删改查功能模块。

本教程将以开发一个简单的 "文章管理 (Post)" 功能为例,带您完整体验 Tagtag Starter 的前后端开发流程。

我们将实现以下功能:

  1. 文章列表的分页查询(支持条件筛选)
  2. 新增文章(包含表单验证)
  3. 编辑文章(支持部分字段更新)
  4. 删除文章(支持单个和批量删除)
  5. 文章状态管理(启用/禁用)
  6. 批量操作(批量删除、批量更新状态)

第一步:后端开发 (Backend)

1. 数据库设计

首先,在数据库中创建 sys_post 表。

CREATE TABLE `sys_post` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `title` varchar(100) NOT NULL COMMENT '文章标题',
  `content` text COMMENT '文章内容',
  `status` tinyint(4) DEFAULT 1 COMMENT '状态: 1-发布, 0-草稿',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `create_by` bigint(20) DEFAULT NULL COMMENT '创建人',
  `update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
  `deleted` tinyint(4) DEFAULT 0 COMMENT '删除标识: 0-未删除, 1-已删除',
  PRIMARY KEY (`id`),
  KEY `idx_title` (`title`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章表';

2. 定义契约 (Contract)

tagtag-contract 模块中定义 DTO (Data Transfer Object)。建议新建 PostDTO.javaPostQueryDTO.java

PostDTO.java (用于新增/修改)

package dev.tagtag.contract.system.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

@Data
public class PostDTO {
    private Long id;

    @NotBlank(message = "标题不能为空")
    private String title;

    private String content;

    @NotNull(message = "状态不能为空")
    private Integer status;
}

PostQueryDTO.java (用于查询)

package dev.tagtag.contract.system.dto;

import lombok.Data;

@Data
public class PostQueryDTO {
    private String title;
    private Integer status;
}

PostOperationRequest.java (用于批量操作)

package dev.tagtag.contract.system.dto;

import jakarta.validation.constraints.NotEmpty;
import lombok.Data;

@Data
public class PostOperationRequest {
    @NotEmpty(message = "ID列表不能为空")
    private java.util.List<Long> ids;

    @NotNull(message = "状态不能为空")
    private Integer status;

    private String password;
}

3. 持久层实现 (Mapper & Entity)

tagtag-module-system 模块中创建实体类和 Mapper。

PostEntity.java

package dev.tagtag.module.system.entity;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import dev.tagtag.framework.mybatis.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_post")
public class PostEntity extends BaseEntity {
    private String title;
    private String content;
    private Integer status;

    @TableLogic
    private Integer deleted;
}

PostMapper.java

package dev.tagtag.module.system.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import dev.tagtag.contract.system.dto.PostQueryDTO;
import dev.tagtag.module.system.entity.Post;
import dev.tagtag.module.system.entity.vo.PostVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface PostMapper extends BaseMapper<Post> {

    /**
     * XML 分页查询(由 MyBatis XML 构建 WHERE/ORDER BY)
     *
     * @param page 分页对象(MyBatis Plus 拦截器识别)
     * @param q    查询条件 DTO
     * @return 分页结果
     */
    IPage<PostVO> selectPage(IPage<PostVO> page, @Param("q") PostQueryDTO q);
}

PostVO.java (视图对象)

package dev.tagtag.module.system.entity.vo;

import lombok.Data;

import java.time.LocalDateTime;

@Data
public class PostVO {
    private Long id;
    private String title;
    private String content;
    private Integer status;
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
}

4. 业务层实现 (Service)

PostService.java

package dev.tagtag.module.system.service;

import dev.tagtag.common.model.PageQuery;
import dev.tagtag.common.model.PageResult;
import dev.tagtag.contract.system.dto.PostDTO;
import dev.tagtag.contract.system.dto.PostQueryDTO;
import java.util.List;

public interface PostService {

    /**
     * 文章分页查询
     * @param query 查询条件DTO
     * @param pageQuery 分页参数
     * @return 分页结果
     */
    PageResult<PostDTO> page(PostQueryDTO query, PageQuery pageQuery);

    /**
     * 获取文章详情
     * @param id 文章ID
     * @return 文章DTO
     */
    PostDTO getById(Long id);

    /**
     * 创建文章
     * @param post 文章DTO
     */
    void create(PostDTO post);

    /**
     * 更新文章(忽略源对象中的空值)
     * @param post 文章DTO
     */
    void update(PostDTO post);

    /**
     * 删除文章
     * @param id 文章ID
     */
    void delete(Long id);

    /**
     * 批量删除文章
     * @param ids 文章ID列表
     */
    void batchDelete(List<Long> ids);

    /**
     * 更新单个文章状态
     * @param id 文章ID
     * @param status 状态值
     */
    void updateStatus(Long id, Integer status);

    /**
     * 批量更新文章状态
     * @param ids 文章ID列表
     * @param status 状态值
     */
    void batchUpdateStatus(List<Long> ids, Integer status);
}

PostServiceImpl.java

package dev.tagtag.module.system.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import dev.tagtag.common.model.PageQuery;
import dev.tagtag.common.model.PageResult;
import dev.tagtag.common.util.BeanUtil;
import dev.tagtag.contract.system.dto.PostDTO;
import dev.tagtag.contract.system.dto.PostQueryDTO;
import dev.tagtag.module.system.entity.Post;
import dev.tagtag.module.system.entity.vo.PostVO;
import dev.tagtag.module.system.mapper.PostMapper;
import dev.tagtag.module.system.service.PostService;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
public class PostServiceImpl implements PostService {

    private final PostMapper postMapper;

    @Override
    public PageResult<PostDTO> page(PostQueryDTO query, PageQuery pageQuery) {
        IPage<PostVO> page = new Page<>(pageQuery.getPage(), pageQuery.getSize());
        IPage<PostVO> result = postMapper.selectPage(page, query);
        
        return PageResult.of(
            result.getRecords().stream()
                .map(vo -> BeanUtil.copyProperties(vo, PostDTO.class))
                .toList(),
            result.getTotal()
        );
    }

    @Override
    public PostDTO getById(Long id) {
        Post post = postMapper.selectById(id);
        if (post == null) {
            throw new RuntimeException("文章不存在");
        }
        return BeanUtil.copyProperties(post, PostDTO.class);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void create(PostDTO post) {
        Post entity = BeanUtil.copyProperties(post, Post.class);
        postMapper.insert(entity);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void update(PostDTO post) {
        Post entity = BeanUtil.copyProperties(post, Post.class);
        postMapper.updateById(entity);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void delete(Long id) {
        postMapper.deleteById(id);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void batchDelete(List<Long> ids) {
        postMapper.deleteBatchIds(ids);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateStatus(Long id, Integer status) {
        Post entity = new Post();
        entity.setId(id);
        entity.setStatus(status);
        postMapper.updateById(entity);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void batchUpdateStatus(List<Long> ids, Integer status) {
        ids.forEach(id -> updateStatus(id, status));
    }
}

5. 控制层实现 (Controller)

PostController.java

package dev.tagtag.module.system.controller;

import dev.tagtag.common.model.PageQuery;
import dev.tagtag.common.model.PageResult;
import dev.tagtag.common.model.Result;
import dev.tagtag.contract.system.dto.PostDTO;
import dev.tagtag.contract.system.dto.PostQueryDTO;
import dev.tagtag.module.system.service.PostService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/posts")
@Tag(name = "文章管理")
@RequiredArgsConstructor
public class PostController {
    
    private final PostService postService;

    /**
     * 分页查询文章
     */
    @GetMapping
    @Operation(summary = "分页查询文章")
    @PreAuthorize("@ss.hasPermission('system:post:query')")
    public Result<PageResult<PostDTO>> page(PostQueryDTO query, PageQuery pageQuery) {
        return Result.ok(postService.page(query, pageQuery));
    }

    /**
     * 获取文章详情
     */
    @GetMapping("/{id}")
    @Operation(summary = "获取文章详情")
    @PreAuthorize("@ss.hasPermission('system:post:query')")
    public Result<PostDTO> getById(@PathVariable Long id) {
        return Result.ok(postService.getById(id));
    }

    /**
     * 创建文章
     */
    @PostMapping
    @Operation(summary = "创建文章")
    @PreAuthorize("@ss.hasPermission('system:post:create')")
    public Result<Void> create(@Valid @RequestBody PostDTO dto) {
        postService.create(dto);
        return Result.okMsg("创建成功");
    }

    /**
     * 更新文章
     */
    @PutMapping
    @Operation(summary = "更新文章")
    @PreAuthorize("@ss.hasPermission('system:post:update')")
    public Result<Void> update(@Valid @RequestBody PostDTO dto) {
        postService.update(dto);
        return Result.okMsg("更新成功");
    }

    /**
     * 删除文章
     */
    @DeleteMapping("/{id}")
    @Operation(summary = "删除文章")
    @PreAuthorize("@ss.hasPermission('system:post:delete')")
    public Result<Void> delete(@PathVariable Long id) {
        postService.delete(id);
        return Result.okMsg("删除成功");
    }

    /**
     * 批量删除文章
     */
    @DeleteMapping("/batch")
    @Operation(summary = "批量删除文章")
    @PreAuthorize("@ss.hasPermission('system:post:delete')")
    public Result<Void> batchDelete(@RequestBody List<Long> ids) {
        postService.batchDelete(ids);
        return Result.okMsg("批量删除成功");
    }

    /**
     * 更新文章状态
     */
    @PutMapping("/{id}/status")
    @Operation(summary = "更新文章状态")
    @PreAuthorize("@ss.hasPermission('system:post:update')")
    public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
        postService.updateStatus(id, status);
        return Result.okMsg("状态更新成功");
    }

    /**
     * 批量更新文章状态
     */
    @PutMapping("/status/batch")
    @Operation(summary = "批量更新文章状态")
    @PreAuthorize("@ss.hasPermission('system:post:update')")
    public Result<Void> batchUpdateStatus(@RequestBody List<Long> ids, @RequestParam Integer status) {
        postService.batchUpdateStatus(ids, status);
        return Result.okMsg("批量状态更新成功");
    }
}

关键要点:

  • 使用 @PreAuthorize 注解进行权限控制
  • 使用 @Operation 注解提供 API 文档说明
  • 所有修改操作(创建、更新、删除)都需要事务支持
  • 批量操作提供统一的入口,提高前端调用效率
  • 状态管理提供单独的更新接口,便于前端实现启用/禁用功能

第二步:前端开发 (Frontend)

1. 定义 API

frontend/apps/tagtag/src/api/modules/system/post.ts 中定义请求。

post.ts

import { request } from '@/api/request';

export interface PostDTO {
  id?: number;
  title: string;
  content?: string;
  author?: string;
  status?: number;
  categoryId?: number;
  tags?: string[];
  coverUrl?: string;
  summary?: string;
}

export interface PostQueryDTO {
  title?: string;
  author?: string;
  status?: number;
  categoryId?: number;
  startDate?: string;
  endDate?: string;
}

export interface PageResult<T> {
  records: T[];
  total: number;
  current: number;
  size: number;
}

/**
 * 分页查询文章
 */
export function getPostPage(params: PostQueryDTO & { page: number; size: number }) {
  return request.get<PageResult<PostDTO>>({ url: '/posts', params });
}

/**
 * 获取文章详情
 */
export function getPostById(id: number) {
  return request.get<PostDTO>({ url: `/posts/${id}` });
}

/**
 * 创建文章
 */
export function createPost(data: PostDTO) {
  return request.post({ url: '/posts', data });
}

/**
 * 更新文章
 */
export function updatePost(data: PostDTO) {
  return request.put({ url: '/posts', data });
}

/**
 * 删除文章
 */
export function deletePost(id: number) {
  return request.delete({ url: `/posts/${id}` });
}

/**
 * 批量删除文章
 */
export function batchDeletePost(ids: number[]) {
  return request.delete({ url: '/posts/batch', data: ids });
}

/**
 * 更新文章状态
 */
export function updatePostStatus(id: number, status: number) {
  return request.put({ url: `/posts/${id}/status`, params: { status } });
}

/**
 * 批量更新文章状态
 */
export function batchUpdatePostStatus(ids: number[], status: number) {
  return request.put({ url: '/posts/status/batch', data: ids, params: { status } });
}

关键要点:

  • 定义清晰的 TypeScript 接口类型
  • 所有 API 方法都有明确的类型注解
  • 批量操作提供单独的接口方法
  • 状态管理提供独立的更新方法

2. 创建列表页面

创建 frontend/apps/tagtag/src/views/modules/system/post/index.vue

<script setup lang="ts">
import { ref, reactive } from 'vue';
import { useTable } from '@vben/plugins/vxe-table';
import { getPostPage, deletePost, addPost, updatePost } from '@/api/modules/system/post';
import { Button, Tag, Modal } from 'ant-design-vue';
import FormModal from './FormModal.vue';

// 表格配置
const [Grid, gridApi] = useTable({
  api: getPostPage,
  columns: [
    { type: 'checkbox', width: 50 },
    { field: 'title', title: '标题' },
    { field: 'status', title: '状态', slots: { default: 'status' } },
    { field: 'createTime', title: '创建时间', width: 180 },
    { title: '操作', slots: { default: 'action' }, width: 150 },
  ],
  formConfig: {
    items: [
      { field: 'title', label: '标题', component: 'Input' },
      { 
        field: 'status', 
        label: '状态', 
        component: 'Select',
        componentProps: {
          options: [
            { label: '已发布', value: 1 },
            { label: '草稿', value: 0 }
          ]
        }
      }
    ]
  },
  toolbarConfig: {
    buttons: [
      { code: 'add', name: '新增', status: 'primary' },
      { code: 'delete', name: '删除', status: 'danger' }
    ]
  }
});

// 弹窗配置
const modalVisible = ref(false);
const modalTitle = ref('');
const formData = reactive({});
const isEdit = ref(false);

// 打开新增弹窗
function handleAdd() {
  isEdit.value = false;
  modalTitle.value = '新增文章';
  Object.assign(formData, {});
  modalVisible.value = true;
}

// 打开编辑弹窗
function handleEdit(row) {
  isEdit.value = true;
  modalTitle.value = '编辑文章';
  Object.assign(formData, row);
  modalVisible.value = true;
}

// 删除单个文章
async function handleDelete(row) {
  Modal.confirm({
    title: '确认删除',
    content: `确定要删除文章 "${row.title}" 吗?`,
    onOk: async () => {
      await deletePost([row.id]);
      gridApi.refresh();
    }
  });
}

// 批量删除
async function handleBatchDelete(ids) {
  Modal.confirm({
    title: '确认删除',
    content: `确定要删除选中的 ${ids.length} 篇文章吗?`,
    onOk: async () => {
      await deletePost(ids);
      gridApi.refresh();
    }
  });
}

// 保存文章
async function handleSave(data) {
  if (isEdit.value) {
    await updatePost(data);
  } else {
    await addPost(data);
  }
  modalVisible.value = false;
  gridApi.refresh();
}

// 监听工具栏按钮点击
function handleToolbarClick(button) {
  if (button.code === 'add') {
    handleAdd();
  } else if (button.code === 'delete') {
    const selectedRows = gridApi.getSelectedRows();
    if (selectedRows.length === 0) {
      Modal.warning({ title: '提示', content: '请选择要删除的文章' });
      return;
    }
    handleBatchDelete(selectedRows.map(row => row.id));
  }
}
</script>

<template>
  <Grid @toolbar-click="handleToolbarClick">
    <template #status="{ row }">
      <Tag :color="row.status === 1 ? 'green' : 'red'">
        {{ row.status === 1 ? '已发布' : '草稿' }}
      </Tag>
    </template>
    <template #action="{ row }">
      <Button type="link" @click="handleEdit(row)">编辑</Button>
      <Button type="link" danger @click="handleDelete(row)">删除</Button>
    </template>
  </Grid>

  <!-- 新增/编辑弹窗 -->
  <FormModal
    v-model:visible="modalVisible"
    :title="modalTitle"
    :form-data="formData"
    @save="handleSave"
  />
</template>

3. 创建表单弹窗组件

创建 frontend/apps/tagtag/src/views/modules/system/post/FormModal.vue

<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { Modal, Form, Input, Select, Switch } from 'ant-design-vue';

// Props
const props = defineProps({
  visible: {
    type: Boolean,
    default: false
  },
  title: {
    type: String,
    default: ''
  },
  formData: {
    type: Object,
    default: () => ({})
  }
});

// Emits
const emit = defineEmits(['update:visible', 'save']);

// 表单实例
const formRef = ref();

// 表单模型
const formModel = reactive({
  id: null,
  title: '',
  content: '',
  status: 1
});

// 监听表单数据变化
watch(
  () => props.formData,
  (newData) => {
    Object.assign(formModel, newData);
  },
  { deep: true, immediate: true }
);

// 关闭弹窗
function handleCancel() {
  emit('update:visible', false);
  formRef.value?.resetFields();
}

// 提交表单
async function handleSubmit() {
  if (!formRef.value) return;
  
  try {
    await formRef.value.validate();
    emit('save', { ...formModel });
    emit('update:visible', false);
  } catch (error) {
    console.error('表单验证失败:', error);
  }
}
</script>

<template>
  <Modal
    v-model:open="visible"
    :title="title"
    ok-text="保存"
    cancel-text="取消"
    @ok="handleSubmit"
    @cancel="handleCancel"
    width="800px"
  >
    <Form
      ref="formRef"
      :model="formModel"
      layout="vertical"
      :rules="{
        title: [{ required: true, message: '请输入文章标题', trigger: 'blur' }],
        content: [{ required: true, message: '请输入文章内容', trigger: 'blur' }]
      }"
    >
      <Form.Item name="id" hidden>
        <Input v-model:value="formModel.id" />
      </Form.Item>
      
      <Form.Item label="文章标题" name="title">
        <Input v-model:value="formModel.title" placeholder="请输入文章标题" />
      </Form.Item>
      
      <Form.Item label="文章内容" name="content">
        <Input.TextArea 
          v-model:value="formModel.content" 
          placeholder="请输入文章内容"
          rows="6"
        />
      </Form.Item>
      
      <Form.Item label="发布状态" name="status">
        <Switch 
          v-model:checked="formModel.status" 
          checked-children="已发布" 
          un-checked-children="草稿"
        />
      </Form.Item>
    </Form>
  </Modal>
</template>

3. 配置菜单

  1. 启动项目,登录管理员账号。
  2. 进入 系统管理 -> 菜单管理
  3. 新增菜单 “文章管理”,路由地址填 /system/post,组件路径填 modules/system/post/index.vue
  4. 刷新页面,即可看到新功能。

第三步:测试用例编写

1. 后端单元测试

为了确保代码质量和功能正确性,我们需要为后端服务编写单元测试。Tagtag Starter 使用 JUnit 5 和 Mockito 进行单元测试。

1.1 准备测试环境

首先,确保在 pom.xml 中添加了必要的测试依赖:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

1.2 编写 Service 层测试

创建 PostServiceImplTest.java 文件,位于 src/test/java 目录下:

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.assertj.core.api.Assertions.assertThat;

@ExtendWith(MockitoExtension.class)
class PostServiceImplTest {

    @Mock
    private PostMapper postMapper;

    @InjectMocks
    private PostServiceImpl postService;

    @Test
    void testPage() {
        // 准备测试数据
        PostQuery query = new PostQuery();
        query.setPage(1);
        query.setSize(10);
        query.setTitle("测试");

        // 模拟分页结果
        Page<PostEntity> page = new Page<>(1, 10);
        List<PostEntity> records = new ArrayList<>();
        records.add(new PostEntity(1L, "测试文章", "内容", 1));
        page.setRecords(records);
        page.setTotal(1);

        // 设置 Mock 行为
        when(postMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class))).thenReturn(page);

        // 执行测试
        PageResult<PostEntity> result = postService.page(query);

        // 验证结果
        assertThat(result).isNotNull();
        assertThat(result.getTotal()).isEqualTo(1);
        assertThat(result.getRecords()).hasSize(1);
        assertThat(result.getRecords().get(0).getTitle()).isEqualTo("测试文章");
    }

    @Test
    void testSave() {
        // 准备测试数据
        PostDTO dto = new PostDTO();
        dto.setTitle("测试文章");
        dto.setContent("内容");
        dto.setStatus(1);

        // 设置 Mock 行为
        when(postMapper.insert(any(PostEntity.class))).thenReturn(1);

        // 执行测试
        postService.save(dto);

        // 验证结果(可以添加更多验证)
        Mockito.verify(postMapper, times(1)).insert(any(PostEntity.class));
    }

    @Test
    void testUpdate() {
        // 准备测试数据
        PostDTO dto = new PostDTO();
        dto.setId(1L);
        dto.setTitle("更新测试文章");
        dto.setContent("更新内容");
        dto.setStatus(0);

        // 设置 Mock 行为
        when(postMapper.updateById(any(PostEntity.class))).thenReturn(1);

        // 执行测试
        postService.update(dto);

        // 验证结果
        Mockito.verify(postMapper, times(1)).updateById(any(PostEntity.class));
    }

    @Test
    void testDelete() {
        // 准备测试数据
        List<Long> ids = Arrays.asList(1L, 2L, 3L);

        // 设置 Mock 行为
        when(postMapper.deleteBatchIds(ids)).thenReturn(3);

        // 执行测试
        postService.delete(ids);

        // 验证结果
        Mockito.verify(postMapper, times(1)).deleteBatchIds(ids);
    }
}

2. 前端测试

前端测试包括单元测试和集成测试,Tagtag Starter 使用 Vitest 进行单元测试,使用 Cypress 进行 E2E 测试。

2.1 编写 API 测试

创建 post.test.ts 文件,位于 frontend/apps/tagtag/src/api/modules/system/__tests__ 目录下:

import { describe, it, expect, vi } from 'vitest';
import { getPostPage, addPost, updatePost, deletePost } from '../post';
import { request } from '@/api/request';

// 模拟 request 模块
vi.mock('@/api/request', () => ({
  request: {
    get: vi.fn(),
    post: vi.fn(),
    put: vi.fn(),
    delete: vi.fn()
  }
}));

describe('Post API', () => {
  it('getPostPage should call request.get with correct params', async () => {
    // 准备测试数据
    const params = { page: 1, size: 10, title: 'test' };
    const mockResponse = { data: { records: [], total: 0 } };
    
    // 设置模拟返回值
    (request.get as vi.Mock).mockResolvedValue(mockResponse);
    
    // 执行测试
    const result = await getPostPage(params);
    
    // 验证结果
    expect(request.get).toHaveBeenCalledWith({ url: '/posts', params });
    expect(result).toEqual(mockResponse);
  });
  
  it('addPost should call request.post with correct data', async () => {
    // 准备测试数据
    const data = { title: 'test', content: 'test content', status: 1 };
    const mockResponse = { data: { success: true } };
    
    // 设置模拟返回值
    (request.post as vi.Mock).mockResolvedValue(mockResponse);
    
    // 执行测试
    const result = await addPost(data);
    
    // 验证结果
    expect(request.post).toHaveBeenCalledWith({ url: '/posts', data });
    expect(result).toEqual(mockResponse);
  });
  
  it('updatePost should call request.put with correct data', async () => {
    // 准备测试数据
    const data = { id: 1, title: 'updated', content: 'updated content', status: 0 };
    const mockResponse = { data: { success: true } };
    
    // 设置模拟返回值
    (request.put as vi.Mock).mockResolvedValue(mockResponse);
    
    // 执行测试
    const result = await updatePost(data);
    
    // 验证结果
    expect(request.put).toHaveBeenCalledWith({ url: '/posts', data });
    expect(result).toEqual(mockResponse);
  });
  
  it('deletePost should call request.delete with correct ids', async () => {
    // 准备测试数据
    const ids = [1, 2, 3];
    const mockResponse = { data: { success: true } };
    
    // 设置模拟返回值
    (request.delete as vi.Mock).mockResolvedValue(mockResponse);
    
    // 执行测试
    const result = await deletePost(ids);
    
    // 验证结果
    expect(request.delete).toHaveBeenCalledWith({ url: '/posts', data: ids });
    expect(result).toEqual(mockResponse);
  });
});

2.2 编写组件测试

创建 index.test.ts 文件,位于 frontend/apps/tagtag/src/views/modules/system/post/__tests__ 目录下:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import PostIndex from '../index.vue';
import { useTable } from '@vben/plugins/vxe-table';

// 模拟依赖
vi.mock('@vben/plugins/vxe-table', () => ({
  useTable: vi.fn(() => [
    // 模拟 Grid 组件
    { template: '<div></div>' },
    // 模拟 gridApi
    {
      refresh: vi.fn(),
      getSelectedRows: vi.fn(() => [])
    }
  ])
}));

describe('PostIndex Component', () => {
  it('should render correctly', () => {
    const wrapper = mount(PostIndex);
    expect(wrapper.exists()).toBe(true);
  });
  
  it('should call handleAdd when add button is clicked', async () => {
    // 模拟 handleAdd 方法
    const handleAdd = vi.fn();
    const wrapper = mount(PostIndex, {
      global: {
        stubs: {
          Grid: { template: '<div @toolbar-click="$emit(\'toolbar-click\', $event)"></div>' },
          FormModal: { template: '<div></div>' }
        }
      }
    });
    
    // 触发工具栏点击事件
    await wrapper.findComponent({ name: 'Grid' }).vm.$emit('toolbar-click', { code: 'add' });
    
    // 验证 handleAdd 被调用
    // 这里可以根据实际实现添加更详细的验证
    expect(wrapper.vm.modalVisible).toBe(true);
  });
});

总结

通过以上步骤,您已经成功开发了一个包含前后端交互的完整 CRUD 模块,并编写了相应的测试用例。Tagtag Starter 的模块化设计让这一切变得清晰且规范。

开发流程回顾

  1. 后端开发
    • 数据库设计
    • 定义契约(DTO、Query)
    • 持久层实现(Entity、Mapper)
    • 业务层实现(Service)
    • 控制层实现(Controller)
    • 编写单元测试
  2. 前端开发
    • 定义 API 请求
    • 创建列表页面
    • 创建表单弹窗组件
    • 配置菜单
    • 编写 API 测试和组件测试
  3. 部署和验证
    • 启动前后端服务
    • 登录系统验证功能
    • 执行测试用例

通过遵循这个流程,您可以快速、高效地开发出高质量的 CRUD 功能模块。