Skip to content

Instantly share code, notes, and snippets.

@recca0120
Last active January 26, 2026 09:10
Show Gist options
  • Select an option

  • Save recca0120/1edc3c7142911261d5e0b936f3ba333b to your computer and use it in GitHub Desktop.

Select an option

Save recca0120/1edc3c7142911261d5e0b936f3ba333b to your computer and use it in GitHub Desktop.
WordPress 外掛開發:AI + 自動化測試 + SQLite + CI/CD 完整指南
<?php
/**
* PHPUnit Bootstrap File for WordPress Plugin Testing
*/
// Load Composer autoloader
require_once dirname(__DIR__).'/vendor/autoload.php';
// Load WordPress test framework functions
require_once dirname(__DIR__).'/vendor/wp-phpunit/wp-phpunit/includes/functions.php';
/**
* Manually load plugins before WordPress initializes
*/
function _manually_load_plugins()
{
// Load your plugin
require dirname(__DIR__).'/your-plugin.php';
}
// Register plugin loader hook
tests_add_filter('muplugins_loaded', '_manually_load_plugins');
// Start up the WordPress testing environment
require dirname(__DIR__).'/vendor/wp-phpunit/wp-phpunit/includes/bootstrap.php';
{
"name": "yourname/your-plugin",
"description": "Your WordPress plugin description",
"type": "wordpress-plugin",
"license": "MIT",
"require": {
"php": ">=7.2",
"psr/http-message": "^1.0",
"nyholm/psr7": "^1.5"
},
"require-dev": {
"phpunit/phpunit": "^8.5 || ^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,
"delete_vendor_files": true,
"include_root_autoload": true
}
}
}
<?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"
verbose="true"
failOnWarning="true"
failOnRisky="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 (default for local development) -->
<env name="DB_ENGINE" value="sqlite"/>
<env name="DB_FILE" value="test.sqlite"/>
<env name="DB_DIR" value="/tmp/wp-phpunit-tests/"/>
</php>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">src</directory>
</include>
<report>
<html outputDirectory="coverage"/>
</report>
</coverage>
</phpunit>
<?php
namespace YourPlugin\Tests\Integration;
use WP_UnitTestCase;
/**
* 整合測試範例
*
* 在真實 WordPress 環境中測試功能
*/
class ExampleIntegrationTest extends WP_UnitTestCase
{
protected function setUp(): void
{
parent::setUp();
// 設置測試環境
// 可以注入 mock objects, filters 等
}
protected function tearDown(): void
{
// 清理測試環境
remove_all_filters('your_filter_name');
parent::tearDown();
}
/**
* 測試:WordPress factory 建立測試資料
*/
public function test_wordpress_factory()
{
// 使用 WordPress factory 建立測試資料
$post_id = $this->factory->post->create([
'post_title' => 'Test Post',
'post_status' => 'publish',
]);
// 驗證
$this->assertIsInt($post_id);
$this->assertGreaterThan(0, $post_id);
$post = get_post($post_id);
$this->assertEquals('Test Post', $post->post_title);
}
/**
* 測試:模擬訪問頁面
*/
public function test_go_to_page()
{
$post_id = $this->factory->post->create([
'post_title' => 'Hello World',
'post_status' => 'publish',
]);
// 模擬訪問頁面
$this->go_to(get_permalink($post_id));
// 驗證 WordPress 條件標籤
$this->assertTrue(is_single());
$this->assertEquals($post_id, get_the_ID());
}
/**
* 測試:使用 filter 注入 mock objects
*/
public function test_inject_mock_via_filter()
{
$mockValue = 'mocked';
// 注入 mock
add_filter('your_filter', function() use ($mockValue) {
return $mockValue;
});
// 執行功能
$result = apply_filters('your_filter', 'original');
// 驗證
$this->assertEquals('mocked', $result);
}
}
<?php
/**
* WordPress Test Configuration
*
* All settings can be overridden via environment variables
*/
// Database engine configuration
define('DB_ENGINE', getenv('DB_ENGINE') ?: 'mysql');
// SQLite configuration
if (DB_ENGINE === 'sqlite') {
// Use file-based SQLite database
define('DB_FILE', getenv('DB_FILE') ?: 'test.sqlite');
define('DB_DIR', getenv('DB_DIR') ?: '/tmp/wp-phpunit-tests/');
// Ensure directory exists
if (!is_dir(DB_DIR)) {
@mkdir(DB_DIR, 0755, true);
}
// Setup SQLite db.php drop-in
$db_dropin_source = WP_CONTENT_DIR.'/plugins/sqlite-database-integration/wp-includes/sqlite/db.php';
$db_dropin_target = WP_CONTENT_DIR.'/db.php';
if (!file_exists($db_dropin_target)) {
@symlink($db_dropin_source, $db_dropin_target);
}
} else {
// MySQL configuration
define('DB_NAME', getenv('DB_NAME') ?: 'wordpress_test');
define('DB_USER', getenv('DB_USER') ?: 'root');
define('DB_PASSWORD', getenv('DB_PASSWORD') ?: '');
define('DB_HOST', getenv('DB_HOST') ?: '127.0.0.1');
}
define('DB_CHARSET', 'utf8');
define('DB_COLLATE', '');
// WordPress database table prefix
$table_prefix = getenv('WP_TABLE_PREFIX') ?: 'wptests_';
// Required constant for wp-phpunit
define('WP_PHP_BINARY', 'php');
// Test environment
define('WP_TESTS_DOMAIN', getenv('WP_TESTS_DOMAIN') ?: 'example.org');
define('WP_TESTS_EMAIL', getenv('WP_TESTS_EMAIL') ?: 'admin@example.org');
define('WP_TESTS_TITLE', getenv('WP_TESTS_TITLE') ?: 'Test Blog');
// Debugging
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', false);
define('WP_DEBUG_DISPLAY', false);
// Absolute path to WordPress directory
if (!defined('ABSPATH')) {
// Priority: 1) Environment variable, 2) Local .wordpress-test, 3) Parent WordPress installation
$wp_core_dir = getenv('WP_CORE_DIR');
if (!$wp_core_dir) {
$plugin_dir = dirname(__DIR__);
// Check for .wordpress-test in plugin directory
$local_wp = $plugin_dir.'/.wordpress-test/wordpress';
if (is_dir($local_wp)) {
$wp_core_dir = $local_wp;
} else {
// Fallback: assume plugin is installed in WordPress plugins directory
$wp_core_dir = dirname($plugin_dir, 3);
}
}
define('ABSPATH', rtrim($wp_core_dir, '/').'/');
}
// Set up wp-content directory
if (!defined('WP_CONTENT_DIR')) {
define('WP_CONTENT_DIR', ABSPATH.'wp-content');
}

WordPress 外掛開發:AI + 自動化測試 + SQLite + CI/CD 完整指南

前言

最近和朋友們聊到用 AI 寫 WordPress 外掛的經驗,大家最大的痛點都是:AI 寫的程式碼無法百分百信任,還是得手動測試...

本文將介紹如何建立完整的自動化測試環境,讓 AI 寫的程式碼能夠被快速驗證。


為什麼 AI 開發更需要自動化測試?

場景 1:AI 重構大型檔案

  • ❌ 沒測試:只能人工檢查,容易漏掉邊界情況
  • ✅ 有測試:跑一次測試就知道有沒有壞掉

場景 2:AI 新增功能

  • ❌ 沒測試:不知道新功能會不會影響舊功能
  • ✅ 有測試:自動回歸測試,確保舊功能正常

場景 3:跨 PHP 版本相容

  • ❌ 沒測試:只能在一個版本測試
  • ✅ 有測試:CI/CD 自動測試多個 PHP 版本

WordPress + SQLite 測試環境

為什麼使用 SQLite?

傳統做法的痛點 ❌

# 需要安裝 MySQL/MariaDB
brew install mysql
mysql -u root -p

# 建立測試資料庫
CREATE DATABASE wordpress_test;

# 設定連線資訊
# 每次切換專案都要設定一次

使用 SQLite ✅

# 不需要安裝 MySQL!
composer install
composer test

# 自動使用 SQLite 檔案資料庫
# 測試資料自動清理
# 切換專案零配置

SQLite 的優勢

  • 速度快:檔案資料庫,不需要啟動服務
  • 🎯 隔離性好:每個專案獨立的 .sqlite 檔案
  • 🧹 自動清理:測試完畢自動重置
  • 🚀 CI 友善:GitHub Actions 不需要設定 MySQL service
  • 💻 本地開發友善:不需要額外安裝資料庫服務

環境設置

步驟 1:安裝測試套件

composer require --dev phpunit/phpunit wp-phpunit/wp-phpunit yoast/phpunit-polyfills

步驟 2:建立測試環境安裝腳本

建立 bin/install-wp-tests.sh(完整內容見 Gist 中的 install-wp-tests.sh

步驟 3:設定 PHPUnit

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>

步驟 4:設定 WordPress 測試配置

tests/wp-config.php(完整內容見 Gist 中的 example-wp-config.php

步驟 5:建立 PHPUnit Bootstrap(自動偵測版)

tests/bootstrap.php(完整內容見 Gist 中的 solution-bootstrap.php

這個版本會自動偵測插件主檔案,支援:

  • 環境變數指定
  • Plugin Header 掃描
  • 目錄名稱慣例

大多數情況下不需要任何修改!

步驟 6:執行安裝與測試

# 安裝 WordPress 測試環境(使用 SQLite)
./bin/install-wp-tests.sh sqlite

# 執行測試
composer test

測試架構設計

目錄結構

your-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 vs Integration Tests

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());
    }
}

整合測試技巧

1. 使用 WordPress Factory

// 建立文章
$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',
]);

2. 模擬頁面訪問

// 訪問首頁
$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());

3. Mock HTTP 請求

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']);
    }
}

4. 測試資料庫操作

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 CI/CD 自動化

Workflow 1: 自動化測試

建立 .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.xml

Matrix 策略說明

matrix:
  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 分鐘(平行執行)

Workflow 2: 自動化發布

建立 .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

Composer 依賴管理(Strauss)

為什麼需要依賴隔離?

問題場景

你的插件:使用 guzzlehttp/guzzle 7.0
其他插件:使用 guzzlehttp/guzzle 6.0
→ 只會載入其中一個版本
→ 另一個插件會壞掉 💥

Strauss 設定

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;

三重隔離保護

  1. Namespace Prefix:PSR-4 namespace 加前綴
  2. Classmap Prefix:全域類別加前綴
  3. Constant Prefix:常數加前綴

讓 AI 寫出方便測試的程式碼

原則 1:物件導向設計

❌ 不好的做法(函式堆疊)

// 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
// ✅ 命名空間隔離

原則 2:一個方法只做一件事

❌ 不好的做法

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;
}

// 測試時可以精確驗證每個方法

原則 3:明確定義回傳值

告訴 AI 每個方法該回傳什麼:

  • create() → 回傳新建的 ID (int)
  • read() → 回傳資料陣列或 null
  • update() → 回傳是否成功 (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']);
}

AI 開發流程

Step 1:先讓 AI 寫測試

你:「請幫我測試 UserData 類別的 CRUD 功能,
     要驗證:
     1. create() 回傳 ID
     2. read() 回傳資料或 null
     3. update() 回傳 true/false
     4. delete() 回傳 true/false」

AI:「好的,我寫了 UserDataTest.php...」

Step 2:讓 AI 實作功能

你:「請實作 UserData 類別,
     讓測試通過」

AI:「已完成 UserData.php...」

Step 3:本地驗證

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 - PASSED

Step 4:推送到 GitHub

git add .
git commit -m "feat: add UserData CRUD"
git push

# GitHub Actions 自動測試多個 PHP 版本

Step 5:發布版本

git tag -a v1.0.0 -m "Initial release"
git push origin v1.0.0

# GitHub Actions 自動建置並發布

CI/CD 流程圖

開發流程
    │
    ├─ 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

實際成效對比

Before(沒測試)

  • ❌ 每次改完要手動測試多種情境
  • ❌ 不敢讓 AI 大幅修改
  • ❌ 升級 PHP 版本要手動測試
  • ❌ 發布前要手動打包
  • ❌ 依賴可能與其他插件衝突

After(有測試 + SQLite + CI/CD)

  • ✅ 幾秒鐘跑完所有測試
  • ✅ AI 重構後立刻知道有沒有問題
  • ✅ CI 自動測試多個 PHP 版本
  • ✅ 本地開發不需要 MySQL
  • ✅ 發布自動打包
  • ✅ 依賴完全隔離

快速開始

最小化設置(5 個檔案)

從 Gist 複製以下檔案到你的專案:

  1. solution-bootstrap.phptests/bootstrap.php
  2. solution-phpunit.xml.distphpunit.xml.dist
  3. example-wp-config.phptests/wp-config.php
  4. example-composer.jsoncomposer.json(修改 namespace)
  5. install-wp-tests.shbin/install-wp-tests.sh

然後執行:

chmod +x bin/install-wp-tests.sh
composer install
./bin/install-wp-tests.sh sqlite
composer test

就可以開始開發了!


給 AI 開發者的建議

開發前準備

  • ✅ 使用 Composer 管理依賴
  • ✅ 安裝 wp-phpunit
  • ✅ 設置 SQLite 作為測試資料庫(不需要 MySQL)
  • ✅ 設置 GitHub Actions

開發時

  • ✅ 先讓 AI 寫測試
  • ✅ 再讓 AI 寫程式
  • ✅ 用物件導向設計(方便測試)
  • ✅ 一個方法只做一件事
  • ✅ 明確定義回傳值

發布前

  • ✅ 本地測試通過
  • ✅ Push 後等 CI 測試通過
  • ✅ 建立 Tag 自動發布
  • ✅ 使用 Strauss 隔離依賴

相關資源

工具

文件


總結

AI 開發 WordPress 外掛的成功關鍵:

  1. 🧪 自動化測試(wp-phpunit + SQLite)
  2. 🔄 CI/CD(GitHub Actions)
  3. 📦 依賴隔離(Strauss)
  4. 🎯 先寫測試,再寫程式
  5. 💻 物件導向設計

有了這套流程,AI 寫的程式碼就能被快速驗證,大幅提升開發效率和程式品質!


歡迎交流討論!

License

MIT License - 歡迎自由使用和修改

#!/usr/bin/env bash
# WordPress Test Environment Setup Script
#
# Usage:
# ./bin/install-wp-tests.sh [db_engine]
#
# Arguments:
# db_engine: 'sqlite' (default) or 'mysql'
#
# Examples:
# ./bin/install-wp-tests.sh # Setup with SQLite (default)
# ./bin/install-wp-tests.sh sqlite # Setup with SQLite
# ./bin/install-wp-tests.sh mysql # Setup with MySQL
set -e
PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DB_ENGINE="${1:-sqlite}"
WP_CORE_DIR="${PLUGIN_DIR}/.wordpress-test/wordpress"
WP_PLUGINS_DIR="${WP_CORE_DIR}/wp-content/plugins"
echo "=== WordPress Test Environment Setup ==="
echo "Plugin directory: ${PLUGIN_DIR}"
echo "WordPress directory: ${WP_CORE_DIR}"
echo "Database engine: ${DB_ENGINE}"
echo ""
# Check if already installed
if [ -f "${WP_CORE_DIR}/wp-includes/version.php" ]; then
echo "WordPress already installed. To reinstall, run:"
echo " rm -rf ${PLUGIN_DIR}/.wordpress-test"
echo ""
echo "To run tests:"
echo " composer test"
exit 0
fi
# Create directories
mkdir -p "${WP_CORE_DIR}"
mkdir -p "${WP_PLUGINS_DIR}"
# Download WordPress
echo "Downloading WordPress..."
curl -sL https://wordpress.org/latest.tar.gz | tar xz --strip-components=1 -C "${WP_CORE_DIR}"
# Download SQLite integration plugin (for SQLite mode)
if [ "${DB_ENGINE}" = "sqlite" ]; then
echo "Downloading SQLite Database Integration plugin..."
SQLITE_PLUGIN_DIR="${WP_PLUGINS_DIR}/sqlite-database-integration"
mkdir -p "${SQLITE_PLUGIN_DIR}"
# Download from WordPress.org
curl -sL https://downloads.wordpress.org/plugin/sqlite-database-integration.latest-stable.zip > /tmp/sqlite.zip
unzip -q /tmp/sqlite.zip -d "${WP_PLUGINS_DIR}/"
rm /tmp/sqlite.zip
echo "✅ SQLite plugin installed"
fi
echo ""
echo "✅ WordPress test environment ready!"
echo ""
echo "To run tests:"
echo " composer test"
echo ""
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment