การทดสอบระดับหน่วยหรือ Unit Testing เป็นกระบวนการตรวจสอบหน่วยย่อยของโปรแกรม เช่น method, function หรือ class ให้ทำงานตรงตามที่ออกแบบไว้ โดยแยกการทดสอบออกจากระบบภายนอกให้มากที่สุด

สำหรับระบบที่พัฒนาด้วย PHP เครื่องมือที่นิยมใช้ ได้แก่ PHPUnit และ Pest ขณะที่ Composer ทำหน้าที่จัดการ dependencies, autoloading และคำสั่งที่เกี่ยวข้องกับการทดสอบ

บทความนี้เป็น Workshop ตั้งแต่สร้าง PHP Project จนสามารถรัน Unit Test ด้วย PHPUnit และ Pest ผ่าน Composer ได้


#1. เครื่องมือที่ใช้

ตัวอย่างนี้ใช้เครื่องมือหลักดังนี้

  • PHP
  • Composer
  • PHPUnit
  • Pest
  • PSR-4 Autoloading

ตรวจสอบ PHP:

php --version

ตรวจสอบ Composer:

composer --version

#2. Composer คืออะไร

Composer คือ Dependency Manager สำหรับ PHP ใช้ติดตั้งและบริหาร package ที่โปรเจกต์ต้องการ เช่น

  • PHPUnit
  • Pest
  • Laravel packages
  • Symfony components
  • Logging libraries
  • HTTP clients

ไฟล์สำคัญคือ:

composer.json
composer.lock

composer.json ใช้ประกาศ dependencies และ configuration ของโปรเจกต์ ส่วน composer.lock บันทึกเวอร์ชัน dependency ที่ถูก resolve แล้ว เพื่อช่วยให้แต่ละ environment ติดตั้ง dependency ชุดเดียวกัน


#3. สร้างโปรเจกต์

สร้าง directory:

mkdir php-unit-testing
cd php-unit-testing

เริ่มต้น Composer:

composer init

หรือสร้าง composer.json เองก็ได้

โครงสร้างที่เราจะใช้คือ:

php-unit-testing/
├── composer.json
├── composer.lock
├── phpunit.xml
├── src/
│   ├── Calculator.php
│   └── DiscountService.php
└── tests/
    ├── Unit/
    │   ├── CalculatorTest.php
    │   └── DiscountServiceTest.php
    └── Pest.php

#4. กำหนด PSR-4 Autoloading

แก้ไข composer.json:

{
    "name": "example/php-unit-testing",
    "description": "PHP Unit Testing with PHPUnit and Pest",
    "type": "project",
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    }
}

จากนั้นสร้าง autoload files:

composer dump-autoload

เมื่อกำหนด namespace เป็น App\\ class ภายใต้ src/ จะสามารถเรียกใช้งานผ่าน namespace App ได้


#ส่วนที่ 1: PHPUnit

#5. ติดตั้ง PHPUnit

ติดตั้งเป็น development dependency:

composer require --dev phpunit/phpunit

ตรวจสอบ:

./vendor/bin/phpunit --version

บน Windows สามารถใช้:

vendor\bin\phpunit --version

--dev หมายถึง dependency นี้ใช้สำหรับ development/testing และไม่ใช่ dependency หลักของ application runtime


#6. สร้าง Class สำหรับทดสอบ

สร้างไฟล์:

src/Calculator.php
<?php

namespace App;

use InvalidArgumentException;

class Calculator
{
    public function add(int $a, int $b): int
    {
        return $a + $b;
    }

    public function subtract(int $a, int $b): int
    {
        return $a - $b;
    }

    public function multiply(int $a, int $b): int
    {
        return $a * $b;
    }

    public function divide(float $a, float $b): float
    {
        if ($b == 0.0) {
            throw new InvalidArgumentException(
                'Cannot divide by zero'
            );
        }

        return $a / $b;
    }
}

#7. สร้าง Unit Test ด้วย PHPUnit

สร้าง:

tests/Unit/CalculatorTest.php
<?php

use App\Calculator;
use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase
{
    public function testAdd(): void
    {
        $calculator = new Calculator();

        $result = $calculator->add(10, 20);

        $this->assertSame(30, $result);
    }

    public function testSubtract(): void
    {
        $calculator = new Calculator();

        $this->assertSame(
            5,
            $calculator->subtract(10, 5)
        );
    }

    public function testMultiply(): void
    {
        $calculator = new Calculator();

        $this->assertSame(
            50,
            $calculator->multiply(10, 5)
        );
    }

    public function testDivide(): void
    {
        $calculator = new Calculator();

        $this->assertSame(
            5.0,
            $calculator->divide(10, 2)
        );
    }
}

รัน:

./vendor/bin/phpunit tests

หรือเฉพาะไฟล์:

./vendor/bin/phpunit tests/Unit/CalculatorTest.php

#8. แนวคิด Arrange--Act--Assert

Unit Test นิยมแบ่ง logic เป็น 3 ขั้นตอน

#Arrange

เตรียม object และข้อมูล:

$calculator = new Calculator();

#Act

เรียก method ที่ต้องการทดสอบ:

$result = $calculator->add(10, 20);

#Assert

ตรวจสอบผลลัพธ์:

$this->assertSame(30, $result);

เรียกรวมว่า:

Arrange
   ↓
Act
   ↓
Assert

หรือ AAA Pattern


#9. PHPUnit Assertions

ตัวอย่าง assertion ที่ใช้บ่อย:

$this->assertSame(10, $value);
$this->assertEquals(10, $value);

$this->assertTrue($result);
$this->assertFalse($result);

$this->assertNull($value);
$this->assertNotNull($value);

$this->assertCount(3, $items);

$this->assertContains('PHP', $languages);

assertSame() ตรวจสอบทั้งค่าและชนิดข้อมูล จึงเหมาะเมื่อ test contract ต้องการ type ที่แน่นอน


#10. ทดสอบ Exception

เราต้องการให้:

$calculator->divide(10, 0);

โยน InvalidArgumentException

เขียน test:

public function testCannotDivideByZero(): void
{
    $calculator = new Calculator();

    $this->expectException(
        InvalidArgumentException::class
    );

    $this->expectExceptionMessage(
        'Cannot divide by zero'
    );

    $calculator->divide(10, 0);
}

Unit Test จึงไม่ได้ตรวจเฉพาะ successful path แต่ควรตรวจสอบ error path และ edge cases ด้วย


#ส่วนที่ 2: Pest

#11. Pest คืออะไร

Pest เป็น Testing Framework สำหรับ PHP ที่เน้น syntax กระชับและอ่านง่าย

แทนที่จะเขียน:

class CalculatorTest extends TestCase
{
    public function testAdd(): void
    {
        // ...
    }
}

Pest สามารถเขียน:

test('adds two numbers', function () {
    // ...
});

และใช้ expectation API เช่น:

expect($result)->toBe(30);

Pest สร้างอยู่บน ecosystem ของ PHPUnit ทำให้แนวคิดและ tooling หลายส่วนสามารถทำงานร่วมกันได้


#12. ติดตั้ง Pest

ติดตั้ง:

composer require --dev pestphp/pest

เริ่มต้น Pest:

./vendor/bin/pest --init

จากนั้นจะมี configuration สำหรับการทดสอบ เช่น:

tests/Pest.php

#13. Unit Test เดิมในรูปแบบ Pest

ตัวอย่าง:

<?php

use App\Calculator;

test('adds two numbers', function () {

    $calculator = new Calculator();

    expect(
        $calculator->add(10, 20)
    )->toBe(30);
});

ทดสอบ subtraction:

test('subtracts two numbers', function () {

    $calculator = new Calculator();

    expect(
        $calculator->subtract(10, 5)
    )->toBe(5);
});

ทดสอบ multiplication:

test('multiplies two numbers', function () {

    $calculator = new Calculator();

    expect(
        $calculator->multiply(10, 5)
    )->toBe(50);
});

ทดสอบ division:

test('divides two numbers', function () {

    $calculator = new Calculator();

    expect(
        $calculator->divide(10, 2)
    )->toBe(5.0);
});

รัน:

./vendor/bin/pest

#14. Pest Expectations

ตัวอย่าง:

expect($value)->toBe(10);

expect($name)->toBe('PHP');

expect($result)->toBeTrue();

expect($result)->toBeFalse();

expect($value)->toBeNull();

expect($items)->toHaveCount(3);

สามารถ chain expectations ได้ในกรณีที่เหมาะสม:

expect($name)
    ->toBeString()
    ->not->toBeEmpty();

จุดเด่นคือ test อ่านใกล้เคียงภาษาธรรมชาติมากขึ้น


#15. ทดสอบ Exception ด้วย Pest

test('cannot divide by zero', function () {

    $calculator = new Calculator();

    $calculator->divide(10, 0);

})->throws(
    InvalidArgumentException::class,
    'Cannot divide by zero'
);

#ส่วนที่ 3: Data-driven Testing

#16. ทำไมต้องใช้ Data-driven Test

สมมติเราต้องทดสอบ:

1 + 2 = 3
5 + 5 = 10
10 + 20 = 30
-5 + 5 = 0

หากสร้าง test แยกทุกกรณีจะเกิด code ซ้ำจำนวนมาก

Pest สามารถเขียนได้:

test(
    'adds numbers',
    function ($a, $b, $expected) {

        $calculator = new Calculator();

        expect(
            $calculator->add($a, $b)
        )->toBe($expected);

    }
)->with([
    [1, 2, 3],
    [5, 5, 10],
    [10, 20, 30],
    [-5, 5, 0],
]);

แนวคิดคือ:

Test Logic
    │
    ├── Dataset 1
    ├── Dataset 2
    ├── Dataset 3
    └── Dataset 4

ทำให้เพิ่ม test cases ได้โดยไม่ต้อง duplicate test logic


#ส่วนที่ 4: ทดสอบ Business Logic

#17. สร้าง DiscountService

สร้าง:

src/DiscountService.php
<?php

namespace App;

class DiscountService
{
    public function calculate(
        float $price,
        float $discountPercent
    ): float {
        if ($price < 0) {
            throw new \InvalidArgumentException(
                'Price cannot be negative'
            );
        }

        if (
            $discountPercent < 0 ||
            $discountPercent > 100
        ) {
            throw new \InvalidArgumentException(
                'Invalid discount percentage'
            );
        }

        return $price -
            ($price * $discountPercent / 100);
    }
}

#18. PHPUnit Test

<?php

use App\DiscountService;
use PHPUnit\Framework\TestCase;

class DiscountServiceTest extends TestCase
{
    public function testCalculateDiscount(): void
    {
        $service = new DiscountService();

        $result = $service->calculate(
            1000,
            10
        );

        $this->assertSame(
            900.0,
            $result
        );
    }
}

#19. Pest Test

<?php

use App\DiscountService;

test(
    'calculates discount',
    function () {

        $service = new DiscountService();

        expect(
            $service->calculate(1000, 10)
        )->toBe(900.0);

    }
);

Data-driven version:

test(
    'calculates discounts',
    function (
        $price,
        $discount,
        $expected
    ) {

        $service = new DiscountService();

        expect(
            $service->calculate(
                $price,
                $discount
            )
        )->toBe($expected);

    }
)->with([
    [1000.0, 10.0, 900.0],
    [1000.0, 20.0, 800.0],
    [500.0, 50.0, 250.0],
    [100.0, 0.0, 100.0],
]);

#ส่วนที่ 5: Composer Scripts

#20. ลดความยาวของคำสั่งด้วย Composer

ปกติเราอาจเรียก:

./vendor/bin/phpunit

หรือ:

./vendor/bin/pest

สามารถสร้าง Composer Scripts เพื่อให้ทีมใช้คำสั่งมาตรฐานเดียวกันได้

ตัวอย่าง:

{
    "scripts": {
        "test:phpunit": "phpunit",
        "test:pest": "pest",
        "test": "pest"
    }
}

จากนั้นรัน:

composer test

หรือ:

composer test:phpunit

และ:

composer test:pest

ข้อดีคือ developer และ CI server สามารถใช้คำสั่งเดียวกัน

Developer
    │
    │ composer test
    ▼
Composer
    │
    ▼
Testing Framework
    │
    ├── PHPUnit
    │
    └── Pest

#ส่วนที่ 6: PHPUnit Configuration

#21. สร้าง phpunit.xml

ตัวอย่าง configuration:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit
    bootstrap="vendor/autoload.php"
    colors="true"
>
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
    </testsuites>
</phpunit>

จากนั้นสามารถรัน:

./vendor/bin/phpunit

โดยไม่ต้องระบุ directory ทุกครั้ง


#ส่วนที่ 7: PHPUnit vs Pest

#22. เปรียบเทียบ

หัวข้อ PHPUnit Pest


รูปแบบ Class-based เป็นหลัก Functional/BDD-style Syntax Explicit กระชับ Assertions $this->assert...() expect()->to...() Learning เหมาะกับการเรียนโครงสร้าง xUnit อ่านง่ายและเริ่มต้นเร็ว Ecosystem Framework หลักของ PHP testing ทำงานบน PHPUnit ecosystem Existing Projects เหมาะมาก สามารถนำมาใช้เพิ่มได้ Data-driven Test รองรับ เขียนได้กระชับ CI/CD รองรับ รองรับ

ไม่จำเป็นต้องมองว่า PHPUnit และ Pest เป็นคู่แข่งที่ต้องเลือกเพียงอย่างเดียว เพราะ Pest ใช้ PHPUnit ecosystem อยู่เบื้องหลัง และสามารถค่อย ๆ นำ Pest เข้าไปใช้ใน codebase ที่มี PHPUnit tests อยู่แล้วได้


#ส่วนที่ 8: Unit Test ที่ดีควรเป็นอย่างไร

#23. FIRST Principles

แนวคิดหนึ่งที่ใช้พิจารณา Unit Test คือ FIRST

#Fast

Test ควรรันเร็ว

#Independent

แต่ละ test ไม่ควรพึ่งพากัน

#Repeatable

รันซ้ำแล้วควรได้ผลเหมือนเดิมภายใต้เงื่อนไขเดียวกัน

#Self-validating

Test ต้องตัดสิน PASS/FAIL ได้เอง

#Timely

ควรเขียน test ในช่วงเวลาที่เหมาะสมใกล้กับการพัฒนา feature


#24. สิ่งที่ไม่ควรอยู่ใน Unit Test โดยตรง

Unit Test ควรลด dependency ต่อระบบภายนอก เช่น:

Database
Network
External REST API
File System
Message Broker
Email Server
Cloud Service

หาก class ต้องพึ่ง dependency เหล่านี้ อาจใช้:

  • Dependency Injection
  • Stub
  • Fake
  • Mock

เพื่อ isolate unit ที่ต้องการทดสอบ


#ส่วนที่ 9: Workflow ที่แนะนำ

Development workflow สามารถเป็น:

เขียน Requirement
       │
       ▼
สร้าง Class / Function
       │
       ▼
เขียน Unit Test
       │
       ▼
composer test
       │
   ┌───┴───┐
   │       │
 PASS     FAIL
   │       │
   │       ▼
   │    แก้ Code
   │       │
   │       └────────┐
   │                │
   ▼                │
Refactor ◄──────────┘
   │
   ▼
Commit
   │
   ▼
CI/CD

หากใช้แนวคิด Test-Driven Development สามารถปรับเป็น:

RED
 │
 │ เขียน Test ที่ยังไม่ผ่าน
 ▼
GREEN
 │
 │ เขียน Code ขั้นต่ำให้ Test ผ่าน
 ▼
REFACTOR
 │
 │ ปรับปรุงโครงสร้าง Code
 └──────────────► ทำซ้ำ

#ส่วนที่ 10: Composer ใน CI/CD

เมื่อ source code ถูก push ไปยัง Git repository CI pipeline สามารถติดตั้ง dependencies:

composer install

แล้วรัน:

composer test

Workflow:

Developer
    │
    ▼
Git Push
    │
    ▼
CI Server
    │
    ├── composer install
    │
    ├── composer test
    │
    └── Build
            │
        ┌───┴───┐
        ▼       ▼
      PASS     FAIL
        │
        ▼
     Deploy

สำหรับ CI โดยทั่วไปควร commit composer.lock เพื่อให้ pipeline ติดตั้ง dependency versions ตามที่โปรเจกต์ล็อกไว้


#สรุป

การสร้างระบบทดสอบสำหรับ PHP สามารถแบ่งหน้าที่ของเครื่องมือได้ดังนี้:

Composer
   │
   ├── Dependency Management
   ├── Autoloading
   └── Test Scripts
            │
            ▼
       Test Framework
        ┌────┴────┐
        │         │
    PHPUnit      Pest
        │         │
        └────┬────┘
             ▼
          Unit Test
             │
       ┌─────┴─────┐
       ▼           ▼
     PASS         FAIL

Composer ดูแล dependencies และ automation commands, PHPUnit เป็น framework หลักที่มีรูปแบบ xUnit ชัดเจน ส่วน Pest เพิ่ม syntax ที่กระชับและ expectation API ที่อ่านง่ายบน PHPUnit ecosystem

สำหรับผู้เริ่มต้น การทดลองเขียน test ชุดเดียวกันทั้ง PHPUnit และ Pest เป็นวิธีที่ดีในการเข้าใจว่า syntax แตกต่างกันอย่างไร ขณะที่หลักการสำคัญยังเหมือนเดิม ได้แก่ การแยกหน่วยที่ต้องการทดสอบ การกำหนด expected behavior และการทำให้ test รันซ้ำได้อย่างสม่ำเสมอ


#คำสั่งสำคัญสรุป

# สร้าง Composer project
composer init

# ติดตั้ง PHPUnit
composer require --dev phpunit/phpunit

# ติดตั้ง Pest
composer require --dev pestphp/pest

# สร้าง autoload
composer dump-autoload

# เริ่มต้น Pest
./vendor/bin/pest --init

# รัน PHPUnit
./vendor/bin/phpunit

# รัน Pest
./vendor/bin/pest

# รันผ่าน Composer Script
composer test

#เอกสารอ้างอิง