これは何?
GitHub Actionsを使ったPHPのCI環境のセット(静的解析テスト ⇒ ユニットテスト ⇒ E2Eテスト)を、できるだけ最小構成で作っていきます。
前提
レシピ
- DockerでPHPの環境作る(3分)
- テスト対象の機能と画面作る(2分)
- PHPStanで静的解析の設定(1分)
- PHPUnitでユニットテストコード用意(2分)
- PlaywrightでE2Eテストコード用意(3分)
- GitHub Actionsでテスト実行(4分)
- ブランチの保護(+α)
それではやっていきましょう。
1. DockerでPHP環境作る(3分)
version: '3'
services:
php:
build: ./build
container_name: php-ci-sample
volumes:
- ./app:/var/www/app
ports:
- 8080:80
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
echo "Hello World!";
ここまで出来たらコンテナ立ち上げます。
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をインストールします。
{
"require": {
"php": "^8.2.0"
},
"require-dev": {
"phpunit/phpunit": "^10.1.1",
"phpstan/phpstan": "^1.10.42"
},
"autoload": {
"psr-4": {
"App\\": "src"
}
}
}
docker exec -w /var/www/app php-ci-sample composer install
続いてFizzBuzzクラスを作ります。
<?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
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。
3. PHPStanで静的解析の設定(1分)
とりあえず一番厳しいレベル9にしときます。./app/src/
の中を検査対象とします。
parameters:
paths:
- src
level: 9
実行してテストパスするか確認。
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
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);
}
}
実行してテストパスするか確認。
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を利用します。
npmでPlaywrightインストールしていきます。ついでにnpm scriptsにcomposerとテスト関連のコマンドのショートカットも登録しておきましょう。
公式のガイドではnpm init playwright@latest
でインストールと設定の初期化を行うようになってますが、今回は説明の都合上init
せずに個別に入れていきます。
{
"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"
}
}
npm install
E2Eテスト用のブラウザエンジンを入れておく必要があるのでインストールします。
npx playwright install
続いてPlaywrightの設定ファイルを用意。ほぼデフォルトですが、testDir
やbaseURL
などの設定は今回に合わせて変えてます。テスト対象のブラウザはとりあえずchromeだけにしておきました。
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機能を呼び出し画面表示される文字をチェックするだけの簡単なものです。
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から起動します。
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/
の下に作ります。
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しましょう。
/node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/app/vendor/
その後、新しくブランチを作って適当な変更を加えmainブランチに対するpull request作ります。しばらくするとGitHub Actionsが起動しテストが実行されることが確認できると思います。
これでGitHub Actionsを使ったPHPのCI環境の雛形ができました。
7. ブランチの保護(+α)
ただ、このままでは仮にテストに失敗してもmainブランチへのマージ自体はできてしまう状態なので、より実践的な運用としてGitHubのブランチ保護ルールを設定してmainを保護してあげましょう。GitHubリポジトリの [Settings] から [Branches] の設定画面を開き、新しいルールを追加します。
2023年12月現在、Freeプランの場合はプライベートなリポジトリはブランチ保護ルールの対象外になっているのでパブリックにしておく必要があります。
Branch name patternに「main」と入力し、以下にチェックを入れます。
- ✅ Require a pull request before merging
- mainブランチに対するマージには必ずpull requestを通さなければならなくなります。続く、Require approvalsでマージの承認に必要なレビュアーの人数を指定できますが、一人で開発していてpull requestを承認する人なんていないよ、という場合はここのチェックは外しておいていいと思います。
- ✅ Require status checks to pass before merging
- 特定の検査にパスしなければマージできなくなります。続く、Require branches to be up to date before merging にもチェックを入れて、先ほど作った「Integration」を指定します。
これで「mainブランチに変更をマージするためにはテストをパスしたpull requestが必ず必要」という状況が作れました。
管理者は保護ルールを無視して強制的にマージしたりmainに直接pushすることもできてしまうので、それも防ぎたいという場合は 「Do not allow bypassing the above settings」 にもチェックを入れておきます。
では最後にわざとテストに失敗させてみます。
FizzBuzzクラスを一部書き換えて、本来Fizz
を返すべきところを間違えてPizza🍕
を返しちゃったとしましょう。
<?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ブランチにバグを含んだコードが混ざることを防ぎます。
おわり
CI環境のベースを作ることを目的に最小構成で組んでいるので、実際のアプリに適用する場合は自身の環境に合わせていい感じに変更&肉付けしてください。
今回使ったサンプルはGitHubに置いておきます。