Files
agent-unit-testing/docs/components/01-services-and-actions.md
T
Pham-Tan-Thien 2045c856e9 feat: complete Smartgift AI Laravel unit testing toolkit
- Add 10 core testing guides (setup, AAA principles, PHPUnit/Pest, mocking, anti-patterns, PHP 8.4+, debugging, data factories, advanced edge cases)
- Add 13 component testing guides covering all Laravel layers
- Add standard templates for PHPUnit 10/11 and Pest v2/v3 including ArchitectureTest
- Add Docker smart test runner, dependency installer, and CI/CD templates (GitHub Actions, GitLab CI)
- Add 4 Claude Code skills (laravel-test-setup, laravel-unit-test with --all mode, laravel-test-review, laravel-mock-helper)
- Add single unified installer script (sg-install-unit-testing.sh) with global support, CLI runners, and 1-click updater (sg-test-update)
- Add comprehensive documentation (README.md, USAGE-GUIDE.md)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 16:41:37 +07:00

7.0 KiB

Component Guide: Services & Action Classes

Services và Action classes là trái tim của logic nghiệp vụ trong các ứng dụng Laravel hiện đại. Đây là đối tượng quan trọng nhất cần đạt độ phủ Unit Test cao.


1. Mục Tiêu & Bản Chất Kiểm Thử

  • Cần test:
    • Xử lý logic nghiệp vụ, tính toán, điều kiện rẽ nhánh (if/else, switch/match).
    • Đảm bảo các dependency (Repository, Payment Gateway, Mailer, Logger) được gọi đúng phương thức với đúng tham số.
    • Các ngoại lệ (Exception) được ném ra đúng khi điều kiện tiên quyết không thỏa mãn.
    • Giá trị trả về (Return value: DTO, Array, Model, Boolean) khớp với kỳ vọng.
  • Không cần test:
    • Code của framework Laravel (ví dụ: không cần test xem hàm collect() có hoạt động hay không).
    • Kết nối mạng thật hoặc truy vấn database thật (phải Mock hoàn toàn).

2. Chiến Lược Giả Lập (Mocking Strategy)

  • Dependencies trong Constructor: Sử dụng Mockery::mock(Interface::class).
  • Static Facades: Sử dụng Event::fake(), Queue::fake(), Log::shouldReceive().
  • DTOs / Input Data: Khởi tạo DTO thật hoặc mảng thật.

3. Mã Mẫu Chuẩn: Service Class

Class Nguồn Cần Test: App\Services\PaymentService

namespace App\Services;

use App\Contracts\PaymentGatewayInterface;
use App\DTOs\PaymentData;
use App\Exceptions\PaymentFailedException;

class PaymentService
{
    public function __construct(
        private readonly PaymentGatewayInterface $gateway
    ) {}

    public function process(PaymentData $data): string
    {
        if ($data->amount <= 0) {
            throw new \InvalidArgumentException('Số tiền thanh toán phải lớn hơn 0');
        }

        $result = $this->gateway->charge($data->amount, $data->currency);

        if (!$result->isSuccess()) {
            throw new PaymentFailedException($result->getErrorMessage());
        }

        return $result->getTransactionId();
    }
}

Test Chuẩn: PHPUnit 10+ (Attribute Style)

namespace Tests\Unit\Services;

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use Mockery;
use Mockery\MockInterface;
use App\Services\PaymentService;
use App\Contracts\PaymentGatewayInterface;
use App\Contracts\PaymentGatewayResultInterface;
use App\DTOs\PaymentData;
use App\Exceptions\PaymentFailedException;

class PaymentServiceTest extends TestCase
{
    private PaymentGatewayInterface&MockInterface $gatewayMock;
    private PaymentService $service;

    protected function setUp(): void
    {
        parent::setUp();
        $this->gatewayMock = Mockery::mock(PaymentGatewayInterface::class);
        $this->service = new PaymentService($this->gatewayMock);
    }

    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }

    #[Test]
    public function it_processes_payment_successfully_and_returns_transaction_id(): void
    {
        // Arrange
        $paymentData = new PaymentData(amount: 150_000, currency: 'VND');
        
        $resultMock = Mockery::mock(PaymentGatewayResultInterface::class);
        $resultMock->shouldReceive('isSuccess')->once()->andReturnTrue();
        $resultMock->shouldReceive('getTransactionId')->once()->andReturn('TXN_9999');

        $this->gatewayMock->shouldReceive('charge')
            ->once()
            ->with(150_000, 'VND')
            ->andReturn($resultMock);

        // Act
        $transactionId = $this->service->process($paymentData);

        // Assert
        $this->assertSame('TXN_9999', $transactionId);
    }

    #[Test]
    public function it_throws_invalid_argument_exception_when_amount_is_zero_or_negative(): void
    {
        // Arrange
        $paymentData = new PaymentData(amount: 0, currency: 'VND');

        // Assert Expectation
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Số tiền thanh toán phải lớn hơn 0');

        // Act
        $this->service->process($paymentData);
    }

    #[Test]
    public function it_throws_payment_failed_exception_when_gateway_fails(): void
    {
        // Arrange
        $paymentData = new PaymentData(amount: 50_000, currency: 'VND');

        $resultMock = Mockery::mock(PaymentGatewayResultInterface::class);
        $resultMock->shouldReceive('isSuccess')->once()->andReturnFalse();
        $resultMock->shouldReceive('getErrorMessage')->once()->andReturn('Tài khoản không đủ số dư');

        $this->gatewayMock->shouldReceive('charge')
            ->once()
            ->with(50_000, 'VND')
            ->andReturn($resultMock);

        // Assert Expectation
        $this->expectException(PaymentFailedException::class);
        $this->expectExceptionMessage('Tài khoản không đủ số dư');

        // Act
        $this->service->process($paymentData);
    }
}

Test Chuẩn: Pest PHP

use App\Services\PaymentService;
use App\Contracts\PaymentGatewayInterface;
use App\Contracts\PaymentGatewayResultInterface;
use App\DTOs\PaymentData;
use App\Exceptions\PaymentFailedException;

beforeEach(function () {
    $this->gatewayMock = Mockery::mock(PaymentGatewayInterface::class);
    $this->service = new PaymentService($this->gatewayMock);
});

describe('PaymentService', function () {
    it('processes payment successfully and returns transaction id', function () {
        $paymentData = new PaymentData(amount: 150_000, currency: 'VND');
        
        $resultMock = Mockery::mock(PaymentGatewayResultInterface::class);
        $resultMock->shouldReceive('isSuccess')->once()->andReturnTrue();
        $resultMock->shouldReceive('getTransactionId')->once()->andReturn('TXN_9999');

        $this->gatewayMock->shouldReceive('charge')
            ->once()
            ->with(150_000, 'VND')
            ->andReturn($resultMock);

        $transactionId = $this->service->process($paymentData);

        expect($transactionId)->toBe('TXN_9999');
    });

    it('throws exception when amount is zero or negative', function () {
        $paymentData = new PaymentData(amount: -1000, currency: 'VND');

        expect(fn () => $this->service->process($paymentData))
            ->toThrow(\InvalidArgumentException::class, 'Số tiền thanh toán phải lớn hơn 0');
    });

    it('throws payment failed exception when gateway fails', function () {
        $paymentData = new PaymentData(amount: 50_000, currency: 'VND');

        $resultMock = Mockery::mock(PaymentGatewayResultInterface::class);
        $resultMock->shouldReceive('isSuccess')->once()->andReturnFalse();
        $resultMock->shouldReceive('getErrorMessage')->once()->andReturn('Tài khoản không đủ số dư');

        $this->gatewayMock->shouldReceive('charge')
            ->once()
            ->andReturn($resultMock);

        expect(fn () => $this->service->process($paymentData))
            ->toThrow(PaymentFailedException::class, 'Tài khoản không đủ số dư');
    });
});