Spring Boot 入门 Day 5:集成测试 + 打包部署,项目收工上线

写完代码只是第一步,测过、打包、能跑才叫真的搞定了。


今天要解决什么问题?

前 4 天我们写出了完整的 CRUD API,但你有没有想过:

  • 你怎么保证"改了一行代码,另一个功能没坏"?
  • 每次都手动用 Invoke-WebRequest 一条条测?不累吗?
  • 代码写完了怎么给别人用?发源码让人装 Maven?

今天一次性解决这三个问题:自动化测试 + 可执行 jar 包


核心概念:测试金字塔

        /\
       /E2E\        ← 少:端到端测试(启动整个系统,模拟真实用户行为)
      /------\
     /集成测试\     ← 中:MockMvc 测试整个请求链路
    /----------\
   /  单元测试  \   ← 多:测单个方法,快且低成本
  /--------------\
测试类型 测什么 速度 我们今天做
单元测试 一个方法/类(依赖全 mock 掉) 极快
集成测试 从 HTTP 请求 → Controller → Service → 数据库全链路
E2E 测试 启动真实浏览器,模拟用户点击

今天我们写的是集成测试——性价比最高的一层。


MockMvc 是什么?

常规测试思维:启动项目 → 开浏览器 → 用 Postman 发请求 → 看结果

真实测试:
  你的电脑 → HTTP 请求 → 8080 端口 → Tomcat → Spring → 返回 JSON
  缺点:慢、要手动发请求、要人工判断结果

MockMvc 的做法:不走网络,直接发给 Spring 内部

MockMvc 测试:
  测试代码 → 模拟 HTTP 请求 → 直接交给 Spring MVC 处理 → 返回结果
  优点:不占端口、自动断言结果、速度快、可重复跑

MockMvc = 假装自己是 HTTP 客户端,直接跟 Spring MVC 对话。全链路都走了,就是不走网线。


动手写测试(7 个用例)

测试文件结构

@SpringBootTest           // 1. 启动完整 Spring 上下文(含数据库)
@AutoConfigureMockMvc     // 2. 自动配置 MockMvc
class TaskControllerTest {

    @Autowired
    private MockMvc mockMvc;          // 3. 模拟 HTTP 请求的工具

    @Autowired
    private ObjectMapper objectMapper; // 4. Java对象 ↔ JSON 互转
}

关键注解解析

注解 作用
@SpringBootTest 启动完整的 Spring 容器(包括数据库),是集成测试的标志
@AutoConfigureMockMvc 自动创建 MockMvc 实例并注入
@Test 标记这是一个测试方法
@BeforeEach 每个测试方法执行前先跑,保证测试互相独立

测试 1:正常创建任务(应返回 201)

@Test
void shouldCreateTask() throws Exception {
    // 准备数据
    CreateTaskRequest req = new CreateTaskRequest();
    req.setTitle("测试任务");
    req.setPriority(Priority.HIGH);

    // 发请求 + 断言结果
    mockMvc.perform(post("/api/tasks")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(req)))   // DTO → JSON
            .andExpect(status().isCreated())                   // 期望返回 201
            .andExpect(jsonPath("$.title").value("测试任务"))   // 期望 title 正确
            .andExpect(jsonPath("$.status").value("TODO"))      // 默认状态是 TODO
            .andExpect(jsonPath("$.id").isNumber());            // 自动生成 id
}

一行行拆解:

mockMvc.perform(...)           → 发送模拟 HTTP 请求
  .andExpect(...)              → 断言结果(链式调用,可以写多个)
    status().isCreated()       → HTTP 状态码必须是 201
    jsonPath("$.title")...     → 返回 JSON 的 title 字段必须等于 "测试任务"
    jsonPath("$.id").isNumber() → id 字段必须是数字

jsonPath 语法速记: $ 表示整个 JSON 对象,$.field 取字段值。


测试 2:空标题 → 应返回 400

@Test
void shouldRejectEmptyTitle() throws Exception {
    CreateTaskRequest req = new CreateTaskRequest();
    req.setTitle("");            // 空字符串!
    req.setPriority(Priority.MEDIUM);

    mockMvc.perform(post("/api/tasks")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(req)))
            .andExpect(status().isBadRequest())    // 期望 400
            .andExpect(jsonPath("$.code").value(400));
}
空标题 → @NotBlank 校验不通过 → MethodArgumentNotValidException 
     → GlobalExceptionHandler 拦截 → 返回 { code: 400, message: "输入校验失败" }

这一步验证了校验和异常处理全局链


测试 3:缺少必填字段 → 应返回 400

@Test
void shouldRejectMissingPriority() throws Exception {
    CreateTaskRequest req = new CreateTaskRequest();
    req.setTitle("无优先级");
    // 不设 priority → @NotNull 校验失败

    mockMvc.perform(post("/api/tasks")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(req)))
            .andExpect(status().isBadRequest());
}

测试 4:根据 ID 查询(先创建再查询)

@Test
void shouldReturnTaskById() throws Exception {
    // 第一步:创建任务
    CreateTaskRequest req = new CreateTaskRequest();
    req.setTitle("Spring Boot");
    req.setPriority(Priority.HIGH);

    String response = mockMvc.perform(post("/api/tasks")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(req)))
            .andReturn().getResponse().getContentAsString();  // 拿到返回 JSON

    int id = objectMapper.readTree(response).get("id").asInt(); // 提取 id

    // 第二步:用 id 查询
    mockMvc.perform(get("/api/tasks/" + id))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.title").value("Spring Boot"));
}

注意:测试里可以按顺序执行多个请求,这就是集成测试的优势——真实模拟用户操作流程。


测试 5:查询不存在的 ID → 应返回 404

@Test
void shouldReturn404WhenNotFound() throws Exception {
    mockMvc.perform(get("/api/tasks/99999"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.code").value(404));
}

这个测试最简单粗暴——直接查一个不存在的 ID,验证全局异常处理返回 404。


测试 6:更新任务(部分字段更新)

@Test
void shouldUpdateTask() throws Exception {
    // 创建
    CreateTaskRequest req = new CreateTaskRequest();
    req.setTitle("原始标题");
    req.setPriority(Priority.LOW);
    String response = mockMvc.perform(post("/api/tasks")...)
            .andReturn().getResponse().getContentAsString();
    int id = objectMapper.readTree(response).get("id").asInt();

    // 只更新标题
    mockMvc.perform(put("/api/tasks/" + id)
            .contentType(MediaType.APPLICATION_JSON)
            .content("{\"title\":\"新标题\"}"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.title").value("新标题"))      // 标题变了
            .andExpect(jsonPath("$.priority").value("LOW"));     // 优先级没变!
}

验证了部分更新不覆盖其他字段的逻辑。


测试 7:删除任务(删完查不到)

@Test
void shouldDeleteTask() throws Exception {
    // 创建
    CreateTaskRequest req = new CreateTaskRequest();
    req.setTitle("待删除");
    req.setPriority(Priority.MEDIUM);
    String response = mockMvc.perform(post("/api/tasks")...)
            .andReturn().getResponse().getContentAsString();
    int id = objectMapper.readTree(response).get("id").asInt();

    // 删除
    mockMvc.perform(delete("/api/tasks/" + id))
            .andExpect(status().isNoContent());   // 期望 204

    // 再查 → 应该 404
    mockMvc.perform(get("/api/tasks/" + id))
            .andExpect(status().isNotFound());
}

三步串联:创建 → 删 → 确认删干净了。


运行测试

cd F:/java.work/learning.txt/agent/task-manager-spring
mvn test

期望输出:

Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

测试 vs 手动 curl 对比

MockMvc 测试 手动 Invoke-WebRequest
重复执行 mvn test 一键全跑 一条条手动复制粘贴
断言 自动判断结果对错 肉眼对比 JSON
以后改代码 跑一次测试就知道有没有坏 可能漏测,上线才发现
速度 毫秒级 每次要等 HTTP

测试的价值:你今天写完 7 个用例,以后每次改代码只需要 mvn test,3 秒告诉你有没有改坏。


打包部署:源码 → 可运行 jar

# 打包(跳过测试,因为测试依赖 @BeforeEach 清库端点)
mvn package -DskipTests

# 运行
java -jar target/task-manager-spring-1.0.0.jar

浏览器打开 http://localhost:8080/api/tasks,确认能正常访问。

为什么一个 jar 就能跑?Tomcat 在哪?

task-manager-spring-1.0.0.jar
├── BOOT-INF/
│   ├── classes/         ← 你的代码(编译后的 .class)
│   └── lib/             ← 所有依赖(Spring Boot、Hibernate、H2、Tomcat...)
├── META-INF/
└── org/springframework/boot/loader/   ← 启动加载器

Spring Boot 把 Tomcat 打包进了 jar 里(内嵌 Tomcat),所以:

  • 不需要单独安装 Tomcat
  • 不需要配置任何应用服务器
  • java -jar xxxx.jar 一条命令搞定

等你换到生产环境,直接把 jar 拷到服务器,同样一条命令启动。


Day 5 总结

要点 一句话
MockMvc 不走网络但走完整 Spring MVC 链路,测全链路
@SpringBootTest 启动完整容器(含数据库),是集成测试的标志
链式断言 andExpect(jsonPath(...).value(...)) 语义清晰
mvn test 一键跑完所有测试,3 秒验证代码质量
mvn package 产出可执行 jar(内嵌 Tomcat + 全部依赖)
java -jar 一条命令部署,不用装任何东西

Week 1 完整回顾

5 天下来,你掌握的项目结构:

task-manager-spring/
├── pom.xml
└── src/
    ├── main/java/com/agent/
    │   ├── Application.java              ← 启动类
    │   ├── controller/TaskController.java ← REST API(5 个端点)
    │   ├── service/TaskService.java       ← 业务 + 事务
    │   ├── repository/TaskRepository.java ← JPA 接口,方法名即 SQL
    │   ├── model/Task.java                ← 数据库实体
    │   ├── dto/                           ← 请求/响应隔离
    │   └── exception/                     ← 全局异常处理
    └── test/java/com/agent/controller/
        └── TaskControllerTest.java        ← 7 个集成测试
技能 掌握
REST API GET/POST/PUT/DELETE 完整实现
参数校验 @Valid + Bean Validation + 统一错误格式
JPA 持久化 @Entity + JpaRepository 方法名自动生成 SQL
H2 数据库 文件模式重启不丢失 + Web Console 可视化
全局异常处理 @RestControllerAdvice 拦截所有异常
DTO 模式 请求/响应/实体三层分离
集成测试 MockMvc 一键验证 7 个用例
打包部署 java -jar 一条命令上线

一句话记住 Day 5:写完代码不算完,测试通过 + 打包能跑才算数。


Week 1 结束。下一阶段:认证授权、日志监控、Docker 容器化部署。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐