8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ソフトウェアテストAdvent Calendar 2023

Day 14

GitHub ActionsでPHPのCI環境作る(15分で)

Last updated at Posted at 2023-12-13

これは何?

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

前提

  • Docker
    • 手元にDocker環境入っていない人は先にインストールしておいてください
  • node >= v20
    • 自分は普段Volta使ってますが何でもいいです

レシピ

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

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

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
./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だけ置いときます。

./app/web/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をインストールします。

./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"
        }
    }
}
コマンド
docker exec -w /var/www/app php-ci-sample composer install

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

./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に対する入力値を受け取れる画面を作ります。

./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

3. PHPStanで静的解析の設定(1分)

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

./app/phpstan.neon
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/に置くことにします。

./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);
    }
}

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

コマンド
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せずに個別に入れていきます。

./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"
  }
}
コマンド
npm install

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

コマンド
npx playwright install

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

./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機能を呼び出し画面表示される文字をチェックするだけの簡単なものです。

./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から起動します。

コマンド
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/の下に作ります。

./.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

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

7. ブランチの保護(+α)

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

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

008.png

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🍕を返しちゃったとしましょう。

./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

おわり

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

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

8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?