GitHub ActionsでPHPのCI環境作る（15分で）

# これは何？

GitHub Actionsを使ったPHPのCI環境のセット（静的解析テスト ⇒ ユニットテスト ⇒ E2Eテスト）を、できるだけ最小構成で作っていきます。

### 前提
- Docker
    - 手元にDocker環境入っていない人は先に[インストール](https://www.docker.com/products/docker-desktop/)しておいてください
- node >= v20
    - 自分は普段[Volta](https://volta.sh/)使ってますが何でもいいです

### レシピ

1. DockerでPHPの環境作る（3分）
1. テスト対象の機能と画面作る（2分）
1. PHPStanで静的解析の設定（1分）
1. PHPUnitでユニットテストコード用意（2分）
1. PlaywrightでE2Eテストコード用意（3分）
1. GitHub Actionsでテスト実行（4分）
1. ブランチの保護（＋α）

それではやっていきましょう。

# 1. DockerでPHP環境作る（3分）

```./docker-compose.yml
version: '3'

services:
  php:
    build: ./build
    container_name: php-ci-sample
    volumes:
      - ./app:/var/www/app
    ports:
      - 8080:80
```

```Dockerfile:./build/Dockerfile
FROM php:8.3-apache

RUN apt-get update \
    && docker-php-ext-install pdo_mysql \
    && apt-get install -y \
    git \
    curl \
    zip \
    unzip

ENV APACHE_DOCUMENT_ROOT='/var/www/app/web/'
RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf

RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer
```

折角なのでPHPのバージョンは11/23にリリースされたばかりの最新8.3にしました。
今回は`./app/web/`をドキュメントルートとして扱う設定にしているので、確認用にHello World用のindex.phpだけ置いときます。

```php:./app/web/index.php
<?php
echo "Hello World!";
```

ここまで出来たらコンテナ立ち上げます。
```shell:コマンド
docker-compose up -d
```

`http://127.0.0.1:8080`にアクセスして`Hello World!`が表示されればOK。

ディレクトリ構成はこんな感じ。`./app/src/`と`./app/tests/`の中身はまだ空です。ここにこの後ソースコードとテストコード置いていきます。

```:ディレクトリ構成
./
├─ app/
│   ├─ src/
│   ├─ tests/
│   └─ web/
│       └─ index.php
├─ build/
│  　├─ Dockerfile
│  　└─ php.ini  
└─ docker-compose.yml
```

# 2. テスト対象の機能と画面作る（2分）

CIで自動テストの環境を整える前に、そもそもテスト対象のシステムが無いと何も始まらないのでとりあえずFizzBuzz問題を解くだけの簡単な機能と画面を作っていきます。
まずcomposer.jsonでautoloadの設定と、後で使うことになるPHPStanとPHPUnitをインストールします。

```json:./app/composer.json
{
    "require": {
        "php": "^8.2.0"
    },
    "require-dev": {
        "phpunit/phpunit": "^10.1.1",
        "phpstan/phpstan": "^1.10.42"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src"
        }
    }
}
```

```shell:コマンド
docker exec -w /var/www/app php-ci-sample composer install
```

続いてFizzBuzzクラスを作ります。

```php:./app/src/FizzBuzz.php
<?php
namespace App;

class FizzBuzz
{
      public function answer(int $n): int|string
      {
        if ($n % 3 == 0 && $n % 5 == 0) {
          return "FizzBuzz"; //nが15の倍数
        } elseif ($n % 3 == 0) {
          return "Fizz"; //nが3の倍数
        } elseif ($n % 5 == 0) {
          return "Buzz"; //nが5の倍数
        } else {
          return $n; //それ以外
        }
    }
}
```

Hello Worldの画面を以下のように変えてFizzBuzzに対する入力値を受け取れる画面を作ります。

```php:./app/web/index.php
<?php
require_once __DIR__ . "/../vendor/autoload.php";

if (isset($_POST['number']) && ctype_digit($_POST['number'])) {
  $fizzBuzz = new App\FizzBuzz();
  $input = (int) $_POST['number'];
  $answer = $fizzBuzz->answer($input);
}

$h = fn($v) => htmlspecialchars($v, ENT_QUOTES, 'UTF-8');
?>
<html>
<head>
  <title>FizzBuzz</title>
</head>
<body>
  <form action="." method="post">
    <input type="number" name="number" value="<?php echo $h($input ?? '');?>" placeholder="Please input number">
    <input type="submit" value="Fizz or Buzz">
  </form>
  <p>Answer: 
    <span class="answer"><?php echo $h($answer ?? '');?></span>
  </p>
</body>
</html>
```

画面表示して動作してればOK。

![002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108727/45d2de4c-7d72-1919-6327-eb636746f553.png)


# 3. PHPStanで静的解析の設定（1分）

とりあえず一番厳しいレベル9にしときます。`./app/src/`の中を検査対象とします。

```yml:./app/phpstan.neon
parameters:
    paths:
        - src
    level: 9
```

実行してテストパスするか確認。

```shell:コマンド
docker exec -w /var/www/app php-ci-sample vendor/bin/phpstan analyse
```

```:結果
Note: Using configuration file /var/www/app/phpstan.neon.
 1/1 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%


 [OK] No errors   
```

問題ないですね。

# 4. PHPUnitでユニットテストコードの用意（2分）

続いて、FizzBuzzクラスのユニットテストコード用意していきます。ユニットテストのコードは`./app/tests/unit/`に置くことにします。

```php:./app/tests/unit/FizzBuzzTest.php
<?php
use PHPUnit\Framework\TestCase;
use App\FizzBuzz;

class FizzBuzzTest extends TestCase
{
    private FizzBuzz $fizzBuzz;
   
    public function setUp(): void {
        $this->fizzBuzz = new FizzBuzz();
    }

    public function testFizz() {
        $result = $this->fizzBuzz->answer(3);
        $this->assertSame($result, "Fizz");
    }
   
    public function testBuzz() {
        $result = $this->fizzBuzz->answer(5);
        $this->assertSame($result, "Buzz");
    }
   
    public function testFizzBuzz() {
        $result = $this->fizzBuzz->answer(15);
        $this->assertSame($result, "FizzBuzz");
    }
   
    public function testOther() {
        $result = $this->fizzBuzz->answer(1);
        $this->assertSame($result, 1);
    }
}
```

実行してテストパスするか確認。

```shell:コマンド
docker exec -w /var/www/app php-ci-sample vendor/bin/phpunit tests/unit
```

```:結果
PHPUnit 10.4.2 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.3.0

....                                                                4 / 4 (100%)

Time: 00:00.030, Memory: 6.00 MB

OK (4 tests, 4 assertions)
```

ユニットテストも問題なし。

# 5. PlaywrightでE2Eテストコードの用意（3分）

E2Eテストに[Playwright](https://playwright.dev/)を利用します。
npmでPlaywrightインストールしていきます。ついでにnpm scriptsにcomposerとテスト関連のコマンドのショートカットも登録しておきましょう。

:::note info
公式のガイドでは`npm init playwright@latest`でインストールと設定の初期化を行うようになってますが、今回は説明の都合上`init`せずに個別に入れていきます。
:::

```json:./package.json
{
  "name": "php-ci-sample",
  "version": "1.0.0",
  "devDependencies": {
    "@playwright/test": "^1.40.0",
    "@types/node": "^20.9.1"
  },
  "scripts": {
    "composer": "docker exec -w /var/www/app php-ci-sample composer",
    "test:static": "docker exec -w /var/www/app php-ci-sample vendor/bin/phpstan analyse",
    "test:unit": "docker exec -w /var/www/app php-ci-sample vendor/bin/phpunit tests/unit",
    "test:e2e": "npx playwright test -c app"
  },
  "volta": {
    "node": "20.9.0"
  }
}
```

```shell:コマンド
npm install
```

E2Eテスト用のブラウザエンジンを入れておく必要があるのでインストールします。

```shell:コマンド
npx playwright install
```

続いてPlaywrightの設定ファイルを用意。ほぼデフォルトですが、`testDir`や`baseURL`などの設定は今回に合わせて変えてます。テスト対象のブラウザはとりあえずchromeだけにしておきました。

```typescript:./app/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

/**
 * Read environment variables from file.
 * https://github.com/motdotla/dotenv
 */
// require('dotenv').config();

/**
 * See https://playwright.dev/docs/test-configuration.
 */
export default defineConfig({
  testDir: './tests/e2e',
  /* Run tests in files in parallel */
  fullyParallel: true,
  /* Fail the build on CI if you accidentally left test.only in the source code. */
  forbidOnly: !!process.env.CI,
  /* Retry on CI only */
  retries: process.env.CI ? 2 : 0,
  /* Opt out of parallel tests on CI. */
  workers: process.env.CI ? 1 : undefined,
  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
  reporter: 'html',
  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
  use: {
    /* Base URL to use in actions like `await page.goto('/')`. */
    baseURL: 'http://127.0.0.1:8080',

    /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
    trace: 'on-first-retry',
  },

  /* Configure projects for major browsers */
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    }
  ],
});
```

ここまででPlaywrightでE2Eテスト書く準備ができたので実際のテストコード書いていきます。E2Eのテストコードは`./app/tests/e2e/`に置くことにします。テスト内容自体はフォーム経由でFizzBuzz機能を呼び出し画面表示される文字をチェックするだけの簡単なものです。

```typescript:./app/tests/e2e/fizzbuzz.spec.ts
import { test, expect } from '@playwright/test';

test('test Fizz', async ({ page }) => {
    await page.goto('/');
    await page.getByPlaceholder('Please input number').fill("3");
    await page.getByRole('button', {name:'Fizz or Buzz'}).click();
    await expect(page.locator('.answer')).toHaveText("Fizz");
});

test('test Buzz', async ({ page }) => {
    await page.goto('/');
    await page.getByPlaceholder('Please input number').fill("5");
    await page.getByRole('button', {name:'Fizz or Buzz'}).click();
    await expect(page.locator('.answer')).toHaveText("Buzz");
});

test('test FizzBuzz', async ({ page }) => {
    await page.goto('/');
    await page.getByPlaceholder('Please input number').fill("15");
    await page.getByRole('button', {name:'Fizz or Buzz'}).click();
    await expect(page.locator('.answer')).toHaveText("FizzBuzz");
});

test('test Other', async ({ page }) => {
    await page.goto('/');
    await page.getByPlaceholder('Please input number').fill("1");
    await page.getByRole('button', {name:'Fizz or Buzz'}).click();
    await expect(page.locator('.answer')).toHaveText("1");
});

test('test Empty', async ({ page }) => {
    await page.goto('/');
    await page.getByPlaceholder('Please input number').fill("");
    await page.getByRole('button', {name:'Fizz or Buzz'}).click();
    await expect(page.locator('.answer')).toBeEmpty();
});
```

実行してテストパスするか確認。先ほど登録したnpm scriptsから起動します。

```shell:コマンド
npm run test:e2e
```

```:結果
> php-ci-sample@1.0.0 test:e2e
> npx playwright test -c app


Running 5 tests using 4 workers
  5 passed (4.6s)

To open last HTML report run:

  npx playwright show-report
```

E2Eテストもパスすることが確認できました。


ここまでのディレクトリ構成はこんな感じ。
```:ディレクトリ構成
./
├─ app/
│   ├─ src/
│   │   └─ FizzBuzz.php
│   ├─ tests/
│   │  ├─ e2e/
│   │  │   └─ fizzbuzz.spec.ts
│   │  └─ unit/
│   │      └─ FizzBuzzTest.php
│   ├─ web/
│   │   └─ index.php
│   ├─ composer.json
│   ├─ phpstan.neon
│   └─ playwright.config.ts
├─ build/
│   ├─ Dockerfile 
│   └─ php.ini
├─ docker-compose.yml
└─ package.json
```

# 6. GitHub Actionsでトリガーする（4分）

ではいよいよここまで作った静的解析・ユニットテスト・E2EテストをGitHub Actions上で動かしてCI環境を組んでいきます。今回はmainブランチに対するpull requestをトリガーにして起動するようにします。ワークフローの名前は「Integration」とします。
まずはGitHub Actionsの設定ファイルを`./.github/workflows/`の下に作ります。

```yml:./.github/workflows/integration.yml
name: Integration
on: 
  pull_request:
    branches:
      - main
jobs:
  Integration:
    runs-on: ubuntu-latest
    steps:
      - name: Check out repository code
        uses: actions/checkout@v3
      - name: Setup
        run: |
          npm install
          npx playwright install
          docker-compose up -d
          npm run composer install
      - name: Run Static Test
        run: npm run test:static
      - name: Run Unit Test
        run: npm run test:unit
      - name: Run E2E Test
        run: npm run test:e2e
```

準備ができたので、`.gitignore`で外部モジュールとPlaywrightが生成するテスト関連の一時ディレクトリの除外設定を入れてコミットし、GitHubのmainブランチにpushしましょう。

```:.gitignore
/node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/app/vendor/
```

その後、新しくブランチを作って適当な変更を加えmainブランチに対するpull request作ります。しばらくするとGitHub Actionsが起動しテストが実行されることが確認できると思います。

![006.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108727/9b76b823-6bc9-7498-f5d0-89fdc349497e.png)

これでGitHub Actionsを使ったPHPのCI環境の雛形ができました。

# 7. ブランチの保護（＋α）

ただ、このままでは仮にテストに失敗してもmainブランチへのマージ自体はできてしまう状態なので、より実践的な運用としてGitHubのブランチ保護ルールを設定してmainを保護してあげましょう。GitHubリポジトリの **[Settings]** から **[Branches]** の設定画面を開き、新しいルールを追加します。

:::note info
2023年12月現在、Freeプランの場合はプライベートなリポジトリはブランチ保護ルールの対象外になっているのでパブリックにしておく必要があります。
:::

![008.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108727/b73af7da-7f62-b307-9efd-bb62d3422647.png)

**Branch name pattern**に「main」と入力し、以下にチェックを入れます。

<dl>
    <dt>✅ Require a pull request before merging</dt>
    <dd>mainブランチに対するマージには必ずpull requestを通さなければならなくなります。続く、Require approvalsでマージの承認に必要なレビュアーの人数を指定できますが、一人で開発していてpull requestを承認する人なんていないよ、という場合はここのチェックは外しておいていいと思います。</dd>
    <dt>✅ Require status checks to pass before merging</dt>
    <dd>特定の検査にパスしなければマージできなくなります。続く、Require branches to be up to date before merging にもチェックを入れて、先ほど作った「Integration」を指定します。</dd>
</dl>

これで「mainブランチに変更をマージするためにはテストをパスしたpull requestが必ず必要」という状況が作れました。

:::note info
管理者は保護ルールを無視して強制的にマージしたりmainに直接pushすることもできてしまうので、それも防ぎたいという場合は **「Do not allow bypassing the above settings」** にもチェックを入れておきます。
:::

では最後にわざとテストに失敗させてみます。
FizzBuzzクラスを一部書き換えて、本来`Fizz`を返すべきところを間違えて`Pizza🍕`を返しちゃったとしましょう。

```php:./app/src/FizzBuzz.php
<?php
namespace App;

class FizzBuzz
{
      public function answer(int $n): int|string
      {
        if ($n % 3 == 0 && $n % 5 == 0) {
          return "FizzBuzz";
        } elseif ($n % 3 == 0) {
          return "Pizza🍕"; // ← FizzをPizzaに変更
        } elseif ($n % 5 == 0) {
          return "Buzz";
        } else {
          return $n;
        }
    }
}
```

当然テストに失敗するのでこのようにマージができなくなります。これによってmainブランチにバグを含んだコードが混ざることを防ぎます。

![007.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108727/6f7614bb-0db4-4be2-94e1-6932368e8903.png)


# おわり

CI環境のベースを作ることを目的に最小構成で組んでいるので、実際のアプリに適用する場合は自身の環境に合わせていい感じに変更＆肉付けしてください。

今回使ったサンプルはGitHubに置いておきます。

https://github.com/tikamoto/php-ci-sample