单元测试验证单个方法逻辑(如isEligibleForDiscount()),不依赖内核、数据库或网络;功能测试走完整HTTP生命周期(如访问/api/posts),启动内核、路由和中间件,验证端到端响应。

单元测试和功能测试不是“选一个用”,而是解决不同问题的两种工具:单元测试验证单个类方法的逻辑是否正确,功能测试验证请求发出去后整个链路是否返回预期响应。混用或错位会导致测试变慢、难维护、还容易漏 bug。
单元测试只测方法输入输出,不启动内核
当你想确认 User::isEligibleForDiscount() 在年龄 ≥ 65 时返回 true,就该写单元测试。它不碰数据库、不走路由、不依赖 Kernel 或 Container。
- 继承
PHPUnit\Framework\TestCase,不是KernelTestCase或WebTestCase - 手动 new 被测对象,用
$this->createMock()替换所有依赖(比如MailerInterface、LoggerInterface) - 禁止调用
static::getContainer()、self::bootKernel()、$client->request() - 测试文件路径严格对应源码:源码在
src/Service/PriceCalculator.php,测试就放tests/Service/PriceCalculatorTest.php - 方法名必须是
public function testCalculatesTotalWithVat(): void这种格式,不能用@test注解替代
功能测试走真实 HTTP 生命周期,必须用 WebTestCase
你想知道访问 /api/posts 是否返回 200 和正确的 JSON 数据?那就得走功能测试。它会启动轻量内核、加载路由、触发中间件、执行控制器——和浏览器发请求几乎一样。
- 继承
Symfony\Bundle\FrameworkBundle\Test\WebTestCase - 用
$client = static::createClient()获取客户端,别自己 new 控制器或 Request - 路径必须是绝对路径:
$client->request('GET', '/api/posts'),写成'api/posts'会报InvalidArgumentException: Unable to parse URL - 带查询参数传数组:
$client->request('GET', '/search', ['q' => 'php']);发 JSON 要设头:['CONTENT_TYPE' => 'application/json'],数据作为第 6 个参数传入 - 先断言状态码:
$this->assertSame(200, $client->getResponse()->getStatusCode()),再取内容:json_decode($client->getResponse()->getContent(), true)
别在控制器里写业务逻辑,否则两类测试都难写
如果控制器方法里有 200 行实体操作、文件生成、第三方 API 调用,那它已经不是控制器,是业务服务。这时强行单元测试控制器只会不断 mock $this->getDoctrine()、$this->getUser(),功能测试又因逻辑耦合太重而难以定位失败点。
- 重构方向很明确:把业务逻辑抽成独立服务类(如
InvoiceGenerator),控制器只负责接收请求、调用服务、返回响应 - 对服务类写纯单元测试(无内核、无 HTTP、秒级运行)
- 对控制器只做薄层功能测试:验证路由是否匹配、权限是否生效、返回格式是否正确
- 若控制器被设为私有(默认行为),
static::getContainer()->get('App\Controller\ApiController')会失败,除非你在services_test.yaml里显式暴露它
最常被忽略的一点:测试环境配置差异。比如防火墙在 test 环境下默认启用,导致未登录请求被重定向到登录页,getStatusCode() 返回 302 而不是预期的 401 —— 此时不是代码错了,是测试没配对环境。











