ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

前后端分离下后端单元测试实战:接口契约、边界用例与CI质量门禁

前后端分离下后端单元测试实战:接口契约、边界用例与CI质量门禁 聊个挺实际的问题。前后端分离开发做了这么多轮框架从 Spring Boot 换到 FastAPI脚手架从自己拼到若依这类现成工程但很多团队对后端质量的把控反而比单体时代更松了。原因不复杂分离之后后端不直接决定用户看到什么页面前端拿着接口文档就能把页面渲染出来接口一旦出错前端看到的往往只是一个 5xx 状态码问题要两个人隔着工位来回查沟通成本和debug成本都上去了。于是写单元测试这种“不赶进度”的事天然就成了第一个被牺牲的环节。但恰恰是前后端分离后端单元测试才更需要做。你想单体应用里后端改一个字段页面可能立刻白屏谁改的谁负责问题靠“崩得快”暴露分离之后接口返回字段变了前端要等联调甚至上线后才发现锅就变成“前后端没对齐”。后端的质量如果只靠人肉联调和事后修复这个团队就永远在救火。这篇文章就把我的实际经验摊开讲在前后端分离的开发模式下后端质量怎么靠单元测试真正兜住用例怎么设计、工程怎么落地、CI 怎么卡住以及一路上踩过的坑。适合正在做后端开发、接口联调经常扯皮、或者想给项目补自动化测试的团队参考。1. 前后端分离之后后端质量为什么失守了1.1 接口契约前后端之间唯一能“咬死”的东西先聊一个被说烂但很容易被忽略的事实前后端分离之后前后端的协作边界只剩一份接口契约。以前 JSP 或者服务端模板渲染时代后端把数据塞进页面字段变了页面马上变形问题在开发环境就能暴露。现在前端是独立应用后端是接口服务两者之间唯一能对齐的就是 URL、请求参数、响应结构、状态码、错误信息。契约的本质是“双人约束”。前端按文档渲染后端按文档返回任何一方偷偷改掉字段名、改掉嵌套结构、改掉异常返回格式对方都要花时间排查。更麻烦的是很多后端接口在需求阶段根本说不清边界产品经理描述的是“正常流程”开发写的是“我觉得没问题”的路径等联调时才发现“哎这个字段怎么是 string前端要的是 number”“为什么报错时你返回 200前端只认 4xx”。单元测试在这里的价值是把接口契约“程序化”地固定下来。每一条测试用例都在说这个接口在这种输入下必须返回这个结构、这个状态码、这个错误信息。以后不管谁重构了 Service、换掉了数据库、迁移了框架只要测试没过契约就说明被破坏。这套东西比“口头约定”和“文档更新”可靠得多因为代码不会说谎也不会“忘了同步”。1.2 单元测试的边界管什么别管什么很多团队写不好单测很大一个原因是搞不清边界。有人把 Controller 到 Mapper 整条链路都算“单测”跑一次要起数据库、连缓存、调外部接口慢得要死最后沦为摆设也有人只测工具类业务核心一点没覆盖覆盖率数字难看价值也有限。我自己的划分很简单单元测试管的是业务规则、数据校验、状态流转、异常分支、依赖协作、时间计算、幂等判断。单元测试不管的是页面渲染、浏览器兼容、真实网络的第三方连通性、数据库主从延迟、负载性能。也就是说单测关注的是“我这段代码的逻辑逻辑在给定输入下是否正确”不需要验证“整个系统能不能跑起来”。验证系统联通是集成测试和联调的活不是单测的活。搞清楚这个边界你才知道在什么层级写用例也才知道写出来的用例为什么能快、能稳、能在 CI 里几十秒跑完。2. 测试用例从哪来用接口契约反推用例清单2.1 一条登录接口拆出十四种场景很多后端写单测有个通病只写 happy path。接口文档说要传用户名和密码那就写“密码正确登录成功”一条跑通绿了收工。这种测试对质量的保护几乎为零因为真正上线的故障几乎全发生在异常路径上。我习惯的做法是从接口契约反推把用户能输入什么、系统可能处于什么状态、依赖可能抛什么错全部列出来。拿最普通的登录接口举例子场景前置条件输入操作预期结果登录成功用户名存在、密码正确POST 用户名/密码返回 token状态码 200密码错误用户名存在、密码错误同上返回 401提示密码错误用户名不存在用户表无此记录同上返回 401为了安全提示不区分还是区分按业务定参数缺失无只传用户名不传密码返回 400字段校验错误参数类型错误无密码传成数字返回 400用户被锁定连续失败超过 5 次正确密码返回 423提示锁定账号已停用管理员停用正确账号密码返回 403验证码过期服务端缓存无此验证码提交旧验证码返回 400提示重新获取验证码错误缓存值与输入不符提交错误验证码返回 400首次登录需改密系统开启首次改密策略正确账号密码返回指定状态码 isFirstLogin 标记并发重复提交同一请求连续点两次连续 POST第二次被幂等拦截数据库超时DB 连接池耗尽登录请求返回 500走统一异常处理验证码发送频率60 秒内重复发送再次发送返回 429Token 签发失败JWT 密钥配置异常登录成功流程返回 500记录日志这张表里的每一行都可以直接转成一条测试用例。需求评审的时候哪怕产品经理没细说你拿着这张表去问“账号锁定策略是几次提示要不要区分用户不存在和密码错误验证码有效期多久”问清楚一个就能补一个用例比开发到一半再扯皮高效得多。2.2 边界条件才是需求评审的“隐藏题”边界条件是最容易漏、也最容易被产品经理当“彩蛋”留给后端的部分。我面试后端候选人时经常会拿分页接口来问页码传 0、传负数、传超过总页数的值分别应该怎么处理字段长度传 255、256金额传 0.001日期传 2 月 30 日空数组传 null你觉得怎么处理这些问题看着琐碎但每一个都是线上 bug 的来源也都是单元测试该固化的点。举几个我在项目里遇到过的真实例子字符串长度数据库字段是 varchar(50)接口入参没限制前端也没校验用户输入 51 个字MySQL 在严格模式下直接报错返回 500前端显示系统异常。单测要覆盖长度恰好 50 和 51 两种情况。金额精度金额字段用 double 还是 BigDecimal转换丢精度的问题在单测里一眼就能抓出来。凡是涉及金额、价格的地方一律用 BigDecimal 且测试里放 0.1 0.2 这类用例。空集合与 null接口返回数组时是返回 [] 还是 null很多前端对 null 不做遍历保护拿到 null 直接白屏。单测里明确返回空数组确保序列化后前端拿到的结构是稳定的。分页边界page1 是第一页page0 是自动纠正为第一页还是报参数错误pageSize 设置了最大值 100传入 101 是裁剪还是报错每一句话都应该变成测试断言。这些“隐藏题”你在需求评审时多问一句产品经理的答案就能直接转成单测。如果产品经理也不知道那就和后端同事一起定一个默认策略并写进测试至少行为是可预期、可复现的。2.3 外部依赖隔离mock 的边界不是越宽越好写单测必然要面对外部依赖数据库、Redis、短信服务、支付接口、第三方 OpenAPI。最容易犯的错有两个一是完全不 mock测试里直连开发库跑一次全凭网络心情二是见啥 mock 啥把自己要测的业务逻辑也 mock 掉最后测试变成“测自己写的假实现”。我自己常用的原则有三个凡是外部副作用必须隔离。发短信、调支付、连真实第三方这种用例在本地 CI 里是不能碰的不然测试环境一断网全红。用 mock 或测试替身把它们挡在外面。凡是自己项目里的业务逻辑尽量用真实实现。比如登录接口里的密码校验、用户状态检查这些是你自己要测的逻辑不该用 mock 代替。真把 UserService mock 成“永远返回成功”那你测的只是 Controller 的转发逻辑核心业务反而没覆盖。数据库这个依赖有条件就上内存库H2/SQLite配事务回滚没条件再用 mock Repository。内存库的好处是 SQL 语法、字段映射这些环节也能被真实跑一遍比纯 mock 可靠得多。我见过最失败的单元测试是这样写的Service 里所有方法都被 Mockito mock 出一个“固定返回值”测试通过看起来覆盖率很高实际上代码里真正的 if/else、异常处理全都没跑到。这种测试不是在保护质量是在自欺欺人。mock 要切在“依赖的边界”不能切在“逻辑的内部”。3. 框架层面的落地方案Spring Boot 和 FastAPI 双视角3.1 Spring Boot 3.xJUnit 5 和 MockMvc 怎么配合Java 后端现在主流的组合是 Spring Boot 3.x JUnit 5 Mockito AssertJ配套的起步依赖只要引一个spring-boot-starter-testJUnit、Mockito、AssertJ、JsonPath 这些常用组件都带上了非常省事。对于 Controller 层我推荐用WebMvcTest它只加载 Web 层相关的 BeanService 用MockBean替换这样测试只关心“给定某种 Service 返回结果接口输出了什么”速度很快。示例WebMvcTest(UserController.class) class UserControllerTest { Autowired private MockMvc mockMvc; MockBean private UserService userService; Test void getUserById_shouldReturnUserInfo() throws Exception { UserDTO dto new UserDTO(1L, 张三, zhangsanexample.com); when(userService.getUserById(1L)).thenReturn(dto); mockMvc.perform(get(/api/users/1)) .andExpect(status().isOk()) .andExpect(jsonPath($.name).value(张三)) .andExpect(jsonPath($.email).value(zhangsanexample.com)); } Test void getUserById_notFound_shouldReturn404() throws Exception { when(userService.getUserById(999L)).thenThrow(new NotFoundException(用户不存在)); mockMvc.perform(get(/api/users/999)) .andExpect(status().isNotFound()) .andExpect(jsonPath($.message).value(用户不存在)); } }对于 Service 层涉及数据库的用例我通常用SpringBootTest搭配Transactional让测试方法结束后自动回滚不给开发库留脏数据。这个组合虽然比重一点的纯 mock 重一些但能把 Mapper 映射、SQL 语法、事务行为全部真实跑一遍性价比很高SpringBootTest Transactional class UserServiceImplTest { Autowired private UserRepository userRepository; Autowired private UserService userService; Test void createUser_duplicateEmail_shouldThrow() { userRepository.save(new UserEntity(ab.com, 旧用户)); assertThatThrownBy(() - userService.createUser(new CreateUserRequest(ab.com, 123456))) .isInstanceOf(BusinessException.class) .hasMessageContaining(邮箱已注册); } }这里有个细节Transactional只对数据库操作生效。如果测试里真的调了 Redis、发了消息队列这些副作用不会被事务回滚需要自己手动清理或者在测试后进行补偿。别以为用了Transactional就万事大吉文件、缓存、消息都是体外操作。3.2 像若依这类脚手架项目单测从哪里下手国内做前后端分离很多人用的是若依RuoYi这类脚手架代码生成器一跑Controller、Service、Mapper 全套 CRUD 就出来了。这种项目有个典型问题生成的接口大部分是“薄逻辑”真正的业务判断都堆在几个手写 Service 里。全给它们补单测工作量太大、收益也不高。我建议按“价值排序”来挑第一优先级手写业务逻辑多的 Service比如订单状态流转、支付回调处理、权限判断。这些是凭人力难以保证不出错的地方。第二优先级关键 Controller 的请求参数校验和错误码返回比如涉及金额、批量操作、导入导出的接口。第三优先级代码生成器生成的简单 CRUD挑几个代表性的测通即可不需要全覆盖。先别一上来追求覆盖率数字。把第一优先级的接口测透了质量防线就有了支点。等 CI 阶段自动跑起来了再慢慢往第二、第三优先级补。对于若依这类项目还有个特殊点很多接口依赖登录态和权限注解PreAuthorize单测里需要处理 SecurityContext常见的做法是给测试类加一个测试专用的认证 Mock或者直接 mock 掉SecurityUtils.getLoginUser()的获取逻辑。我习惯用 Spring Security Test 的WithMockUser能应付大多数场景。3.3 FastAPIpytest 加 TestClient 的轻量方案Python 后端这几年 FastAPI 越来越常见它的单元测试链路也很成熟pytest TestClient dependency_overrides。TestClient 基于 httpx可以在不启动真实服务的情况下发请求非常适合接口层测试。FastAPI 有个特别方便的设计依赖注入可以覆盖。比如你的接口依赖数据库 Session测试时可以覆盖成内存库或临时数据库# app/main.py from fastapi import FastAPI from app.routers import users app FastAPI() app.include_router(users.router)# tests/test_user_api.py from fastapi.testclient import TestClient from app.main import app from app.dependencies import get_db def override_get_db(): # 这里可以返回测试专用的数据库 session yield test_db_session app.dependency_overrides[get_db] override_get_db client TestClient(app) def test_create_user_duplicate_email(monkeypatch): # 用 monkeypatch 挡住底层查询模拟邮箱已存在 monkeypatch.setattr(app.services.user_service.find_by_email, lambda email: {email: email}) resp client.post(/api/users, json{email: ab.com, password: 123456}) assert resp.status_code 400 assert resp.json()[detail] 邮箱已注册这套方案的好处是测试写起来很直观读起来也像在描述“用户做了什么、系统返回了什么”。FastAPI 项目的测试坑主要在异步如果代码里用了 async 依赖要确保测试里覆盖的依赖函数是同步可用的或者用 pytest-asyncio 管理事件循环。另外注意 TestClient 启动时可能会带着全局中间件、异常处理这些会真实生效属于正常的测试范围。4. 前后端联调最常吵的三个问题交给测试去裁决4.1 跨域不是“配好了就行”前后端分离必然要处理跨域后端配个 CORS Filter前端代理一开本地联调看着一切正常上线后换个域名又出事。这类问题在单测里容易被忽略因为很多人觉得“CORS 是运行期配置不是业务逻辑”。我自己的看法是CORS 配置同样需要测试至少要把“预检请求”这件事固化下来。浏览器在跨域复杂请求前会先发一个 OPTIONS 请求后端如果没正确响应前端连真实请求都发不出去。用 MockMvc 可以很直观地验证Test void corsPreflight_shouldReturnAllowHeaders() throws Exception { mockMvc.perform(options(/api/users) .header(Origin, http://localhost:5173) .header(Access-Control-Request-Method, POST) .header(Access-Control-Request-Headers, content-type)) .andExpect(status().isOk()) .andExpect(header().string(Access-Control-Allow-Origin, http://localhost:5173)) .andExpect(header().string(Access-Control-Allow-Methods, containsString(POST))); }这种用例摆在那里以后调整网关、改域名、加安全策略的时候跑一遍就知道跨域配置有没有被意外弄坏。另外要提醒一句前端开发时如果遇到 vue 工程项目里单测报错很多情况下不是前端用例写错而是后端返回的字段结构变了。让后端先把接口契约测试跑绿再回头排查前端效率会高很多。4.2 按钮重复提交后端幂等必须有自己的防线前端对“按钮重复提交”通常会做防抖、置灰、loading 状态但这套防线是“尽力而为”用户刷新页面重发、网络超时重试、脚本模拟请求前端根本拦不住。真正的兜底必须放在后端。后端常见的幂等方案有这么几种唯一索引/唯一约束订单号、业务流水号在数据库层面唯一重复插入直接报错。幂等表请求进来先查幂等表存在则直接返回上次结果。Redis 分布式锁以业务 ID 为 key抢不到锁的请求直接提示重复提交。Token 机制接口要求携带一次性 token服务端校验并消费第二次使用直接拒绝。这些逻辑都属于“必须测试”的范畴。拿支付订单举例Test void submitSameOrderNo_secondTime_shouldReject() { // 第一单已经处理成功 when(paymentRepository.findByOrderNo(20241111001)) .thenReturn(new PaymentEntity(20241111001, SUCCESS)); assertThatThrownBy(() - paymentService.createPayment(20241111001)) .isInstanceOf(DuplicateSubmitException.class) .hasMessageContaining(重复提交); }前端做的“按钮置灰”可以视为体验优化后端的幂等校验才是业务正确性保障。这两层都要做但前后端不要互相甩锅前端拦不住的是网络重试后端防不住的是校验逻辑漏洞单元测试能盯住后者。还有一点值得注意幂等测试不要只测“重复被拦截”还要测“失败后重试能成功”也就是第一次事务回滚后第二次相同请求不能因为临时状态残留而被误拦。这个场景测出来能帮你发现很多隐藏的状态 bug。4.3 状态码与字段命名把约定写死成用例前后端联调争吵最多的问题之一就是错误返回格式。后端觉得“返回 200 code 字段”很合理前端非要用 HTTP 状态码判断错误两边吵半天最后靠项目经理拍板。实际上这类约定最容易用测试固化不管你们团队定的是哪种风格写进测试里就完事了。我见过比较稳的团队约定是HTTP 状态码表达结果类别成功 2xx参数错误 400未认证 401无权限 403资源不存在 404状态冲突 409频率超限 429服务异常 500。响应体统一结构{code: xxx, message: xxx, data: xxx}错误时data为 null。字段命名全局统一要么全 camelCase要么全 snake_case禁止接口里混着两种风格。这些约定写成测试后效果是“任何一只手改坏了约定CI 都会红”。比如Test void allBusinessErrors_shouldUseUnifiedBody() throws Exception { mockMvc.perform(get(/api/nonexistent)) .andExpect(status().isNotFound()) .andExpect(jsonPath($.code).value(40400)) .andExpect(jsonPath($.message).exists()) .andExpect(jsonPath($.data).doesNotExist()); }关于字段命名我以前遇到过一个真实的线上事故某后端把“用户头像”字段从avatar悄悄改成了avatarUrl前后端各自测试都过了就是联调环境没人盯上线后所有用户头像不显示。这类问题没有捷径契约测试就是最便宜的答案。每一次接口字段调整都应该伴随契约测试的同步更新。5. 工程化路上的常见坑与排查手册5.1 一张速查表先拿去用实操过程中踩过的坑太多了我整理了一张高频问题的速查表团队里新同学上手写单测时可以先对照着看现象常见原因排查与解法测试跑完数据库有残留数据没有用事务回滚或用了外部存储Transactional只回滚数据库Redis/MQ 需手动清理用例 A 影响用例 B静态变量、单例缓存、Mock 未重置用BeforeEach重置 mock避免共享可变静态字段测试本地绿CI 红依赖了本地绝对路径、外部 IP、开发库把资源改成 classpath 相对路径用测试替身替代外部依赖中文乱码MockMvc 未设置 UTF-8或数据库字符集问题测试里显式声明characterEncoding响应断言前先确认编码日期断言不稳测试里用了new Date()时区不一致用例输入改为固定日期明确时区如ZonedDateTime.of(...)分页测试忽好忽坏测试数据量不确定顺序不稳定排序字段加 id 作为 tie breaker数据量尽量固定覆盖率很高但 bug 还很多很多断言太弱或者 mock 掉了核心逻辑检查是不是只测了“调用没报错”关键分支要有明确断言测试速度越来越慢每个测试都起完整 Spring 容器能WebMvcTest的就别SpringBootTest能用 mock 的就别起库5.2 用例之间的“脏数据”问题怎么解决测试用例的数据隔离是单测工程化最容易翻车的一环。很多人刚开始写单测跑单个用例是绿的一跑全量就挂十有八九是测试之间相互污染。我自己的经验有三条第一数据库测试能回滚就回滚。Spring 下用TransactionalFastAPI 下用事务包裹测试或者在 fixture 里做 teardown 清表。这一条能挡掉 80% 的污染问题。第二造数不是越多越好而是越精确越好。用一个独立的测试数据构造器Builder/Factory生成最小且边界明确的数据重复用的时候不要复制粘贴一旦多处散落着不同版本的初始数据测试之间的差异就全靠脑补。第三静态变量和单例是污染重灾区。缓存、配置类、Mockito 的静态 mock 都要保证在每个用例前重置。我在 Python 项目里踩过类似的坑一个模块级的全局字典被某个用例写入后另一个用例读到的不是初始值花了一下午才定位到。后来所有全局可变对象都改成 fixture 初始化问题才根除。5.3 慢、脆、没用团队放弃单测的三个信号团队写着写着不愿意写单测背后一定有具体原因总结起来无外乎“慢”“脆”“没用”。“慢”好理解一个测试跑几秒钟甚至几十秒反馈周期拉太长人的注意力就被耗光了。“脆”更致命测试结果波动大没改代码也红跑几次又绿大家很快就不信这个红绿灯了。“没用”则是最致命的信号测试全绿但该出的 bug 还是出领导觉得单测是形式主义开发觉得写它是浪费时间。对应的解法也很直接慢就切测试粒度。Controller 层能用WebMvcTest就别用全量SpringBootTestService 层能用内存库就别连开发库把全量测试的执行时间控制在 2 分钟以内大家才有动力频繁跑。脆就查不稳定源。凡是依赖时间、随机数、全局状态、外部网络的用例一律改成固定输入或 mock。测试应该像数学题一样有唯一答案而不是像彩票一样看运气。没用就回到断言质量。给团队定一条底线每个测试至少要有一个和业务语义强相关的断言。没有断言的“调用不报错”型测试宁可不写。如果团队里这三种信号都出现了不要先加覆盖率的 KPI先把上面这些根因按顺序解决掉。单测这件事要让写的人自己觉得“有保护感”才做得长久。6. 从“跑得通”到“敢合入”CI 质量门禁与多项目合并实战6.1 在 CI 里给单测设一道闸而不是摆设本地跑得通只是第一步真正让单元测试发挥价值的是把它接进 CI作为合并请求的一道闸。GitLab CI 的配置很简单关键是要让“测试不通过就不能合入”成为团队纪律。Maven 工程可以配 JaCoCo 阈值低于阈值直接构建失败。先在pom.xml里加上插件plugin groupIdorg.jacoco/groupId artifactIdjacoco-maven-plugin/artifactId version0.8.11/version executions execution goals goalprepare-agent/goal /goals /execution execution idreport/id phaseverify/phase goals goalreport/goal /goals /execution execution idcheck/id goals goalcheck/goal /goals configuration rules rule elementBUNDLE/element limits limit counterLINE/counter valueCOVEREDRATIO/value minimum0.80/minimum /limit /limits /rule /rules /configuration /execution /executions /plugin然后在 CI 脚本里backend-test: stage: test script: - mvn clean verify artifacts: paths: - target/site/jacoco/Python FastAPI 项目可以简单一点pytest 自带断言失败退出码再加一个覆盖率限制python-backend-test: stage: test script: - pytest --covapp --cov-fail-under80覆盖率阈值定多少合理我的建议是从 60% 起步核心业务模块至少 80%。注意这个数字不要一刀切代码生成器生成的那些简单 CRUD 和核心状态机不应该用同一个标准。更重要的是CI 门禁的目的是拦住“明显没写测试”的合并而不是逼大家堆没断言的垃圾用例。我见过团队把覆盖率冲到 95%bug 照样多原因就是大量测试没有业务断言纯凑行数。6.2 多个 Java 后端项目合并时旧测试就是安全网最后想聊聊一个我最近亲身经历的场景公司手里有几个老 Java 后端项目技术栈差不多、接口有重叠、业务有重复领导决定合并成一个服务。这种项目一合并最慌的是什么不是代码冲突是“我删掉这段逻辑会不会把另一个项目的某个隐藏行为干掉”。多个项目合并本质上是大规模重构接口路径可能要统一包名要重写公共模块要抽出来重复实现要删掉。这时候如果每个项目都有一套单元测试合并的安全性会完全不一样合并前先把三个项目的测试全量跑到绿这个是改造前的地基。合并过程中保留所有旧测试不因为它们“旧”而删掉只是调整包名和 import。合并接口时如果新旧接口的响应结构不一致测试会直接指出差异点来回比对的时间能省一大半。合并完成后旧测试全部通过才说明“行为没有发生意外变化”剩下的差异才敢说是“刻意为之的功能统一”。我印象很深的一次合并里某个老项目登录接口返回的用户名字段是userName另一个是name合并前谁都没发现是合并后跑契约测试时挂掉两个项目的测试都断言自己那边的字段差异一下就暴露出来了。如果没有测试这种问题几乎只能靠人肉 review而人肉 review 恰恰是最容易漏这种细节的。所以每当我听到“重构/合并/换框架”这些词第一反应永远是先看这项目有没有测得过关的测试有就敢动手没有先补测试。单元测试这条路本身没什么高深理论难的是坚持落地。做了几年后端下来我的体会是它不是给领导看的进度也不是刷出来的覆盖率数字而是给未来改代码的自己留的一条安全绳。把接口契约写进测试把边界条件写进测试把异常分支写进测试以后不管再怎么改、怎么合、怎么换人接手都少一些心惊肉跳。如果你的团队正在为联调扯皮发愁我建议别急着上更多框架先挑一个核心接口把用例写透接进 CI坚持两周效果可能比开十次“大家要重视质量”的会都来得实在。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进