13 November 2024

[Symfony • Laravel] Testing

Komparasi Testing di Symfony dan Laravel

OpenAPI

Astaga, habis OpenAPI terbitlah testing 😓

Habis mainan terbitlah kerja 🙃

Btw, mainan dan kerja sebenarnya sama saja kok gaes, gak ada bedanya. Mainan gak serius jadi berantem. Kerja gak serius ya gak laku. Testing adalah upaya untuk serius dalam berkode. Tidak adanya testing merupakan ciri ketidakseriusan.

Lho, saya serius berkode, tapi berbagai macam kondisi belum mendukung untuk menerapkan testing 😭

Dimengerti gaes. Setiap lingkungan punya caranya sendiri dalam merespon masalah. Nah, mulai dari sekarang, dari diri sendiri, mari kita serius ☕️

Skenario testing

Karena kita membuat REST-API, maka tipe testing yang digunakan adalah HTTP Testing. Tipe tersebut adalah untuk memastikan bahwa dari proses request yang dilakukan menghasilkan response yang diharapkan. Ibaratnya pesan es cokelat, maka yang datang seharusnya es cokelat, bukan nasi goreng. Nyok kita mulai ☕️

Symfony

Ingat API Platform yang sempat disinggung di tulisan sebelumnya? Symfony sangat merekomendasikan penggunaan API Platform sebagai alat untuk membuat API. API Platform menyediakan banyak fitur penunjang dalam membuat API tanpa upaya manual seperti saat dilakukan hanya dengan Symfony. Dan yang bisa kita manfaatkan dari API Platform dalam hal ini adalah testingnya 🙃

Instalasi test pack-nya Symfony dulu.

composer require --dev symfony/test-pack

Lalu core-nya API Platform dan Symfony HTTP Client.

composer require api-platform/core symfony/http-client

Instalasi API Platform Core di atas tidak sampai mengintegrasikan Symfony dengan API Platform, sehingga struktur yang telah disiapkan kemarin tidak ada yang berubah. Sebagai tambahan, agar lebih yakin, kalian bisa menonaktifkan (comment) pengaturan API-Platform di config/packages/api_platform.yaml dan config/routes/api_platform.yaml.

Lanjut membuat test dengan tipe ApiTestCase dan nama Product\ReadTest.

symfony console make:test

Kurang lebih akan menghasilkan kode seperti ini.

<?php

namespace App\Tests\Product;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;

class ReadTest extends ApiTestCase
{
    public function testSomething(): void
    {
        $response = static::createClient()->request('GET', '/');

        $this->assertResponseIsSuccessful();
        $this->assertJsonContains(['@id' => '/']);
    }
}

Di sini kita akan membuat testing untuk skenario read data, yang dalam praktik kemarin sudah kita siapkan endpoint-nya yaitu GET /api/products. Method testSomething() diubah menjadi seperti ini.

public function testFindMany(): void
{
    // buat request ke endpoint
    $response = static::createClient()->request('GET', '/api/products');
    // memastikan mengeluarkan response sukses
    $this->assertResponseIsSuccessful();
    // memastikan response mengandung data sesuai skenario di bawah ini
    $this->assertJsonContains([
        'currentPage' => 1,
        'lastPage' => 1,
        'pageSize' => 10,
        'prevPage' => null,
        'nextPage' => null,
        'numResults' => 3,
        'results' => [
            [
                'id' => 1,
                'name' => 'Es Cokelat',
                'price' => '7000.00',
            ],
            [
                'id' => 2,
                'name' => 'Es Thai Green Tea',
                'price' => '7000.00',
            ],
            [
                'id' => 3,
                'name' => 'Es Thai Tea',
                'price' => '7000.00',
            ],
        ]
    ]);
}

Sebagai penyedia data saat testing, kita gunakan Doctrine Fixtures Bundle dan Doctrine Test Bundle.

composer require --dev orm-fixtures dama/doctrine-test-bundle

Buat fixtures dengan nama ProductFixtures.

symfony console make:fixtures

Pada ProductFixtures sediakan pengisian data product memanfaatkan entity Product, buat 3 product sesuai yang diharapkan pada testing.

<?php

namespace App\DataFixtures;

use App\Entity\Product;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;

class ProductFixtures extends Fixture
{
    public function load(ObjectManager $manager): void
    {
        $esCokelat = new Product();
        $esCokelat->setName('Es Cokelat');
        $esCokelat->setPrice('7000.00');
        $manager->persist($esCokelat);

        $esThaiGreenTea = new Product();
        $esThaiGreenTea->setName('Es Thai Green Tea');
        $esThaiGreenTea->setPrice('7000.00');
        $manager->persist($esThaiGreenTea);

        $esThaiTea = new Product();
        $esThaiTea->setName('Es Thai Tea');
        $esThaiTea->setPrice('7000.00');
        $manager->persist($esThaiTea);

        $manager->flush();
    }
}

Coba lihat pemanfaatan entity Product di atas, tidak ada pengisian createdAt dan updatedAt, padahal data tersebut wajib diisi. Nah, harapannya createdAt dan updatedAt dapat terisi otomatis saat product dibuat atau diubah, bukan dengan mengisinya manual setiap saat insert atau update. Maka kita butuh extension dari Doctrine untuk menunjang kebutuhan timestamp tersebut. Install dulu bundle-nya.

composer require stof/doctrine-extensions-bundle

Aktifkan fitur timestampable di config/packages/stof_doctrine_extensions.yaml.

stof_doctrine_extensions:
    default_locale: en_US
    orm:
        default:
            timestampable: true

Tambahkan attribute Timestampable pada property createdAt dan updatedAt di entity Product.

// import ini
use Gedmo\Mapping\Annotation as Gedmo;

// lalu pasang pada property yang dimaksud

#[Gedmo\Timestampable(on: 'create')]
private ?\DateTimeImmutable $createdAt = null;

// dan

#[Gedmo\Timestampable(on: 'update')]
private ?\DateTimeImmutable $updatedAt = null;

Dengan begini, data createdAt dan updatedAt akan terisi otomatis saat membuat atau mengubah data product. Coba eksekusi perintah berikut, lalu cek keberadaan 3 es Gendis di tabel product.

symfony console doctrine:fixtures:load

Jika kalian menemukan pesan deprecation, gak perlu panik, biarin aja, biar empunya yang nanganin. Tapi kalau tidak kuat, ya gpp perbaiki aja sesuai instruksi ☕️

Lanjuuuttt…. Di Symfony, pada dasarnya proses testing hanya memanfaatkan PHPUnit, dengan beberapa sentuhan sesuai tipe testing. Misalnya yang kita siapkan yaitu tipe API, maka sentuhan Symfony melalui API Platform hanya dalam konteks testing API saja. Sehingga testing belum dapat dilakukan hanya dengan eksekusi PHPUnit. Urusan database-nya gimana? Skenario migrasinya gimana? Persiapan data yang dibutuhkan gimana? Oleh sebab itu, sebelum eksekusi PHPUnit, berikut tahapan yang wajib dilalui.

  1. Hapus database (jika ada) dengan eksekusi symfony console doctrine:database:drop --force --env=test || true.
  2. Buat database dengan eksekusi symfony console doctrine:database:create --env=test.
  3. Migrasi database dengan eksekusi symfony console doctrine:migrations:migrate -n --env=test.
  4. Yang terakhir, persiapan data dengan eksekusi symfony console doctrine:fixtures:load -n --env=test. Nah, ProductFixtures yang telah kita siapkan sebelumnya adalah untuk ini.

Setelah keempat proses tersebut dilalui, barulah kita bisa eksekusi PHPUnit.

Sangat tidak sederhana 😓

Betul sekali, sangat kontras dengan pendekatan yang dilakukan Laravel. Di Symfony kita lebih ditunjukkan kenyataan kode, di Laravel kita lebih ditunjukkan citra kode. Itu sebabnya kalau sudah masuk kasus rumit, di Laravel pun tidak akan sederhana.

Lanjut… Penyederhanaan dari tahapan di atas bisa dilakukan dengan mengeksekusi perintah2 tersebut di dalam tests/bootstrap.php memanfaatkan passthru().

 <?php

use Symfony\Component\Dotenv\Dotenv;

require dirname(__DIR__) . '/vendor/autoload.php';

if (method_exists(Dotenv::class, 'bootEnv')) {
    (new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');
}

if ($_SERVER['APP_DEBUG']) {
    umask(0000);
}

// semua persiapan sebelum testing dilakukan di sini
passthru('symfony console doctrine:database:drop --force --env=test || true');
passthru('symfony console doctrine:database:create --env=test');
passthru('symfony console doctrine:migrations:migrate -n --env=test');
passthru('symfony console doctrine:fixtures:load -n --env=test');

Selesai, saatnya eksekusi perintah testing.

symfony php bin/phpunit

Duarrrr, akan muncul error ini.

There was 1 failure:

1) App\Tests\Product\ReadTest::testFindMany
Failed asserting that an array has the subset Array &0 (
    'currentPage' => 1
    'lastPage' => 1
    'pageSize' => 10
    'prevPage' => null
    'nextPage' => null
    'numResults' => 3
    'results' => Array &1 (
        0 => Array &2 (
            'id' => 1
            'name' => 'Es Cokelat'
            'price' => '7000.00'
        )
        1 => Array &3 (
            'id' => 2
            'name' => 'Es Thai Green Tea'
            'price' => '7000.00'
        )
        2 => Array &4 (
            'id' => 3
            'name' => 'Es Thai Tea'
            'price' => '7000.00'
        )
    )
).
--- Expected
+++ Actual
@@ @@
 array (
-  'currentPage' => 1,
-  'lastPage' => 1,
-  'pageSize' => 10,
-  'prevPage' => NULL,
-  'nextPage' => NULL,
-  'numResults' => 3,
-  'results' =>
-  array (
-    0 =>
-    array (
-      'id' => 1,
-      'name' => 'Es Cokelat',
-      'price' => '7000.00',
-    ),
-    1 =>
-    array (
-      'id' => 2,
-      'name' => 'Es Thai Green Tea',
-      'price' => '7000.00',
-    ),
-    2 =>
-    array (
-      'id' => 3,
-      'name' => 'Es Thai Tea',
-      'price' => '7000.00',
-    ),
-  ),
 )

Lha, kemarin kan controller-nya masih mengeluarkan array kosong 🙄

Nahhh, emang belum kita siapkan 🫣. Tapi itu artinya testing sudah berfungsi sebagaimana mestinya. Nyok lanjut ke Laravel dulu…

Laravel

Di Laravel kita gak akan ada instalasi apapun gaes, tinggal pakai aja pokoknya 🤣

Tapi itu artinya dia framework gemuk kan ya 🙄

Gak salah 🙃

Sampai tulisan ini dibuat, Symfony menggunakan PHPUnit 9, dan Laravel menggunakan PHPUnit 11. Sehingga di beberapa titik testing di Laravel nanti akan ada perbedaan dengan Symfony. Gak masalah, seruput dulu kopinya ☕️

Langsung aja buat testingnya gaes, skenarionya sama persis seperti yang ada di Symfony yak.

php artisan make:test Product/ReadTest

Kurang lebih menghasilkan kode seperti ini.

<?php

namespace Tests\Feature\Product;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class ReadTest extends TestCase
{
    /**
     * A basic feature test example.
     */
    public function test_example(): void
    {
        $response = $this->get('/');

        $response->assertStatus(200);
    }
}

Mari kita sesuaikan method-nya menjadi seperti berikut.

public function testFindMany(): void
{
        // buat request ke endpoint
    $response = $this->json('GET', '/api/products');
    // memastikan mengeluarkan response sukses
    $response->assertSuccessful();
    // memastikan response mengandung data sesuai skenario di bawah ini
    $response->assertJson([
        'currentPage' => 1,
        'lastPage' => 1,
        'pageSize' => 10,
        'prevPage' => null,
        'nextPage' => null,
        'numResults' => 3,
        'results' => [
            [
                'id' => 1,
                'name' => 'Es Cokelat',
                'price' => '7000.00',
            ],
            [
                'id' => 2,
                'name' => 'Es Thai Green Tea',
                'price' => '7000.00',
            ],
            [
                'id' => 3,
                'name' => 'Es Thai Tea',
                'price' => '7000.00',
            ],
        ],
    ]);
}

Lanjut dengan membuat seeder (di Symfony tadi disebut fixture) dengan nama ProductSeeder.

php artisan make:seeder ProductSeeder

Seperti di Symfony, buat 3 product memanfaatkan model Product.

<?php

namespace Database\Seeders;

use App\Models\Product;
use Illuminate\Database\Seeder;

class ProductSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $esCokelat = new Product();
        $esCokelat->name = 'Es Cokelat';
        $esCokelat->price = '7000.00';
        $esCokelat->save();

        $esThaiGreenTea = new Product();
        $esThaiGreenTea->name = 'Es Thai Green Tea';
        $esThaiGreenTea->price = '7000.00';
        $esThaiGreenTea->save();

        $esThaiTea = new Product();
        $esThaiTea->name = 'Es Thai Tea';
        $esThaiTea->price = '7000.00';
        $esThaiTea->save();
    }
}

Lihat, tidak ada penentuan nilai created_at dan updated_at, karena keduanya akan otomatis terisi saat create atau update tanpa tambahan pengaturan apa2 lagi. Mudah banget kan ya 🤣

Karena di Laravel seeder-nya terpusat, daftarkan ProductSeeder ke dalam method run() di file database/seeders/DatabaseSeeder.php.

public function run(): void
{
    $this->call([
        ProductSeeder::class,
    ]);
}

Coba eksekusi perintah berikut, lalu cek keberadaan 3 es Gendis di tabel products.

php artisan db:seed

Balik lagi ke bagian testing, buka file tests/TestCase.php, ubah menjadi seperti berikut. Kita akan tambahkan fungsi reset database dan seeding.

<?php

namespace Tests;

// import ini
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
        // untuk reset db setiap test dilakukan
    use RefreshDatabase;

        // untuk melakukan seeding setiap test dilakukan
    protected $seed = true;
}

Sebelum eksekusi perintah test, sesuaikan dulu sedikit pengaturan PHPUnit-nya, agar database yang digunakan saat test tidak sama seperti yang digunakan pada non test. Buka file phpunit.xml lalu tambahkan bagian berikut di dalam tag <php>. <dbName> bisa kalian ganti dengan nama database yang akan digunakan untuk testing ya. Jangan lupa juga untuk create manual database tersebut.

<env name="DB_DATABASE" value="<dbName>"/>

Lanjut jalankan test.

php artisan test

Duarrr… Sama seperti di Symfony, emang belum kita sediakan response-nya.

 FAILED  Tests\Feature\Product\ReadTest > find many
  Unable to find JSON:

[{
    "currentPage": 1,
    "lastPage": 1,
    "pageSize": 10,
    "prevPage": null,
    "nextPage": null,
    "numResults": 3,
    "results": [
        {
            "id": 1,
            "name": "Es Cokelat",
            "price": "7000.00"
        },
        {
            "id": 2,
            "name": "Es Thai Green Tea",
            "price": "7000.00"
        },
        {
            "id": 3,
            "name": "Es Thai Tea",
            "price": "7000.00"
        }
    ]
}]

within response JSON:

[[]].

Aktualisasi skenario testing

Seperti halnya skenario film, skenario testing bentuknya masih berupa skrip. Harapan sutradara, film yang jadi nanti seharusnya sesuai dengan skrip yang telah di tulis. Begitu juga dengan yang kita lakukan, kita telah menulis skenario testingnya, maka selanjutnya adalah mengaktualisasikannya ke proses di balik endpoint.

Symfony

Mari kita refresh ingatan kita tentang apa yang sudah kita sediakan di ProductController.

<?php

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use OpenApi\Attributes as OA;

class ProductController extends Controller
{
    /**
     * Retrieves the collection of product resources
     */
    #[OA\Get(path: '/api/products', tags: ['Product'])]
    #[OA\Parameter(
        name: 'name',
        in: 'query',
        description: 'Search by product name',
        schema: new OA\Schema(type: 'string'),
    )]
    #[OA\Response(
        response: 200,
        description: 'Product collection',
        content: new OA\JsonContent(
            type: 'array',
            items: new OA\Items(ref: '#/components/schemas/Product')
        ),
    )]
    public function findMany(): JsonResponse
    {
        return response()->json([]);
    }
}

Btw struktur response di testing kayaknya sudah bukan begitu deh 🤔

Nah betul, di testing strukturnya adalah pagination. Mari kita ubah struktur response OpenAPI agar sama seperti yang kita skenariokan di testing, kira2 menjadi seperti ini.

#[OA\Response(
    response: 200,
    description: 'Product collection',
    content: new OA\JsonContent(
        type: 'object',
        properties: [
            new OA\Property(property: 'currentPage', example: 1),
            new OA\Property(property: 'lastPage', example: 1),
            new OA\Property(property: 'pageSize', example: 10),
            new OA\Property(property: 'prevPage', example: null),
            new OA\Property(property: 'nextPage', example: 2),
            new OA\Property(property: 'numResults', example: 10),
            new OA\Property(
                property: 'results',
                type: 'array',
                items: new OA\Items(ref: new Model(type: Product::class))
            ),
        ]
    )
)]

Sekalian tambahkan data example seperti di atas untuk property yang membutuhkan, di entity Product.

// import ini
use OpenApi\Attributes as OA;

// lalu tambahkan attribute data example melalui Property

#[OA\Property(example: 1)]
private ?int $id = null;

#[OA\Property(example: 'Es Cokelat')]
private ?string $name = null;

#[OA\Property(example: '7000.00')]
private ?string $price = null;

Dengan begitu, contoh response yang ditampilkan di OpenAPI akan begini.

{
  "currentPage": 1,
  "lastPage": 1,
  "pageSize": 10,
  "prevPage": null,
  "nextPage": 2,
  "numResults": 10,
  "results": [
    {
      "id": 1,
      "name": "Es Cokelat",
      "price": "7000.00",
      "createdAt": "2024-10-17T17:25:43.293Z",
      "updatedAt": "2024-10-17T17:25:43.293Z"
    }
  ]
}

Nahhhh begini kan keren 😋

Karena response-nya pagination, maka request-nya juga perlu disesuaikan agar dapat mengimplementasikan tugas pagination melalui OpenAPI. Tambahkan parameter pageSize dan page.

#[OA\Parameter(
    name: 'pageSize',
    in: 'query',
    description: 'The number of items per page',
    schema: new OA\Schema(type: 'integer'),
    example: 10,
)]
#[OA\Parameter(
    name: 'page',
    in: 'query',
    description: 'The page number',
    schema: new OA\Schema(type: 'integer'),
    example: 1,
)]

Sekarang kita masuk ke proses di dalam method findMany di controller. Sesuaikan menjadi seperti ini.

// import ini
use App\Repository\ProductRepository;
use App\Request\ProductFilteringRequest;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;

public function findMany(
        // repository untuk melakukan proses pengambilan data product
    ProductRepository $repository,

        // mapping request dari query string memanfaatkan ProductFilteringRequest
    #[MapQueryString(
            // jika gagal, kode default-nya adalah 404
            // ini adalah untuk mengubah kodenya menjadi 422
        validationFailedStatusCode: Response::HTTP_UNPROCESSABLE_ENTITY
    )] ProductFilteringRequest $request = new ProductFilteringRequest(),
): JsonResponse {
    $paginator = $repository->findMany($request);

    return $this->json($paginator);
}

Tambahkan method findMany juga di ProductRepository.

// import ini
use App\Request\ProductFilteringRequest;
use App\Util\Paginator;

// buat method ini
public function findMany(ProductFilteringRequest $request): Paginator
{
        // inisialisasi query builder Product dengan alias p
    $queryBuilder = $this->createQueryBuilder('p');
    // aliaskan query builder expression, hanya untuk menyingkat
    $expression = $queryBuilder->expr();

        // jika terdapat query string name
        // maka terapkan filter berdasarkan name
    if (null !== $request->name) {
        $queryBuilder->where(
            $expression->like(
                $expression->lower('p.name'),
                $expression->literal('%' . mb_strtolower($request->name) . '%'),
            ),
        );
    }

        // urutkan berdasarkan nama
    $queryBuilder->orderBy('p.name', 'ASC');

        // proses pagination
    $paginator = new Paginator($queryBuilder, $request->pageSize);
    $paginator->paginate($request->page);

    return $paginator;
}

Buat file Paginator.php di folder src/Util untuk memproses query dan menyediakan struktur pagination, berikut isinya.

<?php

namespace App\Util;

use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;

final class Paginator
{
    public const PAGE_SIZE = 10;

    private int $currentPage;
    private int $numResults;

    /** @var \Traversable<array-key, object> */
    private \Traversable $results;

    public function __construct(
        private readonly QueryBuilder $queryBuilder,
        private readonly int $pageSize = self::PAGE_SIZE
    ) {}

    public function paginate(int $page = 1): self
    {
        $this->currentPage = max(1, $page);
        $firstResult = ($this->currentPage - 1) * $this->pageSize;

        $this->queryBuilder->setFirstResult($firstResult);
        $this->queryBuilder->setMaxResults($this->pageSize);

        /** @var DoctrinePaginator<object> */
        $paginator = new DoctrinePaginator($this->queryBuilder);

        $this->results = $paginator->getIterator();
        $this->numResults = $paginator->count();

        return $this;
    }

    public function getCurrentPage(): int
    {
        return $this->currentPage;
    }

    public function getLastPage(): int
    {
        return (int) ceil($this->numResults / $this->pageSize);
    }

    public function getPageSize(): int
    {
        return $this->pageSize;
    }

    public function getPrevPage(): ?int
    {
        if (0 < $prevPage = $this->currentPage - 1) {
            return $prevPage;
        }

        return null;
    }

    public function getNextPage(): ?int
    {
        if ($this->getLastPage() > $nextPage = $this->currentPage + 1) {
            return $nextPage;
        }

        return null;
    }

    public function getNumResults(): int
    {
        return $this->numResults;
    }

    /**
     * @return \Traversable<int, object>
     */
    public function getResults(): \Traversable
    {
        return $this->results;
    }
}

Astagaaa buat pagination aja seribet itu 😮‍💨

Tinggal copy paste aja kok ribet sih gaes, pasti jomblo 😭

Oke2, lanjuttt 😶‍🌫️

Selanjutnya kita buat class ProductFilteringRequest sebagai Data Transfer Object (DTO) untuk mapping query string di src/Request.

<?php

namespace App\Request;

final class ProductFilteringRequest
{
        // sebagai default pageSize dan page
        public const DEFAULT_PAGE_SIZE = 10;
    public const DEFAULT_PAGE = 1;

    public function __construct(
        public readonly int $pageSize = self::DEFAULT_PAGE_SIZE,
        public readonly int $page = self::DEFAULT_PAGE,
        public readonly ?string $name = null,
    ) {}
}

Lalu kita tambahkan validasi di pageSize dan page, agar tidak dapat diisi nilai yang tidak valid. Misalnya pageSize diisi 0, sampai kiamat gak bakal dapat data kan yak 🫠

Kita pasang dulu paket untuk validasinya.

composer require symfony/validator

Pasang aturan validasi berikut di ProductFilteringRequest.

// import ini
use Symfony\Component\Validator\Constraints as Assert;

// pageSize dibuat minimal 10
#[Assert\GreaterThanOrEqual(self::DEFAULT_PAGE_SIZE)]
public readonly int $pageSize = self::DEFAULT_PAGE_SIZE,

// page dibuat minimal 1
#[Assert\GreaterThanOrEqual(self::DEFAULT_PAGE)]
public readonly int $page = self::DEFAULT_PAGE,

Selesai, silakan jalankan testing, hasilnya kurang lebih seperti ini.

Dropped database "symfony_api_test" for connection named default
Created database "symfony_api_test" for connection named default
[notice] Migrating up to DoctrineMigrations\Version20241018225746
[notice] finished in 11.2ms, used 18M memory, 1 migrations executed, 4 sql queries

 [OK] Successfully migrated to version: DoctrineMigrations\Version20241018225746

   > purging database
   > loading App\DataFixtures\AppFixtures
   > loading App\DataFixtures\ProductFixtures
PHPUnit 9.6.21 by Sebastian Bergmann and contributors.

Testing
.                                                         1 / 1 (100%)

Time: 00:00.258, Memory: 30.00 MB

OK (1 test, 2 assertions)

Coba juga melalui swagger, hasilnya kurang lebih seperti ini.

{
  "currentPage": 1,
  "lastPage": 1,
  "pageSize": 10,
  "prevPage": null,
  "nextPage": null,
  "numResults": 3,
  "results": [
    {
      "id": 1,
      "name": "Es Cokelat",
      "price": "7000.00",
      "createdAt": "2024-10-18T22:59:16+00:00",
      "updatedAt": "2024-10-18T22:59:16+00:00"
    },
    {
      "id": 2,
      "name": "Es Thai Green Tea",
      "price": "7000.00",
      "createdAt": "2024-10-18T22:59:16+00:00",
      "updatedAt": "2024-10-18T22:59:16+00:00"
    },
    {
      "id": 3,
      "name": "Es Thai Tea",
      "price": "7000.00",
      "createdAt": "2024-10-18T22:59:16+00:00",
      "updatedAt": "2024-10-18T22:59:16+00:00"
    }
  ]
}

Laravel

Pada ProductController, ubah bagian OpenAPI-nya seperti di Symfony.

// tambahkan query string pageSize dan page
#[OA\Parameter(
    name: 'pageSize',
    in: 'query',
    description: 'The number of items per page',
    schema: new OA\Schema(type: 'integer'),
    example: 10,
)]
#[OA\Parameter(
    name: 'page',
    in: 'query',
    description: 'The page number',
    schema: new OA\Schema(type: 'integer'),
    example: 1,
)]

// reseponse-nya diubah menjadi seperti ini
#[OA\Response(
    response: 200,
    description: 'Product collection',
    content: new OA\JsonContent(
        type: 'object',
        properties: [
            new OA\Property(property: 'currentPage', example: 1),
            new OA\Property(property: 'lastPage', example: 1),
            new OA\Property(property: 'pageSize', example: 10),
            new OA\Property(property: 'prevPage', example: null),
            new OA\Property(property: 'nextPage', example: 2),
            new OA\Property(property: 'numResults', example: 10),
            new OA\Property(
                property: 'results',
                type: 'array',
                items: new OA\Items(ref: '#/components/schemas/Product')
            ),
        ]
    )
)]

Ubah method findMany menjadi seperti ini.

// import ini
use App\Http\Requests\ProductFilteringRequest;
use App\Repository\ProductRepository;

// ubah method menjadi seperti ini
public function findMany(
        // form request untuk validasi
        ProductFilteringRequest $request,

        // repository untuk melakukan proses pengambilan data product
        ProductRepository $repository,
): JsonResponse {
    $paginator = $repository->findMany($request);

    return response()->json($paginator->toArray());
}

Tambah form request ProductFilteringRequest untuk kebutuhan validasi.

php artisan make:request ProductFilteringRequest

Ubah menjadi seperti ini.

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

// diberi PHPDoc untuk intelephense (opsional)
/**
 * @property int $pageSize
 * @property int $page
 * @property ?string $name
 */
class ProductFilteringRequest extends FormRequest
{
        // sebagai default pageSize dan page
        public const DEFAULT_PAGE_SIZE = 10;
    public const DEFAULT_PAGE = 1;

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
            // tentukan aturan validasi di sini
        return [
            'pageSize' => [
                    // tidak harus diisi
                'nullable',
                'integer',
                // minimal 10
                'min:' . self::DEFAULT_PAGE_SIZE,
            ],
            'page' => [
                'nullable',
                'integer',
                // minimal 1
                'min:' . self::DEFAULT_PAGE,
            ],
            'name' => [
                'nullable',
            ],
        ];
    }
}

Saya menambahkan PHPDoc @property untuk dikonsumsi intelephense, cukup membantu untuk mengetahui class ProductFilteringRequest memiliki property apa saja. Begitu juga di model Product, kalian bisa menambahkannya juga.

Tambahkan default value-nya dengan menambahkan method prepareForValidation.

protected function prepareForValidation()
{
    $this->merge([
        'pageSize' => $this->pageSize ?? self::DEFAULT_PAGE_SIZE,
        'page' => $this->page ?? self::DEFAULT_PAGE,
    ]);
}

Form request selesai, lanjut ke repository.

Tambah file app/Repository/ProductRepository.php untuk kebutuhan query-nya, ubah menjadi seperti ini.

<?php

namespace App\Repository;

use App\Http\Requests\ProductFilteringRequest;
use App\Models\Product;
use App\Util\Paginator;
use Illuminate\Support\Facades\DB;

final class ProductRepository
{
    public function findMany(ProductFilteringRequest $request): Paginator
    {
            // inisialisasi query builder
        $queryBuilder = Product::query();

                // jika ada query string name, filter berdasarkan name
        if (null !== $request->name) {
            $queryBuilder->where(DB::raw('LOWER(name)'), 'like', '%' . mb_strtolower($request->name) . '%');
        }

                // urutkan berdasarkan name
        $queryBuilder->orderBy('name', 'ASC');

                // pagination memanfaatkan class yang mirip dengan yang digunakan di Symfony
        $paginator = new Paginator($queryBuilder, $request->pageSize);
        $paginator->paginate($request->page);

        return $paginator;
    }
}

Hmmm kenapa gak pakai paginator-nya Laravel sih? 😓

  1. Agar skenario testing di tulisan ini gak terlalu variatif gaes. Antara Symfony dan Laravel biar gak beda2 banget gitu.
  2. Agar kita juga belajar bahwa algoritma pagination ya begitu itu, gak yang gimana2 sehingga harus disediakan framework dan kita hanya bisa patuh.

Aman ya? Oke lanjut ke class Paginator. Buat file app/Util/Paginator.php, ubah menjadi seperti ini.

<?php

namespace App\Util;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;

final class Paginator
{
    public const PAGE_SIZE = 10;

    private int $currentPage;
    private int $numResults;

    private Collection $results;

    public function __construct(
        private readonly Builder $queryBuilder,
        private readonly int $pageSize = self::PAGE_SIZE
    ) {}

    public function paginate(int $page = 1): self
    {
        $this->currentPage = max(1, $page);
        $firstResult = ($this->currentPage - 1) * $this->pageSize;

        $this->numResults = $this->queryBuilder->count();

        $this->queryBuilder->offset($firstResult);
        $this->queryBuilder->limit($this->pageSize);
        $this->results = $this->queryBuilder->get();

        return $this;
    }

    public function getCurrentPage(): int
    {
        return $this->currentPage;
    }

    public function getLastPage(): int
    {
        return (int) ceil($this->numResults / $this->pageSize);
    }

    public function getPageSize(): int
    {
        return $this->pageSize;
    }

    public function getPrevPage(): ?int
    {
        if (0 < $prevPage = $this->currentPage - 1) {
            return $prevPage;
        }

        return null;
    }

    public function getNextPage(): ?int
    {
        if ($this->getLastPage() > $nextPage = $this->currentPage + 1) {
            return $nextPage;
        }

        return null;
    }

    public function getNumResults(): int
    {
        return $this->numResults;
    }

    public function getResults(): Collection
    {
        return $this->results;
    }

        /**
         * Transformasi ke dalam bentuk array
         * untuk dikembalikan sebagai response
         */
    public function toArray()
    {
        return [
            'currentPage' => $this->getCurrentPage(),
            'lastPage' => $this->getLastPage(),
            'pageSize' => $this->getPageSize(),
            'prevPage' => $this->getPrevPage(),
            'nextPage' => $this->getNextPage(),
            'numResults' => $this->getNumResults(),
            'results' => $this->getResults(),
        ];
    }
}

Selesai gaes… Sikat testingnya, kurang lebih hasilnya seperti ini.

 PASS  Tests\Feature\Product\ReadTest
  ✓ find many                                           0.77s

  Tests:    1 passed (2 assertions)
  Duration: 0.85s

Coba juga melalui swagger.

{
  "currentPage": 1,
  "lastPage": 1,
  "pageSize": 10,
  "prevPage": null,
  "nextPage": null,
  "numResults": 3,
  "results": [
    {
      "id": 1,
      "created_at": "2024-11-03T06:01:37.000000Z",
      "updated_at": "2024-11-03T06:01:37.000000Z",
      "name": "Es Cokelat",
      "price": "7000.00"
    },
    {
      "id": 2,
      "created_at": "2024-11-03T06:01:37.000000Z",
      "updated_at": "2024-11-03T06:01:37.000000Z",
      "name": "Es Thai Green Tea",
      "price": "7000.00"
    },
    {
      "id": 3,
      "created_at": "2024-11-03T06:01:37.000000Z",
      "updated_at": "2024-11-03T06:01:37.000000Z",
      "name": "Es Thai Tea",
      "price": "7000.00"
    }
  ]
}

Melengkapi skenario testing

Kita tadi telah melewati tahapan berkode dengan prinsip Test Driven Development (TDD), yaitu pengembangan berdasar skenario test yang dibuat. Meskipun sebenarnya kita tadi belum sepenuhnya menerapkan TDD juga, karena skenario test kita belum menyediakan skenario validasi query string dan skenario filter data product berdasarkan name. Sehingga kode yang kita buat belum sepenuhnya kita uji.

Saya pribadi bukan penganut aliran berkode yang harus begini dan begitu, karena sangat mungkin ada lompatan2 konsentrasi yang tak terduga se-waktu2. Apalagi faktanya kita gak akan bisa membuat testing yang lengkap 100%, mentok cuma 99% aja mah kita, sampai kapan pun. Maka dari itu, sering kali saya ingatkan, yang terpenting adalah konsistensi kita untuk selalu menjadi lebih baik. Yang penting selalu bergerak, bukan malah mengeluh 🙃

Yahhh kena lagi 😶‍🌫️

Symfony

Mari kita sesuaikan ReadTest yang sudah dibuat sebelumnya, ubah method testFindMany menjadi seperti ini.

/**
 * ini adalah pendaftaran data provider di PHPUnit
 * yang artinya testFindMany akan memproses testing
 * sesuai dan sebanyak data yang disediakan method findManyProvider
 *
 * @dataProvider findManyProvider
 */
public function testFindMany(array $request, callable $after): void
{
        // $request & $after dikirim melalui findManyProvider

    $response = static::createClient()->request('GET', '/api/products', [
        'extra' => [
                // cantumkan $request sebagai query string
            'parameters' => $request,
        ]
    ]);

    // response sukses
    $this->assertResponseIsSuccessful();

    // assertion dilakukan melalui fungsi after ini
    $after();
}

Lalu tambahkan method findManyProvider sebagai data provider-nya. Silakan dibaca dengan seksama penjelasannya.

public function findManyProvider(): \Generator
{
        // struktur yield yang digunakan sama seperti satu nilai associative array
        //    'key' => ['value']

        // setiap yield mewakili satu skenario

        // setiap yield memiliki nilai parameter ber-beda2
        //     untuk dikonsumsi melalui argument di testFindMany

        // kita menyediakan dua parameter di setiap yield
        //     1. sebagai representasi query string yang dikirim
        //     2. sebagai after, di sini kita isi dengan assertion

        // maka testFindMany juga harus menyediakan dua argument
        //     sesuai dengan parameter yang disediakan di yield

        // NOTE: keterangan di yield tidak mewakili skenario secara langsung
        //     kita harus menyelaraskan keterangan di yield dengan parameter di yield
        //     misalnya default, maka jangan kirim query string sama sekali
        //     sebagaimana saat di swagger, yaitu dengan tidak mengisi parameter

    yield 'default'
        => [
                // mengirim query string seperti apa
            [],

            // dan berharap output-nya bagaimana
            function () {
                $this->assertJsonContains([
                    'currentPage' => 1,
                    'lastPage' => 1,
                    'pageSize' => 10,
                    'prevPage' => null,
                    'nextPage' => null,
                    'numResults' => 3,
                    'results' => [
                        [
                            'id' => 1,
                            'name' => 'Es Cokelat',
                            'price' => '7000.00',
                        ],
                        [
                            'id' => 2,
                            'name' => 'Es Thai Green Tea',
                            'price' => '7000.00',
                        ],
                        [
                            'id' => 3,
                            'name' => 'Es Thai Tea',
                            'price' => '7000.00',
                        ],
                    ],
                ]);
            },
        ];

    yield 'pageSize 20'
        => [
            ['pageSize' => 20],
            function () {
                $this->assertJsonContains([
                    'currentPage' => 1,
                    'lastPage' => 1,
                    'pageSize' => 20, // sesuai query string
                    'prevPage' => null,
                    'nextPage' => null,
                    'numResults' => 3,
                    'results' => [
                        [
                            'id' => 1,
                            'name' => 'Es Cokelat',
                            'price' => '7000.00',
                        ],
                        [
                            'id' => 2,
                            'name' => 'Es Thai Green Tea',
                            'price' => '7000.00',
                        ],
                        [
                            'id' => 3,
                            'name' => 'Es Thai Tea',
                            'price' => '7000.00',
                        ],
                    ],
                ]);
            },
        ];

    yield 'page 2'
        => [
            ['page' => 2],
            function () {
                $this->assertJsonContains([
                    'currentPage' => 2, // sesuai query string
                    'lastPage' => 1,
                    'pageSize' => 10,
                    'prevPage' => 1, // akhirnya memiliki prevPage
                    'nextPage' => null,
                    'numResults' => 3, // tersedia data, tapi di page 1
                    'results' => [], // kosong
                ]);
            },
        ];

    yield 'filter by name = cokelat'
        => [
            ['name' => 'cokelat'],
            function () {
                $this->assertJsonContains([
                    'currentPage' => 1,
                    'lastPage' => 1,
                    'pageSize' => 10,
                    'prevPage' => null,
                    'nextPage' => null,
                    'numResults' => 1, // hanya satu data
                    'results' => [
                            // hanya tersedia data Es Cokelat
                        [
                            'id' => 1,
                            'name' => 'Es Cokelat',
                            'price' => '7000.00',
                        ],
                    ],
                ]);
            },
        ];

    yield 'filter by name = thai'
        => [
            ['name' => 'thai'],
            function () {
                $this->assertJsonContains([
                    'currentPage' => 1,
                    'lastPage' => 1,
                    'pageSize' => 10,
                    'prevPage' => null,
                    'nextPage' => null,
                    'numResults' => 2, // hanya dua data
                    'results' => [
                              // hanya tersedia data Es Thai Green Tea & Es Thai Tea
                        [
                            'id' => 2,
                            'name' => 'Es Thai Green Tea',
                            'price' => '7000.00',
                        ],
                        [
                            'id' => 3,
                            'name' => 'Es Thai Tea',
                            'price' => '7000.00',
                        ],
                    ],
                ]);
            },
        ];

    yield 'filter by name = teh'
        => [
            ['name' => 'teh'],
            function () {
                $this->assertJsonContains([
                    'currentPage' => 1,
                    'lastPage' => 1,
                    'pageSize' => 10,
                    'prevPage' => null,
                    'nextPage' => null,
                    'numResults' => 0, // kosong
                    'results' => [], // kosong
                ]);
            },
        ];
}

Ohhh bisa dibilang satu skenario testing bisa memiliki sub skenario ya 🤔

Nah, sedikit demi sedikit aura kejombloanmu mulai memudar, semangatnya itu lo 😶

Waaa berarti selesai ini udah gak jomblo ya 😋

Gak lah… Syarat gak jomblo itu kawin, bukan coding 🫠

Lanjut sikat testing-nya gaes, kurang lebih hasilnya begini.

Testing
......                                                    6 / 6 (100%)

Time: 00:00.311, Memory: 30.00 MB

OK (6 tests, 12 assertions)

Noh lihat, ada enam skenario, sesuai dengan yang kita sediakan di data provider. Data provider ini sangat membantu kita dalam melakukan banyak skenario testing dengan juga mempertahankan beberapa kode umum. Misalnya bagian static::createClient()->request('GET', '/api/products'........, ya itu gak perlu setiap testing kita tulis, cukup sekali saja di method utamanya.

Woke, skenario pengambilan data kita cukupkan, mari kita masuk ke skenario validasi query string-nya. Tambahkan method test dan data provider-nya, kurang lebih seperti ini.

/**
 * @dataProvider findManyValidationProvider
 */
public function testFindManyValidation(array $request, string $violation): void
{
    $response = static::createClient()->request('GET', '/api/products', [
        'extra' => [
            'parameters' => $request,
        ]
    ]);

    // ini sesuai mapping request-nya, jika gagal = 422
    $this->assertResponseIsUnprocessable();

    // keterangan di detail, pastikan sama seperti $violation
    // yang ditentukan di setiap skenario di data provider
    $this->assertJsonContains([
        'detail' => $violation,
    ]);
}

public function findManyValidationProvider(): \Generator
{
    yield 'pageSize.greaterThanOrEqual(10)'
        => [
                // query string dibuat salah
            ['pageSize' => 5],

            // pesan kesalahan yang diharapkan
            'pageSize: This value should be greater than or equal to 10.',
        ];

    yield 'page.greaterThanOrEqual(1)'
        => [
            ['page' => 0],
            'page: This value should be greater than or equal to 1.',
        ];
}

Sikat menggunakan perintah ini.

# ini artinya dipilih yang method-nya mengandung testFindManyValidation
symfony php bin/phpunit --filter testFindManyValidation

Dan hasilnya kurang lebih begini.

Testing
[error] Uncaught PHP Exception Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException: "This value should be greater than or equal to 10." at HttpException.php line 44
.[error] Uncaught PHP Exception Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException: "This value should be greater than or equal to 1." at HttpException.php line 44
.                                                         2 / 2 (100%)

Time: 00:00.220, Memory: 28.00 MB

OK (2 tests, 4 assertions)

Dua skenario test aman, tapi ada error yang muncul. Tenang, itu hanya log kok gaes, bisa kalian abaikan. Jadi bukan error yang harus diperbaiki, karena emang begitu skenario exception-nya di Symfony. Tapi kalau mau dihilangkan ya gpp juga sih, kalian bisa mengaturnya di config/packages/framework.yaml. Tambahkan pengaturan sesuai struktur di bawah ini.

when@test:
    framework:

                # ini tambahannya
                # log level-nya dibuat bukan error, misalnya debug
        exceptions:
            Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException:
                log_level: debug

Nah, sekarang sudah bersih.

Testing
..                                                        2 / 2 (100%)

Time: 00:00.222, Memory: 28.00 MB

OK (2 tests, 4 assertions)

Btw, tentu kita tetap bisa menggunakan perintah symfony php bin/phpunit tanpa filter ya, hanya saja kalau mau spesifik menjalankan test tertentu, akan lebih baik dipilih mana yang akan dijalankan. Karena jika skenario totalnya banyak, jadinya boros.

Efisiensi lain yang bisa kita lakukan yaitu dengan menjalankan satu file testing saja, misalnya tests/Product/ReadTest.php.

symfony php bin/phpunit tests/Product/ReadTest.php

Jika ingin satu file dengan filter, bisa dengan seperti ini.

symfony php bin/phpunit tests/Product/ReadTest.php --filter testFindManyValidation

Atau mau lebih spesifik dengan menentukan data provider-nya, tambahkan setelah nama method-nya.

--filter testFindManyValidation@"page.greaterThanOrEqual(1)"

Begitu ya gaes… Dari Symfony cukup, lanjut ke Laravel.

Laravel

Kita ubah method testFindMany menjadi seperti ini.

// import ini
use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\Attributes\DataProvider;

// data provider yang digunakan
#[DataProvider('findManyProvider')]
public function testFindMany(array $request, callable $after): void
{
    $response = $this->json('GET', '/api/products', $request);
    $response->assertSuccessful();

    // after memanfaatkan response
    $after($response);
}

Nah, kalian bisa lihat ada perbedaan di penggunaan data provider-nya. PHPUnit 11 sudah menggunakan attribute, tidak lagi menggunakan annotation.

Lanjut data provider-nya, dengan skenario yang sama seperti di Symfony.

public static function findManyProvider(): \Generator
{
    yield 'default'
        => [
            [],
            function (TestResponse $response) {
                // dari response yang dikirim
                // buat assertion-nya
                $response->assertJson([
                    'currentPage' => 1,
                    'lastPage' => 1,
                    'pageSize' => 10,
                    'prevPage' => null,
                    'nextPage' => null,
                    'numResults' => 3,
                    'results' => [
                        [
                            'id' => 1,
                            'name' => 'Es Cokelat',
                            'price' => '7000.00',
                        ],
                        [
                            'id' => 2,
                            'name' => 'Es Thai Green Tea',
                            'price' => '7000.00',
                        ],
                        [
                            'id' => 3,
                            'name' => 'Es Thai Tea',
                            'price' => '7000.00',
                        ],
                    ],
                ]);
            },
        ];

    yield 'pageSize 20'
        => [
            ['pageSize' => 20],
            function (TestResponse $response) {
                $response->assertJson([
                    'currentPage' => 1,
                    'lastPage' => 1,
                    'pageSize' => 20,
                    'prevPage' => null,
                    'nextPage' => null,
                    'numResults' => 3,
                    'results' => [
                        [
                            'id' => 1,
                            'name' => 'Es Cokelat',
                            'price' => '7000.00',
                        ],
                        [
                            'id' => 2,
                            'name' => 'Es Thai Green Tea',
                            'price' => '7000.00',
                        ],
                        [
                            'id' => 3,
                            'name' => 'Es Thai Tea',
                            'price' => '7000.00',
                        ],
                    ],
                ]);
            },
        ];

    yield 'page 2'
        => [
            ['page' => 2],
            function (TestResponse $response) {
                $response->assertJson([
                    'currentPage' => 2,
                    'lastPage' => 1,
                    'pageSize' => 10,
                    'prevPage' => 1,
                    'nextPage' => null,
                    'numResults' => 3,
                    'results' => [],
                ]);
            },
        ];

    yield 'filter by name = cokelat'
        => [
            ['name' => 'cokelat'],
            function (TestResponse $response) {
                $response->assertJson([
                    'currentPage' => 1,
                    'lastPage' => 1,
                    'pageSize' => 10,
                    'prevPage' => null,
                    'nextPage' => null,
                    'numResults' => 1,
                    'results' => [
                        [
                            'id' => 1,
                            'name' => 'Es Cokelat',
                            'price' => '7000.00',
                        ],
                    ],
                ]);
            },
        ];

    yield 'filter by name = thai'
        => [
            ['name' => 'thai'],
            function (TestResponse $response) {
                $response->assertJson([
                    'currentPage' => 1,
                    'lastPage' => 1,
                    'pageSize' => 10,
                    'prevPage' => null,
                    'nextPage' => null,
                    'numResults' => 2,
                    'results' => [
                        [
                            'id' => 2,
                            'name' => 'Es Thai Green Tea',
                            'price' => '7000.00',
                        ],
                        [
                            'id' => 3,
                            'name' => 'Es Thai Tea',
                            'price' => '7000.00',
                        ],
                    ],
                ]);
            },
        ];

    yield 'filter by name = teh'
        => [
            ['name' => 'teh'],
            function (TestResponse $response) {
                $response->assertJson([
                    'currentPage' => 1,
                    'lastPage' => 0,
                    'pageSize' => 10,
                    'prevPage' => null,
                    'nextPage' => null,
                    'numResults' => 0,
                    'results' => [],
                ]);
            },
        ];
}

Sikat testing-nya gaes, kurang lebih hasilnya seperti ini.

PASS  Tests\Feature\Product\ReadTest
  ✓ find many with data set "default"                   0.71s
  ✓ find many with data set "page size 20"              0.02s
  ✓ find many with data set "page 2"                    0.02s
  ✓ find many with data set "filter by name = cokelat"  0.02s
  ✓ find many with data set "filter by name = thai"     0.02s
  ✓ find many with data set "filter by name = teh"      0.02s

  Tests:    6 passed (12 assertions)
  Duration: 0.85s

Waaa keren hasilnya detail 😋

Lanjut skenario validasinya ya gaes.

#[DataProvider('findManyValidationProvider')]
public function testFindManyValidation(array $request, string $violation): void
{
    $response = $this->json('GET', '/api/products', $request);
    $response->assertUnprocessable();
    $response->assertJson([
        'message' => $violation,
    ]);
}

public static function findManyValidationProvider(): \Generator
{
    yield 'pageSize.min:10'
        => [
            // query string dibuat salah
            ['pageSize' => 5],

            // pesan kesalahan yang diharapkan
            'The page size field must be at least 10.',
        ];

    yield 'page.min:1'
        => [
            ['page' => 0],
            'The page field must be at least 1.',
        ];
}

Btw di Laravel bisa pakai filter juga gak? 🤔

Bisa dongs… Perintah php artisan test pada dasarnya juga menggunakan PHPUnit, jadi pemanfaatannya juga akan sama seperti dengan perintah ./vendor/bin/phpunit. Kalian bisa coba2 sendiri ya gaes.

Kesimpulan

Gimana gaes?

Menarik, peran test ternyata sevital itu. Dia sebagai skrip, yang menceritakan skenario aplikasi. Dia juga sebagai kontrol, agar aplikasi tetap dalam skenario yang diharapkan. Artinya benar, bahwa ciri berkode serius adalah adanya testing. Semakin banyak skenario yang disediakan, semakin tinggi pula kadar keseriusan berkodenya. Keren… 🥳

Di sisi lain, antara Symfony dan Laravel, terlihat mencolok sekali perbedaannya. Senada dengan yang telah kamu sebutkan di awal, Laravel sangat cocok sekali untuk pemula, kesannya mudah dan tidak perlu persiapan yang rumit. Sedangkan Symfony, terlihat sekali bahwa dia didesain tidak hanya untuk menjadi framework, tapi juga untuk menjadi component yang bisa dimanfaatkan framework lain. Itu sebabnya, saat membutuhkan sesuatu kita harus instalasi component-nya terlebih dahulu.

Betul sekali… Tapi, mari kita lihat kenyataan kodenya. Buka class ProductFilteringRequest antara yang ada di Symfony dan Laravel.

<?php

namespace App\Request;

use Symfony\Component\Validator\Constraints as Assert;

final class ProductFilteringRequest
{
    public const DEFAULT_PAGE_SIZE = 10;
    public const DEFAULT_PAGE = 1;

    public function __construct(
        #[Assert\GreaterThanOrEqual(self::DEFAULT_PAGE_SIZE)]
        public readonly int $pageSize = self::DEFAULT_PAGE_SIZE,

        #[Assert\GreaterThanOrEqual(self::DEFAULT_PAGE)]
        public readonly int $page = self::DEFAULT_PAGE,

        public readonly ?string $name = null,
    ) {}
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

/**
 * @property int $pageSize
 * @property int $page
 * @property ?string $name
 */
class ProductFilteringRequest extends FormRequest
{
    public const DEFAULT_PAGE_SIZE = 10;
    public const DEFAULT_PAGE = 1;

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'pageSize' => [
                'nullable',
                'integer',
                'min:' . self::DEFAULT_PAGE_SIZE,
            ],
            'page' => [
                'nullable',
                'integer',
                'min:' . self::DEFAULT_PAGE,
            ],
            'name' => [
                'nullable',
            ],
        ];
    }

    protected function prepareForValidation()
    {
        $this->merge([
            'pageSize' => $this->pageSize ?? self::DEFAULT_PAGE_SIZE,
            'page' => $this->page ?? self::DEFAULT_PAGE,
        ]);
    }
}

Ohhhh nyatanya Laravel memiliki kerumitannya sendiri ya, dibandingkan Symfony 🤔

Singkatnya seperti itu. Tapi Laravel telah memiliki riwayat yang lebih panjang dalam hal class khusus untuk validasi daripada Symfony. Request mapper yang ada di Symfony baru tersedia di pertengahan tahun 2023 dan masih berkembang hingga sekarang. Sedangkan Laravel telah menyediakan form request di tahun 2016. Artinya, form request terdahulu memiliki andil dalam menentukan bentuk form request yang sekarang. Ingat bagaimana implementasi route di Laravel? Dari dulu ya begitu itu cara Laravel bermain route. Ini semacam struktur dasar bangunan, yang berperan dalam membentuk bangunan akan seperti apa ke depannya.

Hmmm paham2 🤔

Di sisi lain, Symfony ini lebih tua dari Laravel gaes. Struktur request mapper yang sekarang, telah ada sebelum form request lahir, yaitu melalui form atau entity. Jadi sebenarnya request mapper adalah kepanjangan fungsi dari struktur validasi yang sudah ada sejak dulu. Bentuk request mapper yang sederhana alias biasa saja tersebut, bukanlah hal baru.

Namanya komparasi begini ya, plus minus saling menampakkan diri 🤣

Woke cukup sekian ya gaes, silakan kalian eksplor lebih dalam terkait testing ini. Tunggu komparasi selanjutnya ☕️