最近和朋友們聊到用 AI 寫 WordPress 外掛的經驗,大家最大的痛點都是:AI 寫的程式碼無法百分百信任,還是得手動測試...
本文將介紹如何建立完整的自動化測試環境,讓 AI 寫的程式碼能夠被快速驗證。
- ❌ 沒測試:只能人工檢查,容易漏掉邊界情況
- ✅ 有測試:跑一次測試就知道有沒有壞掉
- ❌ 沒測試:不知道新功能會不會影響舊功能
- ✅ 有測試:自動回歸測試,確保舊功能正常
- ❌ 沒測試:只能在一個版本測試
- ✅ 有測試:CI/CD 自動測試多個 PHP 版本
# 需要安裝 MySQL/MariaDB
brew install mysql
mysql -u root -p
# 建立測試資料庫
CREATE DATABASE wordpress_test;
# 設定連線資訊
# 每次切換專案都要設定一次# 不需要安裝 MySQL!
composer install
composer test
# 自動使用 SQLite 檔案資料庫
# 測試資料自動清理
# 切換專案零配置- ⚡ 速度快:檔案資料庫,不需要啟動服務
- 🎯 隔離性好:每個專案獨立的 .sqlite 檔案
- 🧹 自動清理:測試完畢自動重置
- 🚀 CI 友善:GitHub Actions 不需要設定 MySQL service
- 💻 本地開發友善:不需要額外安裝資料庫服務
composer require --dev phpunit/phpunit wp-phpunit/wp-phpunit yoast/phpunit-polyfills建立 bin/install-wp-tests.sh(完整內容見 Gist 中的 install-wp-tests.sh)
phpunit.xml.dist:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<php>
<!-- wp-phpunit configuration -->
<env name="WP_PHPUNIT__TESTS_CONFIG" value="tests/wp-config.php"/>
<!-- SQLite configuration (本地開發使用) -->
<env name="DB_ENGINE" value="sqlite"/>
<env name="DB_FILE" value="test.sqlite"/>
<env name="DB_DIR" value="/tmp/wp-phpunit-tests/"/>
<!-- 可選:手動指定插件主檔案 -->
<!-- <env name="PLUGIN_MAIN_FILE" value="your-plugin.php"/> -->
</php>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
</phpunit>tests/wp-config.php(完整內容見 Gist 中的 example-wp-config.php)
tests/bootstrap.php(完整內容見 Gist 中的 solution-bootstrap.php)
這個版本會自動偵測插件主檔案,支援:
- 環境變數指定
- Plugin Header 掃描
- 目錄名稱慣例
大多數情況下不需要任何修改!
# 安裝 WordPress 測試環境(使用 SQLite)
./bin/install-wp-tests.sh sqlite
# 執行測試
composer testyour-plugin/
├── src/ # 插件程式碼
├── tests/
│ ├── Unit/ # 單元測試(不依賴 WordPress)
│ │ ├── Http/
│ │ ├── Database/
│ │ └── Utils/
│ ├── Integration/ # 整合測試(完整 WordPress 環境)
│ │ ├── Admin/
│ │ ├── API/
│ │ └── Features/
│ ├── bootstrap.php # PHPUnit bootstrap
│ └── wp-config.php # WordPress 測試設定
├── bin/
│ └── install-wp-tests.sh # 環境安裝腳本
├── phpunit.xml.dist # PHPUnit 設定
└── composer.json
Unit Tests(單元測試)
- 測試獨立的類別或方法
- 不依賴 WordPress
- 執行速度快
- Mock 所有外部依賴
<?php
namespace YourPlugin\Tests\Unit;
use PHPUnit\Framework\TestCase;
use YourPlugin\Utils\StringHelper;
class StringHelperTest extends TestCase
{
public function test_slugify()
{
$helper = new StringHelper();
$result = $helper->slugify('Hello World!');
$this->assertEquals('hello-world', $result);
}
}Integration Tests(整合測試)
- 測試與 WordPress 的整合
- 使用真實 WordPress 環境
- 測試 hooks、filters、database 操作
- 繼承
WP_UnitTestCase
<?php
namespace YourPlugin\Tests\Integration;
use WP_UnitTestCase;
class PostHandlerTest extends WP_UnitTestCase
{
public function test_create_post()
{
// 使用 WordPress factory
$post_id = $this->factory->post->create([
'post_title' => 'Test Post',
'post_status' => 'publish',
]);
$this->assertIsInt($post_id);
$this->assertGreaterThan(0, $post_id);
// 使用 WordPress API
$post = get_post($post_id);
$this->assertEquals('Test Post', $post->post_title);
}
public function test_wordpress_filter()
{
// 註冊 filter
add_filter('the_title', function($title) {
return $title . ' [Modified]';
});
$post_id = $this->factory->post->create([
'post_title' => 'Original',
]);
$this->go_to(get_permalink($post_id));
// 驗證 filter 有作用
$this->assertStringContainsString('[Modified]', get_the_title());
}
}// 建立文章
$post_id = $this->factory->post->create([
'post_title' => 'Test Post',
'post_content' => 'Test content',
'post_status' => 'publish',
]);
// 建立使用者
$user_id = $this->factory->user->create([
'user_login' => 'testuser',
'role' => 'administrator',
]);
// 建立分類
$term_id = $this->factory->term->create([
'name' => 'Test Category',
'taxonomy' => 'category',
]);
// 建立留言
$comment_id = $this->factory->comment->create([
'comment_post_ID' => $post_id,
'comment_content' => 'Test comment',
]);// 訪問首頁
$this->go_to('/');
// 訪問文章
$this->go_to(get_permalink($post_id));
// 訪問自訂路徑
$this->go_to('/api/users');
// 檢查 WordPress 條件標籤
$this->assertTrue(is_home());
$this->assertTrue(is_single());
$this->assertFalse(is_404());use Http\Mock\Client as MockClient;
use Nyholm\Psr7\Response;
class APITest extends WP_UnitTestCase
{
private $mockClient;
protected function setUp(): void
{
parent::setUp();
$this->mockClient = new MockClient();
// 透過 filter 注入 mock client
add_filter('your_plugin_http_client', function () {
return $this->mockClient;
});
}
public function test_api_request()
{
// 設定 mock 回應
$this->mockClient->addResponse(
new Response(200, ['Content-Type' => 'application/json'],
'{"status":"ok"}'
)
);
// 執行功能(會使用 mock client)
$result = your_plugin_fetch_api();
// 驗證發出的請求
$lastRequest = $this->mockClient->getLastRequest();
$this->assertEquals('GET', $lastRequest->getMethod());
$this->assertEquals('https://api.example.com/data',
(string) $lastRequest->getUri()
);
// 驗證結果
$this->assertEquals('ok', $result['status']);
}
}class DatabaseTest extends WP_UnitTestCase
{
private $table_name;
protected function setUp(): void
{
parent::setUp();
global $wpdb;
$this->table_name = $wpdb->prefix . 'your_table';
// 建立測試用資料表
$wpdb->query("CREATE TABLE {$this->table_name} (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255),
email VARCHAR(255)
)");
}
protected function tearDown(): void
{
global $wpdb;
// 清理測試資料表
$wpdb->query("DROP TABLE IF EXISTS {$this->table_name}");
parent::tearDown();
}
public function test_insert_data()
{
global $wpdb;
$result = $wpdb->insert($this->table_name, [
'name' => 'John Doe',
'email' => 'john@example.com',
]);
$this->assertEquals(1, $result);
$this->assertGreaterThan(0, $wpdb->insert_id);
}
public function test_query_data()
{
global $wpdb;
// 插入測試資料
$wpdb->insert($this->table_name, [
'name' => 'Jane Doe',
'email' => 'jane@example.com',
]);
// 查詢資料
$row = $wpdb->get_row(
$wpdb->prepare("SELECT * FROM {$this->table_name} WHERE email = %s",
'jane@example.com'
)
);
$this->assertNotNull($row);
$this->assertEquals('Jane Doe', $row->name);
}
}建立 .github/workflows/tests.yml:
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['7.4', '8.0', '8.1', '8.2', '8.3']
db: ['sqlite']
include:
- php: '8.3'
db: 'mysql'
services:
mysql:
image: ${{ matrix.db == 'mysql' && 'mysql:8.0' || '' }}
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: wordpress_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
name: PHP ${{ matrix.php }} (${{ matrix.db }})
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, intl, pdo_sqlite, pdo_mysql
coverage: pcov
- name: Cache composer dependencies
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Setup WordPress test environment
run: ./bin/install-wp-tests.sh ${{ matrix.db }}
- name: Install dependencies
run: composer update --prefer-dist --no-progress
- name: Run tests
env:
DB_ENGINE: ${{ matrix.db }}
DB_PASSWORD: ${{ matrix.db == 'mysql' && 'root' || '' }}
WP_CORE_DIR: ${{ github.workspace }}/.wordpress-test/wordpress
run: ./vendor/bin/phpunit --coverage-clover=coverage.xml
- name: Upload coverage to Codecov
if: matrix.php == '8.3' && matrix.db == 'sqlite'
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xmlmatrix:
php: ['7.4', '8.0', '8.1', '8.2', '8.3'] # 5 個 PHP 版本
db: ['sqlite'] # 預設用 SQLite
include:
- php: '8.3'
db: 'mysql' # 額外測試 MySQL為什麼這樣設計?
- 多個任務用 SQLite:速度快(~2 分鐘)
- 1 個任務用 MySQL:驗證生產環境
- 平行執行:總時間 = 最慢的那個任務
執行結果範例:
✅ PHP 7.4 (sqlite) - Tests passed in 2m 15s
✅ PHP 8.0 (sqlite) - Tests passed in 2m 18s
✅ PHP 8.1 (sqlite) - Tests passed in 2m 16s
✅ PHP 8.2 (sqlite) - Tests passed in 2m 19s
✅ PHP 8.3 (sqlite) - Tests passed in 2m 20s (+ Coverage)
✅ PHP 8.3 (mysql) - Tests passed in 3m 45s
總時間:~4 分鐘(平行執行)
建立 .github/workflows/release.yml:
name: Release
on:
release:
types: [created]
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, intl
- name: Install dependencies
run: composer install --no-dev --optimize-autoloader
- name: Download and run Strauss
run: |
mkdir -p bin
curl -o bin/strauss.phar -L https://github.com/BrianHenryIE/strauss/releases/latest/download/strauss.phar
php bin/strauss.phar
- name: Create plugin directory
run: |
PLUGIN_SLUG=$(basename ${{ github.workspace }})
mkdir -p build/${PLUGIN_SLUG}
rsync -av --progress . build/${PLUGIN_SLUG} \
--exclude='.git' \
--exclude='.github' \
--exclude='tests' \
--exclude='vendor' \
--exclude='composer.json' \
--exclude='composer.lock' \
--exclude='phpunit.xml*' \
--exclude='bin'
cp LICENSE build/${PLUGIN_SLUG}/ 2>/dev/null || true
- name: Create ZIP archive
run: |
cd build
PLUGIN_SLUG=$(basename ${{ github.workspace }})
zip -r ${PLUGIN_SLUG}.zip ${PLUGIN_SLUG}
- name: Upload Release Assets
uses: softprops/action-gh-release@v1
with:
files: build/*.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}# 本地建立 tag
git tag -a v1.0.0 -m "Initial release"
git push origin v1.0.0
# GitHub Actions 自動:
# 1. 執行完整測試
# 2. 安裝生產依賴(--no-dev)
# 3. Strauss 隔離依賴
# 4. 排除開發檔案
# 5. 打包 ZIP
# 6. 上傳到 Release問題場景:
你的插件:使用 guzzlehttp/guzzle 7.0
其他插件:使用 guzzlehttp/guzzle 6.0
→ 只會載入其中一個版本
→ 另一個插件會壞掉 💥
composer.json:
{
"name": "yourname/your-plugin",
"require": {
"php": ">=7.4",
"psr/http-message": "^1.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"wp-phpunit/wp-phpunit": "^6.6",
"yoast/phpunit-polyfills": "^2.0"
},
"autoload": {
"psr-4": {
"YourPlugin\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"YourPlugin\\Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit"
},
"extra": {
"strauss": {
"target_directory": "vendor-prefixed",
"namespace_prefix": "YourPlugin\\Vendor\\",
"classmap_prefix": "YourPlugin_Vendor_",
"constant_prefix": "YOUR_PLUGIN_VENDOR_",
"delete_vendor_packages": true,
"include_root_autoload": true
}
}
}原始依賴:
vendor/psr/http-message/src/MessageInterface.php
namespace Psr\Http\Message;
↓ Strauss 處理後 ↓
vendor-prefixed/psr/http-message/src/MessageInterface.php
namespace YourPlugin\Vendor\Psr\Http\Message;
- Namespace Prefix:PSR-4 namespace 加前綴
- Classmap Prefix:全域類別加前綴
- Constant Prefix:常數加前綴
❌ 不好的做法(函式堆疊)
// functions.php
function create_user_data($data) { ... }
function update_user_data($id, $data) { ... }
function delete_user_data($id) { ... }
// 問題:
// - 難以測試
// - 容易命名衝突
// - 無法 mock✅ 好的做法(物件導向)
// src/Database/UserDatabase.php
namespace YourPlugin\Database;
class UserDatabase {
public function createTable(): bool { ... }
public function dropTable(): bool { ... }
public function tableExists(): bool { ... }
}
// src/Database/UserData.php
namespace YourPlugin\Database;
class UserData {
public function create(array $data): int { ... }
public function read(int $id): ?array { ... }
public function update(int $id, array $data): bool { ... }
public function delete(int $id): bool { ... }
}
// 優勢:
// ✅ 職責清楚
// ✅ 容易測試
// ✅ 可以 mock
// ✅ 命名空間隔離❌ 不好的做法
public function processUser($data) {
// 驗證資料
if (empty($data['name'])) return false;
// 檢查是否存在
$user = $this->findUser($data['email']);
// 建立或更新
if ($user) {
$this->updateUser($user->id, $data);
} else {
$this->createUser($data);
}
// 發送通知
$this->sendNotification($data['email']);
return true;
}
// 問題:測試時很難控制每個環節✅ 好的做法
public function create(array $data): int {
global $wpdb;
$wpdb->insert('table', $data);
return $wpdb->insert_id;
}
public function update(int $id, array $data): bool {
global $wpdb;
return $wpdb->update('table', $data, ['id' => $id]) !== false;
}
public function exists(string $email): bool {
global $wpdb;
return $wpdb->get_var(
$wpdb->prepare("SELECT id FROM table WHERE email = %s", $email)
) !== null;
}
// 測試時可以精確驗證每個方法告訴 AI 每個方法該回傳什麼:
create()→ 回傳新建的 ID (int)read()→ 回傳資料陣列或 nullupdate()→ 回傳是否成功 (bool)delete()→ 回傳是否成功 (bool)
這樣測試時就能精確驗證:
public function test_create_should_return_id()
{
$id = $this->userData->create(['name' => 'John']);
$this->assertIsInt($id);
$this->assertGreaterThan(0, $id);
}
public function test_read_should_return_array_or_null()
{
$data = $this->userData->read(999);
$this->assertNull($data); // ID 不存在
$id = $this->userData->create(['name' => 'John']);
$data = $this->userData->read($id);
$this->assertIsArray($data);
$this->assertEquals('John', $data['name']);
}你:「請幫我測試 UserData 類別的 CRUD 功能,
要驗證:
1. create() 回傳 ID
2. read() 回傳資料或 null
3. update() 回傳 true/false
4. delete() 回傳 true/false」
AI:「好的,我寫了 UserDataTest.php...」
你:「請實作 UserData 類別,
讓測試通過」
AI:「已完成 UserData.php...」
composer test
# ✅ UserDataTest::test_create_should_return_id - PASSED
# ✅ UserDataTest::test_read_should_return_data - PASSED
# ✅ UserDataTest::test_update_should_return_true - PASSED
# ✅ UserDataTest::test_delete_should_return_true - PASSEDgit add .
git commit -m "feat: add UserData CRUD"
git push
# GitHub Actions 自動測試多個 PHP 版本git tag -a v1.0.0 -m "Initial release"
git push origin v1.0.0
# GitHub Actions 自動建置並發布開發流程
│
├─ AI 寫測試 → AI 實作功能 → composer test (SQLite)
│ │
│ ▼
│ 測試通過?
│ │ ✅
│ ▼
└───────────────────────────→ git push
│
┌─────────────────────┴──────────────┐
▼ ▼
┌───────────────────────┐ ┌──────────────────┐
│ GitHub Actions │ │ 繼續本地開發 │
│ 自動化測試 │ └──────────────────┘
├───────────────────────┤
│ ✅ PHP 7.4 (sqlite) │
│ ✅ PHP 8.0 (sqlite) │
│ ✅ PHP 8.1 (sqlite) │
│ ✅ PHP 8.2 (sqlite) │
│ ✅ PHP 8.3 (sqlite) │
│ ✅ PHP 8.3 (mysql) │
│ 📊 上傳覆蓋率 │
└───────────┬───────────┘
│ 全部通過
▼
準備發布版本
│
▼
git tag v1.0.0 && git push tag
│
▼
┌───────────────────────┐
│ Release Workflow │
│ 自動化建置 │
├───────────────────────┤
│ 1. composer install │
│ --no-dev │
│ 2. Strauss 隔離依賴 │
│ 3. 排除開發檔案 │
│ 4. 打包 ZIP │
│ 5. 上傳到 Release │
└───────────┬───────────┘
│
▼
┌───────────────────────┐
│ ✅ 發布完成 │
│ 📦 plugin.zip │
│ 可供下載 │
└───────────────────────┘
# 1. Clone 專案
git clone https://github.com/yourname/your-plugin.git
cd your-plugin
# 2. 安裝依賴
composer install
# 3. 設置 WordPress 測試環境(使用 SQLite)
./bin/install-wp-tests.sh sqlite
# 4. 執行測試
composer test# 請 AI 寫測試
# 請 AI 寫功能
# 執行測試
composer test
# 測試通過就推送
git add .
git commit -m "feat: new feature"
git push- ❌ 每次改完要手動測試多種情境
- ❌ 不敢讓 AI 大幅修改
- ❌ 升級 PHP 版本要手動測試
- ❌ 發布前要手動打包
- ❌ 依賴可能與其他插件衝突
- ✅ 幾秒鐘跑完所有測試
- ✅ AI 重構後立刻知道有沒有問題
- ✅ CI 自動測試多個 PHP 版本
- ✅ 本地開發不需要 MySQL
- ✅ 發布自動打包
- ✅ 依賴完全隔離
從 Gist 複製以下檔案到你的專案:
- solution-bootstrap.php →
tests/bootstrap.php - solution-phpunit.xml.dist →
phpunit.xml.dist - example-wp-config.php →
tests/wp-config.php - example-composer.json →
composer.json(修改 namespace) - install-wp-tests.sh →
bin/install-wp-tests.sh
然後執行:
chmod +x bin/install-wp-tests.sh
composer install
./bin/install-wp-tests.sh sqlite
composer test就可以開始開發了!
- ✅ 使用 Composer 管理依賴
- ✅ 安裝 wp-phpunit
- ✅ 設置 SQLite 作為測試資料庫(不需要 MySQL)
- ✅ 設置 GitHub Actions
- ✅ 先讓 AI 寫測試
- ✅ 再讓 AI 寫程式
- ✅ 用物件導向設計(方便測試)
- ✅ 一個方法只做一件事
- ✅ 明確定義回傳值
- ✅ 本地測試通過
- ✅ Push 後等 CI 測試通過
- ✅ 建立 Tag 自動發布
- ✅ 使用 Strauss 隔離依賴
- wp-phpunit(WordPress 測試框架):https://github.com/wp-phpunit/wp-phpunit
- Strauss(依賴隔離):https://github.com/BrianHenryIE/strauss
- SQLite Integration:https://wordpress.org/plugins/sqlite-database-integration/
- GitHub Actions:https://docs.github.com/en/actions
- PHPUnit:https://phpunit.de/
- WordPress Plugin Handbook:https://developer.wordpress.org/plugins/
AI 開發 WordPress 外掛的成功關鍵:
- 🧪 自動化測試(wp-phpunit + SQLite)
- 🔄 CI/CD(GitHub Actions)
- 📦 依賴隔離(Strauss)
- 🎯 先寫測試,再寫程式
- 💻 物件導向設計
有了這套流程,AI 寫的程式碼就能被快速驗證,大幅提升開發效率和程式品質!
歡迎交流討論!
MIT License - 歡迎自由使用和修改