#การทำ Unit Test ใน Laravel 13 ด้วย PHPUnit แบบทีละขั้นตอน

การทดสอบซอฟต์แวร์เป็นส่วนสำคัญของการพัฒนา Application ที่ต้องการความถูกต้องและดูแลรักษาได้ง่าย โดยเฉพาะระบบที่มี Business Logic จำนวนมาก การเขียน Unit Test ช่วยให้เราตรวจสอบแต่ละหน่วยของโปรแกรมได้โดยอัตโนมัติ และช่วยลดโอกาสเกิด Regression เมื่อมีการแก้ไขโค้ดในอนาคต

บทความนี้สาธิตการทำ Unit Test ใน Laravel 13 โดยใช้ PHPUnit ตั้งแต่การเตรียมโปรเจกต์ การสร้าง Service การสร้าง Test Case การใช้ Assertions การทดสอบ Exception ไปจนถึงการ Mock Dependency

แนวคิดสำคัญ: Unit Test ควรทดสอบหน่วยของโค้ดขนาดเล็กแบบแยกส่วน (isolated) และไม่ควรพึ่ง Database, HTTP Request หรือ External Service โดยไม่จำเป็น


#1. Unit Test คืออะไร

Unit Test คือการทดสอบหน่วยย่อยของโปรแกรม เช่น Method, Function หรือ Class เพื่อยืนยันว่า logic ของหน่วยนั้นให้ผลลัพธ์ตรงตามที่ออกแบบไว้

ตัวอย่างเช่น เรามีระบบคำนวณส่วนลด:

ราคา 1,000 บาท
ส่วนลด 10%
ผลลัพธ์ที่คาดหวัง = 900 บาท

แทนที่จะเปิด Browser และกรอกข้อมูลด้วยตนเอง เราสามารถสร้าง Test Case เพื่อเรียก Method คำนวณราคาโดยตรงและตรวจสอบผลลัพธ์อัตโนมัติ

Input
  ↓
Method / Class
  ↓
Actual Result
  ↓
Assertion
  ↓
Expected Result
  ↓
PASS / FAIL

#2. Unit Test กับ Feature Test ต่างกันอย่างไร

Laravel แบ่งการทดสอบโดยทั่วไปออกเป็นสองกลุ่มสำคัญ

Unit Test Feature Test
ทดสอบ Class/Method ขนาดเล็ก ทดสอบหลายส่วนทำงานร่วมกัน
เน้น isolated logic ใช้งาน Laravel framework ได้เต็มกว่า
ไม่ควรเชื่อม Database โดยไม่จำเป็น สามารถทดสอบ Database ได้
ไม่จำเป็นต้องส่ง HTTP Request เหมาะกับ HTTP/API testing
ทำงานเร็ว มักใช้เวลามากกว่า Unit Test
อยู่ใน tests/Unit อยู่ใน tests/Feature

ตัวอย่างงานที่เหมาะกับ Unit Test ได้แก่ การคำนวณราคา ภาษี คะแนน เกรด การแปลงข้อมูล หรือ validation logic ที่เขียนเป็น class แยก

ส่วนการทดสอบ endpoint เช่น

POST /api/users

รวมถึง Authentication, Middleware, Validation และ Database เหมาะกับ Feature Test มากกว่า


#3. สร้าง Laravel 13 Project

ตรวจสอบ PHP ก่อน:

php -v

จากนั้นสร้างโปรเจกต์ เช่น

composer create-project laravel/laravel laravel-unit-test
cd laravel-unit-test

ตรวจสอบ Laravel:

php artisan --version

และทดลองรัน test ที่มากับโปรเจกต์:

php artisan test

#4. โครงสร้าง Tests ของ Laravel

โครงสร้างหลักจะมีลักษณะดังนี้

laravel-unit-test/
├── app/
│   ├── Http/
│   ├── Models/
│   └── Services/
│
├── tests/
│   ├── Feature/
│   │   └── ExampleTest.php
│   └── Unit/
│       └── ExampleTest.php
│
├── phpunit.xml
└── artisan

ในบทความนี้เราจะเน้นไฟล์ใน

tests/Unit/

#5. Workshop: สร้าง PriceCalculator

สร้าง directory:

app/Services

จากนั้นสร้างไฟล์

app/Services/PriceCalculator.php
<?php

namespace App\Services;

use InvalidArgumentException;

class PriceCalculator
{
    public function calculateDiscount(float $price, float $percent): float
    {
        if ($price < 0) {
            throw new InvalidArgumentException('Price cannot be negative');
        }

        if ($percent < 0 || $percent > 100) {
            throw new InvalidArgumentException('Discount must be between 0 and 100');
        }

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

Class นี้รับราคาและเปอร์เซ็นต์ส่วนลด แล้วคืนราคาสุทธิ

final price = price - (price × percent / 100)

ตัวอย่าง

price   = 1000
percent = 10

1000 - (1000 × 10 / 100)
= 900

#6. สร้าง Unit Test

ใช้ Artisan:

php artisan make:test PriceCalculatorTest --unit

Laravel จะสร้างไฟล์

tests/Unit/PriceCalculatorTest.php

แก้ไขเป็น

<?php

namespace Tests\Unit;

use App\Services\PriceCalculator;
use PHPUnit\Framework\TestCase;

class PriceCalculatorTest extends TestCase
{
    public function test_calculate_discount(): void
    {
        $calculator = new PriceCalculator();

        $result = $calculator->calculateDiscount(1000, 10);

        $this->assertEquals(900, $result);
    }
}

Test นี้แบ่งแนวคิดได้เป็น Arrange → Act → Assert (AAA)

// Arrange
$calculator = new PriceCalculator();

// Act
$result = $calculator->calculateDiscount(1000, 10);

// Assert
$this->assertEquals(900, $result);

#7. รัน Unit Test

รัน test ทั้งหมด:

php artisan test

รันเฉพาะ Unit suite:

php artisan test --testsuite=Unit

รันเฉพาะไฟล์:

php artisan test tests/Unit/PriceCalculatorTest.php

หรือกรองชื่อ test:

php artisan test --filter=PriceCalculatorTest

เมื่อผลลัพธ์ถูกต้อง test จะแสดงสถานะผ่าน หาก expected value ไม่ตรงกับ actual value test จะล้มเหลวและแสดงรายละเอียดเพื่อช่วยวิเคราะห์ปัญหา


#8. เพิ่ม Test Case หลายกรณี

Unit Test ที่ดีไม่ควรตรวจสอบเฉพาะ Happy Path

public function test_zero_discount_returns_original_price(): void
{
    $calculator = new PriceCalculator();

    $result = $calculator->calculateDiscount(1000, 0);

    $this->assertEquals(1000, $result);
}

ทดสอบส่วนลด 100%:

public function test_full_discount_returns_zero(): void
{
    $calculator = new PriceCalculator();

    $result = $calculator->calculateDiscount(1000, 100);

    $this->assertEquals(0, $result);
}

จึงควรคิดอย่างน้อยถึง

Normal Case
Boundary Case
Invalid Input
Exception Case

#9. ทดสอบ Exception

กรณีส่วนลดเกิน 100%:

public function test_discount_greater_than_100_throws_exception(): void
{
    $calculator = new PriceCalculator();

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

    $calculator->calculateDiscount(1000, 120);
}

สามารถตรวจสอบข้อความ Exception เพิ่มได้:

public function test_invalid_discount_has_correct_message(): void
{
    $calculator = new PriceCalculator();

    $this->expectException(\InvalidArgumentException::class);
    $this->expectExceptionMessage('Discount must be between 0 and 100');

    $calculator->calculateDiscount(1000, 120);
}

และกรณีราคาติดลบ:

public function test_negative_price_throws_exception(): void
{
    $calculator = new PriceCalculator();

    $this->expectException(\InvalidArgumentException::class);
    $this->expectExceptionMessage('Price cannot be negative');

    $calculator->calculateDiscount(-100, 10);
}

#10. Assertions ที่ควรรู้

PHPUnit มี Assertions จำนวนมาก ตัวอย่างที่ใช้บ่อย ได้แก่

$this->assertEquals($expected, $actual);
$this->assertSame($expected, $actual);
$this->assertTrue($value);
$this->assertFalse($value);
$this->assertNull($value);
$this->assertNotNull($value);
$this->assertCount(3, $items);
$this->assertEmpty($items);
$this->assertContains('Laravel', $items);
$this->assertInstanceOf(User::class, $user);

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


#11. ตัวอย่างการทดสอบ Business Logic: GradeCalculator

สร้าง

app/Services/GradeCalculator.php
<?php

namespace App\Services;

use InvalidArgumentException;

class GradeCalculator
{
    public function calculate(float $score): string
    {
        if ($score < 0 || $score > 100) {
            throw new InvalidArgumentException('Score must be between 0 and 100');
        }

        return match (true) {
            $score >= 80 => 'A',
            $score >= 70 => 'B',
            $score >= 60 => 'C',
            $score >= 50 => 'D',
            default => 'F',
        };
    }
}

สร้าง test:

php artisan make:test GradeCalculatorTest --unit
<?php

namespace Tests\Unit;

use App\Services\GradeCalculator;
use PHPUnit\Framework\TestCase;

class GradeCalculatorTest extends TestCase
{
    public function test_score_80_returns_grade_a(): void
    {
        $calculator = new GradeCalculator();

        $this->assertSame('A', $calculator->calculate(80));
    }

    public function test_score_79_returns_grade_b(): void
    {
        $calculator = new GradeCalculator();

        $this->assertSame('B', $calculator->calculate(79));
    }

    public function test_score_49_returns_grade_f(): void
    {
        $calculator = new GradeCalculator();

        $this->assertSame('F', $calculator->calculate(49));
    }
}

Boundary values เช่น 79/80, 69/70 และ 49/50 มีความสำคัญ เพราะข้อผิดพลาดในเงื่อนไขมักเกิดบริเวณขอบเขต


#12. ใช้ Data Provider ลด Test Code ซ้ำ

แทนที่จะเขียน method จำนวนมาก สามารถใช้ Data Provider ของ PHPUnit ได้

<?php

namespace Tests\Unit;

use App\Services\GradeCalculator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

class GradeCalculatorTest extends TestCase
{
    #[DataProvider('gradeProvider')]
    public function test_calculate_grade(float $score, string $expected): void
    {
        $calculator = new GradeCalculator();

        $this->assertSame($expected, $calculator->calculate($score));
    }

    public static function gradeProvider(): array
    {
        return [
            'A lower boundary' => [80, 'A'],
            'A maximum' => [100, 'A'],
            'B lower boundary' => [70, 'B'],
            'B upper boundary' => [79, 'B'],
            'C lower boundary' => [60, 'C'],
            'D lower boundary' => [50, 'D'],
            'F upper boundary' => [49, 'F'],
            'F minimum' => [0, 'F'],
        ];
    }
}

วิธีนี้เหมาะกับ logic ที่ต้องทดสอบ Input/Expected Output หลายชุด


#13. Unit Test ที่มี Dependency

ในระบบจริง Service หนึ่งอาจเรียก Service อื่น เช่นระบบส่ง Notification

OrderService
    ↓
NotificationService

หากต้องการ Unit Test OrderService เราไม่ควรส่ง Notification จริง แต่ควรแทน dependency ด้วย Mock

ตัวอย่าง Interface:

<?php

namespace App\Contracts;

interface NotificationService
{
    public function send(string $message): bool;
}

สร้าง OrderService:

<?php

namespace App\Services;

use App\Contracts\NotificationService;

class OrderService
{
    public function __construct(
        private NotificationService $notification
    ) {}

    public function complete(): bool
    {
        $this->notification->send('Order completed');

        return true;
    }
}

#14. Mock Dependency ด้วย PHPUnit

<?php

namespace Tests\Unit;

use App\Contracts\NotificationService;
use App\Services\OrderService;
use PHPUnit\Framework\TestCase;

class OrderServiceTest extends TestCase
{
    public function test_complete_sends_notification(): void
    {
        $notification = $this->createMock(NotificationService::class);

        $notification
            ->expects($this->once())
            ->method('send')
            ->with('Order completed')
            ->willReturn(true);

        $service = new OrderService($notification);

        $result = $service->complete();

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

Mock ช่วยให้ test ไม่ต้องเชื่อมต่อ Email, LINE, Payment Gateway, REST API หรือบริการภายนอกจริง ทำให้ test เร็วและ deterministic มากขึ้น


#15. Unit Test ไม่ควรใช้ Database หรือไม่

สำหรับ pure Unit Test ควรหลีกเลี่ยง Database เพราะเป้าหมายคือทดสอบ logic แยกส่วน

ตัวอย่างที่ไม่เหมาะกับ Unit Test:

User::create([
    'name' => 'John',
    'email' => 'john@example.com',
]);

เมื่อ test ต้องพึ่ง Eloquent และ Database มักเหมาะกับ Feature Test เช่น

php artisan make:test UserRegistrationTest

จากนั้นสามารถใช้เครื่องมืออย่าง RefreshDatabase และ Laravel testing helpers ได้ตามลักษณะการทดสอบ


#16. การตั้งชื่อ Test ที่ดี

ชื่อ Test ควรบอก behavior ที่ต้องการตรวจสอบ

ไม่แนะนำ:

public function test1(): void

ดีกว่า:

public function test_calculate_discount_returns_correct_price(): void

หรือ

public function test_negative_price_throws_exception(): void

เมื่อ test fail เราจะเข้าใจปัญหาได้ทันทีจากชื่อ test


#17. แนวคิด FIRST สำหรับ Unit Test

Unit Test ที่ดีสามารถพิจารณาหลัก FIRST ได้

หลักการ ความหมาย
Fast Test ควรรันเร็ว
Independent Test ไม่ควรขึ้นต่อกัน
Repeatable รันซ้ำแล้วควรได้ผลที่สม่ำเสมอ
Self-validating Test ตัดสิน PASS/FAIL ได้เอง
Timely ควรเขียน test ใกล้กับช่วงที่พัฒนา feature

ดังนั้นไม่ควรออกแบบ test เช่น

Test A → สร้างข้อมูล
          ↓
Test B → ต้องใช้ข้อมูลจาก Test A

Test B ควรเตรียมข้อมูลของตัวเองเพื่อไม่ให้ลำดับการรันมีผลต่อผลทดสอบ


#18. Unit Test ใน Development Workflow

Unit Test สามารถนำเข้า workflow ได้ดังนี้

Developer
   ↓
Write Code
   ↓
Write Unit Test
   ↓
php artisan test
   ↓
PASS ?
 ├── No  → Fix Code → Test Again
 └── Yes
       ↓
     Git Commit
       ↓
     Git Push
       ↓
GitHub Actions / CI
       ↓
Automated Test
       ↓
Build / Deploy

เมื่อใช้ร่วมกับ CI/CD ทุกครั้งที่ Push หรือสร้าง Pull Request ระบบสามารถรัน test อัตโนมัติก่อน merge หรือ deploy ได้


#19. คำสั่งที่ใช้บ่อย

# สร้าง Unit Test
php artisan make:test PriceCalculatorTest --unit

# รัน Test ทั้งหมด
php artisan test

# รันเฉพาะ Unit Test
php artisan test --testsuite=Unit

# รันเฉพาะไฟล์
php artisan test tests/Unit/PriceCalculatorTest.php

# รันตามชื่อ
php artisan test --filter=PriceCalculatorTest

# หยุดเมื่อพบ failure แรก
php artisan test --stop-on-failure

สามารถเรียก PHPUnit โดยตรงได้เช่นกัน แต่ในโปรเจกต์ Laravel การใช้ php artisan test มักสะดวกและสอดคล้องกับ workflow ของ framework


#20. Checklist ก่อนเขียน Unit Test

ก่อนสร้าง Test Case ลองตอบคำถามต่อไปนี้

  • หน่วยของโค้ดที่กำลังทดสอบคืออะไร
  • Input คืออะไร
  • Expected Output คืออะไร
  • มี Boundary Value ใดบ้าง
  • Invalid Input มีอะไรบ้าง
  • ต้องเกิด Exception เมื่อใด
  • Class มี External Dependency หรือไม่
  • Dependency นั้นควร Mock หรือไม่
  • Test สามารถรันแยกจาก test อื่นได้หรือไม่
  • Test พึ่ง Database/HTTP มากเกินไปจนควรเป็น Feature Test หรือไม่

#21. สรุป

Laravel 13 มีโครงสร้างรองรับ Automated Testing ตั้งแต่เริ่มต้น และสามารถใช้ PHPUnit เพื่อสร้าง Unit Test สำหรับ business logic ได้อย่างเป็นระบบ

แนวทางสำคัญคือ

Business Logic
      ↓
Unit Test
      ↓
Arrange → Act → Assert
      ↓
Normal + Boundary + Invalid Cases
      ↓
Mock External Dependencies
      ↓
php artisan test
      ↓
CI/CD

สำหรับผู้เริ่มต้น ควรเริ่มจาก Class ที่ไม่มี dependency เช่น PriceCalculator หรือ GradeCalculator ก่อน แล้วจึงเพิ่ม Exception Testing, Data Provider และ Mocking ตามลำดับ เมื่อ test เริ่มเกี่ยวข้องกับ HTTP, Database, Authentication หรือหลาย component ของ Laravel ควรพิจารณาย้ายไปใช้ Feature Test เพื่อให้ตรงกับระดับของการทดสอบ


#แหล่งข้อมูลเพิ่มเติม