Unit test yang kita tulis sejauh ini mengisolasi satu class dengan menyingkirkan semua dependensinya. Pendekatan itu cepat dan presisi, tetapi punya titik buta: bug sering bersembunyi justru di sambungan antar komponen — query yang salah menulis nama kolom, nilai yang tidak lolos konversi tipe, atau format file yang tidak sesuai ekspektasi pembacanya. Integration test menutup titik buta tersebut dengan menggabungkan dua komponen atau lebih secara nyata, misalnya service bersama database SQLite sungguhan. Pada Chapter ini kita akan belajar di mana batas antara unit dan integration test, cara menyiapkan seam agar kode mudah diintegrasikan dalam test, serta cara menjaga suite yang lebih lambat ini tetap rapi.

Batas antara Unit Test dan Integration Test #

Integration test adalah test yang menggunakan dependensi asli untuk sebagian bagiannya, misalnya database, filesystem, atau service eksternal. Lawannya, unit test mengganti semua dependensi dengan test double sehingga hanya logika murni yang diperiksa. Keduanya bukan pesaing, melainkan pelapis dengan biaya dan jaminan yang berbeda:

AspekUnit testIntegration test
Cakupansatu class/fungsibeberapa komponen bekerja sama
Dependensitest doublesebagian asli (SQLite, file)
Kecepatanmilidetikbisa 10-100x lebih lambat
Mendeteksilogika yang salahsambungan yang salah (SQL, format, kontrak)

Aturan praktis yang baik: bug apa yang ingin kita tangkap menentukan jenis test yang ditulis. Kesalahan rumus diskon cukup ditangkap unit test, sedangkan kesalahan nama kolom pada query SELECT hanya akan terlihat ketika query itu benar-benar dijalankan terhadap skema database. Karena itu integration test biasanya menguji repository, service, dan controller — kode yang berdiri di depan dependensi nyata.

Apa yang Perlu Diintegrasikan #

Tiga kategori dependensi paling sering diajak integrasi. Pertama, database: repository diuji bersama skema asli, dengan SQLite untuk pengembangan lokal. Kedua, filesystem: kode yang menulis dan membaca berkas diuji terhadap berkas sementara yang dibersihkan setelah test selesai. Ketiga, service eksternal seperti API payment — untuk kategori ini kita justru menghindari panggilan jaringan sungguhan (dibahas pada bagian khusus di bawah).

Contoh untuk kategori filesystem terlihat pada ReportWriter berikut, yang menerima path berkas target dari luar:

php
<?php

declare(strict_types=1);

final class ReportWriter
{
    public function __construct(
        private readonly string $targetFile,
    ) {
    }

    public function tulis(array $baris): void
    {
        file_put_contents(
            $this->targetFile,
            implode("\n", $baris) . "\n",
            FILE_APPEND,
        );
    }

    public function baca(): array
    {
        $isi = file_get_contents($this->targetFile);

        return array_values(array_filter(explode("\n", (string) $isi), 'trim'));
    }
}

Integration test untuk ReportWriter memakai berkas sementara yang dibuat di setUp() dan dihapus di tearDown():

php
<?php

declare(strict_types=1);

use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;

#[Group('integration')]
final class ReportWriterIntegrationTest extends TestCase
{
    private string $targetFile;

    protected function setUp(): void
    {
        $this->targetFile = (string) tempnam(sys_get_temp_dir(), 'laporan-');
    }

    protected function tearDown(): void
    {
        @unlink($this->targetFile);
    }

    public function testMenulisLaluMembacaBarisLaporan(): void
    {
        $writer = new ReportWriter($this->targetFile);
        $writer->tulis(['Belajar PHP Dasar: 3 terjual', 'Mug Eksklusif: 5 terjual']);

        $this->assertSame(
            ['Belajar PHP Dasar: 3 terjual', 'Mug Eksklusif: 5 terjual'],
            $writer->baca(),
        );
    }
}

Dua hal penting dari pola di atas. Method tempnam() menghasilkan berkas unik di direktori sementara sistem, sehingga test paralel tidak saling menimpa; @unlink() di tearDown() membersihkannya kembali apa pun hasil testnya. Selain itu, attribute #[Group('integration')] menandai class ini sebagai anggota grup integration — perannya akan terlihat ketika kita mengatur eksekusi suite di bagian akhir Chapter.

Seam untuk Testability: Suntikkan Koneksi #

Syarat utama integration test yang sehat adalah seam: titik di mana dependensi bisa diganti dari luar. Bandingkan dua bentuk constructor repository berikut:

php
<?php

declare(strict_types=1);

// Anti-pattern: koneksi diciptakan di dalam, tidak bisa diganti
final class ProductRepositoryBuruk
{
    private PDO $connection;

    public function __construct()
    {
        $this->connection = new PDO('mysql:host=localhost;dbname=toko');
    }
}
php
<?php

declare(strict_types=1);

final class ProductRepository
{
    public function __construct(
        private readonly PDO $connection,
    ) {
    }

    public function save(string $nama, float $harga): int
    {
        $statement = $this->connection->prepare(
            'INSERT INTO produk (nama, harga) VALUES (:nama, :harga)',
        );
        $statement->execute(['nama' => $nama, 'harga' => $harga]);

        return (int) $this->connection->lastInsertId();
    }

    public function findByIds(array $ids): array
    {
        $placeholders = implode(',', array_fill(0, count($ids), '?'));
        $statement = $this->connection->prepare(
            "SELECT id, nama, harga FROM produk WHERE id IN ($placeholders) ORDER BY id",
        );
        $statement->execute(array_values($ids));

        return $statement->fetchAll();
    }

    public function maxHarga(): float
    {
        return (float) $this->connection
            ->query('SELECT MAX(harga) FROM produk')
            ->fetchColumn();
    }
}

Versi pertama mengunci database produksi di dalam constructor sehingga test dipaksa menyentuh MySQL sungguhan — lambat dan rapuh. Versi kedua menerima object PDO dari luar, sehingga test tinggal menyuntikkan koneksi SQLite mana pun. Perhatikan juga bahwa parameter ? pada findByIds() dibangun dari array_fill() agar jumlah placeholder selalu cocok dengan jumlah ID; menggabungkan ID mentah ke string SQL adalah pintu masuk SQL injection yang sudah kita bahas pada Section keamanan.

Integration Test Pertama: Service dan Database #

Setelah seam tersedia, kita bisa menguji CheckoutService dan ProductRepository sebagai satu kesatuan. Service ini menghitung total belanja dari ID produk — perilaku yang hanya terbukti benar jika query, skema, dan penjumlahannya bekerja bersama:

php
<?php

declare(strict_types=1);

final class CheckoutService
{
    public function __construct(
        private readonly ProductRepository $products,
    ) {
    }

    public function totalBelanja(array $idsProduk): float
    {
        if ($idsProduk === []) {
            return 0.0;
        }

        $total = 0.0;
        foreach ($this->products->findByIds($idsProduk) as $produk) {
            $total += (float) $produk['harga'];
        }

        return $total;
    }
}

Test-nya membuat database SQLite baru di memori untuk setiap test melalui setUp():

php
<?php

declare(strict_types=1);

use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;

#[Group('integration')]
final class CheckoutIntegrationTest extends TestCase
{
    private PDO $connection;

    protected function setUp(): void
    {
        $this->connection = new PDO('sqlite::memory:');
        $this->connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $this->connection->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
        $this->connection->exec(
            'CREATE TABLE produk (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                nama TEXT NOT NULL,
                harga REAL NOT NULL
            )',
        );
    }

    public function testTotalBelanjaMenjumlahkanHargaProdukNyata(): void
    {
        $repository = new ProductRepository($this->connection);
        $repository->save('Belajar PHP Dasar', 95000.0);
        $repository->save('Mug Eksklusif', 25000.0);

        $checkout = new CheckoutService($repository);

        $this->assertSame(120000.0, $checkout->totalBelanja([1, 2]));
    }

    public function testTotalBelanjaNolUntukKeranjangKosong(): void
    {
        $checkout = new CheckoutService(new ProductRepository($this->connection));

        $this->assertSame(0.0, $checkout->totalBelanja([]));
    }
}

Tiga keputusan desain pada setUp() menentukan kualitas test ini. Pertama, DSN sqlite::memory: membuat database yang hidup hanya di memori koneksi tersebut — tanpa berkas, tanpa server, tanpa pembersihan. Kedua, PDO::ERRMODE_EXCEPTION mengubah kesalahan SQL menjadi exception sehingga query yang salah langsung menggagalkan test, bukan diam-diam mengembalikan false. Ketiga, CREATE TABLE dijalankan ulang setiap test sehingga setiap test selalu mulai dari keadaan kosong yang sama; test pertama yang menyisakan data tidak akan pernah mencemari test kedua.

Database :memory: Benar-benar Terisolasi #

KLAIM bahwa setiap koneksi sqlite::memory: adalah database tersendiri layak dibuktikan, bukan sekadar dipercaya. Skrip kecil berikut membuat dua koneksi lalu memeriksa apakah keduanya melihat data yang sama:

php
<?php

declare(strict_types=1);

$koneksiA = new PDO('sqlite::memory:');
$koneksiA->exec('CREATE TABLE sesi (token TEXT NOT NULL)');
$koneksiA->exec("INSERT INTO sesi (token) VALUES ('abc123')");

echo 'Koneksi A: ', $koneksiA->query('SELECT COUNT(*) FROM sesi')->fetchColumn(), " baris\n";

$koneksiB = new PDO('sqlite::memory:');
try {
    $koneksiB->exec('SELECT COUNT(*) FROM sesi');
    echo "Koneksi B: tabel sesi terlihat\n";
} catch (PDOException $exception) {
    echo "Koneksi B: tabel sesi tidak terlihat\n";
}

Output yang Dihasilkan:

text
Koneksi A: 1 baris
Koneksi B: tabel sesi tidak terlihat

Koneksi A melihat satu baris yang barusan dibuatnya, sedangkan koneksi B bahkan tidak menemukan tabelnya — lengkap dengan PDOException karena tabel sesi tidak ada di dunianya. Isolasi inilah yang membuat pola “koneksi baru di setUp()” efektif: setiap test mendapat database bersih tanpa perlu membersihkan apa pun setelahnya. Satu konsekuensinya, object PDO tidak boleh dibagikan antar test lewat property static, karena seluruh test yang memakainya akan menumpuk data di database yang sama.

Menghindari Jaringan dalam Test #

Test yang memanggil API eksternal lewat jaringan sungguhan adalah sumber utama test flaky: kadang lulus, kadang gagal karena server lambat, kuota habis, atau respons berubah. Prinsipnya tegas — test otomatis tidak boleh bergantung pada jaringan. Solusinya adalah membiarkan service bergantung pada interface, lalu menyuntikkan fake di test:

php
<?php

declare(strict_types=1);

interface HttpClientInterface
{
    public function get(string $url): array;
}
php
<?php

declare(strict_types=1);

final class ExchangeRateService
{
    public function __construct(
        private readonly HttpClientInterface $client,
    ) {
    }

    public function kursUsdKeIdr(): float
    {
        $response = $this->client->get('https://api.example.com/rates/usd-idr');

        return (float) $response['body']['rate'];
    }
}
php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class FakeHttpClient implements HttpClientInterface
{
    public array $requestedUrls = [];

    public function __construct(private readonly array $stubbedResponse)
    {
    }

    public function get(string $url): array
    {
        $this->requestedUrls[] = $url;

        return $this->stubbedResponse;
    }
}

final class ExchangeRateServiceTest extends TestCase
{
    public function testKursDibacaDariResponsTiruan(): void
    {
        $client = new FakeHttpClient([
            'status' => 200,
            'body' => ['rate' => 15800],
        ]);
        $service = new ExchangeRateService($client);

        $this->assertSame(15800.0, $service->kursUsdKeIdr());
        $this->assertSame(
            ['https://api.example.com/rates/usd-idr'],
            $client->requestedUrls,
        );
    }
}

FakeHttpClient adalah fake tulisan tangan seperti pada Chapter test doubles : ia menyimpan respons tetap, mencatat setiap URL yang diminta, dan tidak menyentuh jaringan sama sekali. Test di atas berjalan dalam milidetik serta selalu deterministik, dan property requestedUrls masih memungkinkan kita memverifikasi URL yang dipanggil tanpa perlu mock framework. Resolusi pertanyaan “apakah integrasi dengan API pihak ketiga benar-benar berjalan” diserahkan pada sedikit end-to-end test yang kita bahas pada Chapter end-to-end testing .

Test Data Builder #

Integration test sering butuh data awal (fixture) yang agak panjang, dan baris-baris save() yang berulang membuat maksud test sulit terbaca. Test data builder memperbaikinya dengan menyediakan cara mendeklarasikan data secara deklaratif:

php
<?php

declare(strict_types=1);

final class ProductBuilder
{
    private string $nama = 'Belajar PHP Dasar';
    private float $harga = 95000.0;

    public static function aProduct(): self
    {
        return new self();
    }

    public function withNama(string $nama): self
    {
        $clone = clone $this;
        $clone->nama = $nama;

        return $clone;
    }

    public function withHarga(float $harga): self
    {
        $clone = clone $this;
        $clone->harga = $harga;

        return $clone;
    }

    public function insert(PDO $connection): int
    {
        $statement = $connection->prepare(
            'INSERT INTO produk (nama, harga) VALUES (:nama, :harga)',
        );
        $statement->execute(['nama' => $this->nama, 'harga' => $this->harga]);

        return (int) $connection->lastInsertId();
    }
}
php
<?php

declare(strict_types=1);

use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;

#[Group('integration')]
final class ProductReportIntegrationTest extends TestCase
{
    private PDO $connection;

    protected function setUp(): void
    {
        $this->connection = new PDO('sqlite::memory:');
        $this->connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $this->connection->exec(
            'CREATE TABLE produk (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                nama TEXT NOT NULL,
                harga REAL NOT NULL
            )',
        );
    }

    public function testProdukTermahalDitemukanDariDataSeeded(): void
    {
        ProductBuilder::aProduct()->withHarga(95000.0)->insert($this->connection);
        ProductBuilder::aProduct()
            ->withNama('Stiker Rajin Ngoding')
            ->withHarga(15000.0)
            ->insert($this->connection);

        $repository = new ProductRepository($this->connection);

        $this->assertSame(95000.0, $repository->maxHarga());
    }
}

Builder menyimpan nilai bawaan yang wajar, sehingga test hanya perlu menyebutkan bagian yang relevan untuk skenarionya — pada test di atas, harga dan nama produk kedua. Setiap method with...() mengembalikan salinan (via clone) agar satu builder bisa dipakai berkali-kali tanpa efek samping, dan method insert() menyembunyikan SQL sehingga test tetap bersih. Hasilnya, baris pertama test terbaca seperti kalimat: “buat satu produk (bawaan), lalu satu stiker seharga 15 ribu”.

Menjalankan Suite Cepat dan Lambat secara Terpisah #

Integration test yang sehat tetap lebih lambat dari unit test, jadi kita biasanya ingin phpunit sehari-hari hanya menjalankan bagian cepat. Berikut phpunit.xml yang mengecualikan grup integration dari eksekusi bawaan:

xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.5/phpunit.xsd"
         bootstrap="bootstrap.php">
    <testsuites>
        <testsuite name="unit">
            <directory>tests/unit</directory>
        </testsuite>
        <testsuite name="integration">
            <directory>tests/integration</directory>
        </testsuite>
    </testsuites>
    <groups>
        <exclude>
            <group>integration</group>
        </exclude>
    </groups>
</phpunit>

Dengan konfigurasi di atas, eksekusi bawaan hanya menjalankan test di luar grup integration:

bash
php /home/z/my-project/bin/phpunit.phar
text
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23
Configuration: /tmp/a96/integration/phpunit.xml

.                                                                   1 / 1 (100%)

Time: 00:00.001, Memory: 28.05 MB

OK (1 test, 2 assertions)

Untuk menjalankan bagian integration — misalnya sebelum push atau di pipeline CI — kita memilih grupnya secara eksplisit:

bash
php /home/z/my-project/bin/phpunit.phar --group integration
text
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23
Configuration: /tmp/a96/integration/phpunit.xml

....                                                                4 / 4 (100%)

Time: 00:00.004, Memory: 28.05 MB

OK (4 tests, 4 assertions)

Pemisahan ini memberi kita dua kecepatan: putaran umpan balik milidetik saat menulis kode, dan jaminan integrasi penuh saat dibutuhkan. Alternatif lain yang juga lazim adalah membuat berkas phpunit.xml terpisah per jenis suite dan memilihnya dengan -c; prinsipnya sama, hanya mekanismenya berbeda.

Ringkasan #

Integration test menguji sambungan antar komponen dengan dependensi asli: database SQLite di memori, berkas sementara dengan tempnam(), atau fake untuk service eksternal. Kuncinya ada pada seam — koneksi dan path disuntikkan dari luar — serta fixture yang selalu segar per test lewat setUp(). Test data builder menjaga keterbacaan fixture, sementara atribut #[Group('integration')] beserta konfigurasi pengecualian grup menjaga suite cepat tetap cepat. Pada Chapter berikutnya kita memperdalam dependensi yang paling sering diintegrasikan: database, lengkap dengan pola seeding, assertion hasil query, dan transaction rollback.

Referensi #