site logo

Marico's space

在 GitHub Actions 中使用 MySQL 运行 Laravel Pest 测试

算法解析 2026-07-22 11:29:23 7

最近折腾 Laravel Pest 在 CI 环境里的配置,踩了几个莫名其妙的大坑。这篇把问题说清楚,给后来者省点时间。

官方文档告诉你怎么加 MySQL 服务容器,但它不会告诉你配置完了测试还是会挂的三种隐藏原因。我全碰上了,这篇一并说。

为什么不用 SQLite?

SQLite 是 Laravel 测试的默认选项,因为它快、免配置。对于不碰数据库的单元测试,够用了。但对于功能测试,它埋了一个坑。

SQLite 的 SQL 语法和 MySQL 不一样,具体来说:

  • 字符串拼接:SQLite 用 ||,MySQL 用 CONCAT()
  • 日期格式化:SQLite 用 strftime(),MySQL 用 DATE_FORMAT()
  • 列歧义规则 SQLite 更宽松
  • 部分 JSON 函数有差异

如果你的 Eloquent 查询里用了原生 SQL(selectRawwhereRaworderByRaw,报表查询这些),SQLite 会放行,MySQL 会拒绝。这问题不到生产环境根本发现不了。

老老实实用 MySQL 跑测试。多出来的配置就 20 行 YAML,不难。

第一步:添加 MySQL 服务

api-ci.yml 的 test job 里加一个 services: 块:

 tests: name: Tests (PHP 8.4, MySQL 8.4) runs-on: ubuntu-latest services: mysql: image: mysql:8.4 env: MYSQL_DATABASE: your_app_test MYSQL_ROOT_PASSWORD: secret ports: - 3306:3306 options: >- --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3

options: 这段是关键,它让 GitHub Actions 等 MySQL 真正启动完毕才执行后续步骤。没有这段,你的 migrate 步骤会在 MySQL 还没就绪时就去连接,然后抛出一个让人摸不着头脑的错误就挂掉了。

第二步:覆盖测试环境配置

GitHub Actions 不认你本地的 .env.env.testing,环境变量得显式声明。最干净的做法是复制 .env.example.env.testing,再追加覆盖:

 - name: Copy .env run: cp .env.example .env.testing - name: Set test environment variables run: | echo "APP_ENV=testing" >> .env.testing echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env.testing echo "DB_CONNECTION=mysql" >> .env.testing echo "DB_HOST=127.0.0.1" >> .env.testing echo "DB_PORT=3306" >> .env.testing echo "DB_DATABASE=your_app_test" >> .env.testing echo "DB_USERNAME=root" >> .env.testing echo "DB_PASSWORD=secret" >> .env.testing echo "QUEUE_CONNECTION=sync" >> .env.testing echo "BROADCAST_CONNECTION=log" >> .env.testing echo "CACHE_STORE=array" >> .env.testing

为什么要设 BROADCAST_CONNECTION=log 这个坑了我很久。如果你的 .env.example 里配了 BROADCAST_CONNECTION=reverb(Laravel 11 之后的默认配置),任何触发广播事件的测试——支付完成、状态变更、通知发送——都会试图建立到 0.0.0.0:8080 的 TCP 连接。CI 环境里哪有什么 Reverb 服务器,每个这样的请求都返回 500。测试就莫名其妙地失败了,完全看不出原因。设成 log 把广播事件写到日志文件,问题就解决了。

为什么要设 QUEUE_CONNECTION=sync 队列任务会同步立即执行,而不是推送到 Redis。没有这个配置,任何在控制器里派发任务的逻辑在测试时都会被静默跳过,不会真的执行。

第三步:执行迁移和测试

 - name: Run migrations run: php artisan migrate --env=testing --force env: DB_CONNECTION: mysql DB_HOST: 127.0.0.1 DB_PORT: "3306" DB_DATABASE: your_app_test DB_USERNAME: root DB_PASSWORD: secret - name: Run Pest tests run: ./vendor/bin/pest env: DB_CONNECTION: mysql DB_HOST: 127.0.0.1 DB_PORT: "3306" DB_DATABASE: your_app_test DB_USERNAME: root DB_PASSWORD: secret

数据库环境变量传了两遍——一次给 migrate,一次给测试。GitHub Actions 里每个步骤都在独立的 shell 环境执行,显式声明能避免很多奇怪的问题。

空目录陷阱

Git 不跟踪空目录。如果你的测试依赖 storage/framework/views/storage/framework/sessions/bootstrap/cache/ 存在,CI 拉取代码时这些目录就是空的。

Laravel 的 package:discover(在 composer install 后运行)会启动框架,这需要这些目录存在。如果不存在,整个初始化直接崩掉,错误信息是"Please provide a valid cache path",看了完全不知道跟目录结构有关。

修复方法:在每个需要的目录里放一个 .gitignore 占位文件:

for dir in \ storage/framework/views \ storage/framework/sessions \ storage/framework/cache/data \ storage/logs \ bootstrap/cache; do mkdir -p api/$dir printf "*\n!.gitignore" > api/$dir/.gitignore
done
git add api/storage api/bootstrap/cache
git commit -m "chore: add storage skeleton for CI"

空测试目录陷阱

Pest 在配置的测试目录不存在时返回退出码 2(不是 1)。如果你的 phpunit.xml 列了 tests/Unit 但这个目录是空的、从来没提交过,CI 会在跑任何测试之前就失败。

解决办法不是加 .gitkeep,而是在 phpunit.xml 配置的每个目录里都放至少一个有实际意义的测试。CI 在跑测试之前就崩掉什么信息都没有;CI 在真实断言上失败才能告诉你哪里出了问题。

完整测试任务配置参考

 tests: name: Tests (PHP 8.4, MySQL 8.4) runs-on: ubuntu-latest needs: quality services: mysql: image: mysql:8.4 env: MYSQL_DATABASE: your_app_test MYSQL_ROOT_PASSWORD: secret ports: - 3306:3306 options: >- --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - uses: actions/checkout@v4 - name: Setup PHP 8.4 uses: shivammathur/setup-php@v2 with: php-version: '8.4' extensions: mbstring, pdo, pdo_mysql, bcmath, gd, zip, intl coverage: none tools: composer:v2 - name: Cache Composer dependencies uses: actions/cache@v4 with: path: api/vendor key: php-8.4-composer-${{ hashFiles('api/composer.lock') }} restore-keys: php-8.4-composer- - name: Install dependencies run: composer install --no-interaction --prefer-dist --no-progress - name: Copy .env run: cp .env.example .env.testing - name: Set test environment variables run: | echo "APP_ENV=testing" >> .env.testing echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env.testing echo "DB_CONNECTION=mysql" >> .env.testing echo "DB_HOST=127.0.0.1" >> .env.testing echo "DB_PORT=3306" >> .env.testing echo "DB_DATABASE=your_app_test" >> .env.testing echo "DB_USERNAME=root" >> .env.testing echo "DB_PASSWORD=secret" >> .env.testing echo "QUEUE_CONNECTION=sync" >> .env.testing echo "BROADCAST_CONNECTION=log" >> .env.testing echo "CACHE_STORE=array" >> .env.testing - name: Run migrations run: php artisan migrate --env=testing --force env: DB_CONNECTION: mysql DB_HOST: 127.0.0.1 DB_PORT: "3306" DB_DATABASE: your_app_test DB_USERNAME: root DB_PASSWORD: secret - name: Run Pest tests run: ./vendor/bin/pest env: DB_CONNECTION: mysql DB_HOST: 127.0.0.1 DB_PORT: "3306" DB_DATABASE: your_app_test DB_USERNAME: root DB_PASSWORD: secret

下篇讲怎么在测试之前加代码质量门禁,卡住风格漂移或者静态分析有问题的 PR。