Menunggu pukul 02:30 datang untuk mengetahui apakah tasks:prune benar bekerja bukanlah cara menguji perangkat lunak — itu cara menunggu kegagalan production. Pengujian jadwal harus memisahkan dua hal yang berbeda: logika task (query, kondisi, efek samping) dan mekanisme penjadwalan (cron, mutex, background) yang sudah menjadi tanggung jawab framework. Pada chapter penutup ini kita susun rangkaian test nyata untuk keduanya — command diuji langsung lewat artisan test, due-ness diuji dengan membekukan waktu lewat Carbon::setTestNow — dan jujur tentang batasnya: apa yang bisa dan tidak bisa diuji dari scheduler.

Filosofi: Pisahkan Logika dari Jadwal #

Strategi utamanya sudah kita latih sejak chapter sebelumnya: logika berat tinggal di kelas — command PruneCompletedTasks atau job SendDailyReport — sementara routes/console.php hanya menaruhnya di jadwal. Dengan struktur ini, 90% kebutuhan testing selesai tanpa menyentuh scheduler sama sekali, karena command adalah kelas biasa yang bisa dipanggil $this->artisan().

Yang tersisa khusus untuk scheduling hanya dua pertanyaan kecil: apakah jadwal terdaftar dengan benar, dan apakah task benar-benar due pada waktunya. Keduanya bisa diuji tanpa menunggu waktu nyata.

Menguji Command Terjadwal #

Command tasks:prune menghapus tugas berstatus selesai yang lebih tua dari batas hari. Feature test berikut membuat tiga skenario data — tugas selesai tua, tugas selesai baru, dan tugas pending — lalu menjalankan command dan memeriksa efeknya:

php
namespace Tests\Feature;

use App\Models\Task;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class TaskSchedulingTest extends TestCase
{
    use RefreshDatabase;

    #[Test]
    public function prune_menghapus_tugas_selesai_lama_saja(): void
    {
        $lama = Task::create([
            'title' => 'Kirim invoice Q3',
            'status' => 'selesai',
            'completed_at' => now()->subDays(40),
        ]);
        $baru = Task::create([
            'title' => 'Backup server',
            'status' => 'selesai',
            'completed_at' => now()->subDays(2),
        ]);
        $pending = Task::create([
            'title' => 'Belajar Laravel',
            'status' => 'pending',
        ]);

        $this->artisan('tasks:prune')
            ->expectsOutputToContain('1 tugas selesai lebih dari 30 hari dihapus.');

        $this->assertDatabaseMissing('tasks', ['id' => $lama->id]);
        $this->assertDatabaseHas('tasks', ['id' => $baru->id]);
        $this->assertDatabaseHas('tasks', ['id' => $pending->id]);
    }
}

Assertion berlapis tiga: output command tepat, tugas lama hilang, dan dua tugas lainnya selamat. Jalankan test-nya:

bash
php artisan test tests/Feature/TaskSchedulingTest.php

Output yang Dihasilkan:

text
  PASS  Tests\Feature\TaskSchedulingTest
  ✓  prune menghapus tugas selesai lama saja                             0.14s

  Tests:    1 passed (4 assertions)

Angka empat assertion berasal dari kombinasi expectsOutputToContain dan tiga assertion database — test framework menghitung keduanya dalam jumlah yang sama.

Membekukan Waktu: Menguji Due-ness #

Sekarang bagian yang spesifik scheduling: apakah task dieksekusi tepat pada jam jadwalnya? Triknya adalah Carbon::setTestNow() — karena scheduler mengevaluasi cron terhadap Carbon::now(), membekukan waktu membuat kita bisa “melompat” ke jam berapa pun. Jadwal yang diuji di routes/console.php:

php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schedule;

Artisan::command('inspire', function () {
    $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

Schedule::command('tasks:prune')->dailyAt('02:30')->name('prune-tugas');

Schedule::call(function () {
    Cache::put('laporan:terakhir-jalan', now()->toDateTimeString(), 300);
})->dailyAt('06:00')->name('tandai-laporan');

Dua test berikut memindahkan jam ke dua titik berbeda, menjalankan scheduler, lalu memeriksa efek sampingnya di cache:

php
#[Test]
public function tugas_terjadwal_jalan_saat_waktunya_tiba(): void
{
    Carbon::setTestNow('2026-10-13 06:00:00');

    $this->artisan('schedule:run');

    $this->assertSame('2026-10-13 06:00:00', Cache::get('laporan:terakhir-jalan'));
}

#[Test]
public function tugas_terjadwal_dilewati_di_luar_waktunya(): void
{
    Carbon::setTestNow('2026-10-13 07:15:00');

    $this->artisan('schedule:run');

    $this->assertNull(Cache::get('laporan:terakhir-jalan'));
}

Pada jam 06:00 closure tandai-laporan dieksekusi dan menulis stempel ke cache; pada 07:15 task yang sama dilewati karena cron 0 6 * * * tidak cocok. Hasil eksekusi seluruh rangkaian test-nya:

Output yang Dihasilkan:

text
  PASS  Tests\Feature\TaskSchedulingTest
  ✓  prune menghapus tugas selesai lama saja                             0.14s
  ✓  tugas terjadwal jalan saat waktunya tiba                            0.01s
  ✓  tugas terjadwal dilewati di luar waktunya                           0.01s

  Tests:    3 passed (6 assertions)

Memverifikasi Registrasi Jadwal #

Kesalahan klasik terakhir: jadwal lupa didefinisikan, atau ekspresinya salah ketik. Ini diuji dengan introspeksi langsung ke instance Schedule yang terdaftar di container — cukup jalankan satu command artisan apa pun terlebih dahulu agar routes/console.php dimuat:

php
#[Test]
public function jadwal_prune_terdaftar_di_aplikasi(): void
{
    $this->artisan('list'); // memicu pemuatan routes/console.php

    $events = collect(app(\Illuminate\Console\Scheduling\Schedule::class)->events());

    $this->assertTrue(
        $events->contains(fn ($event) => $event->expression === '30 2 * * *')
    );
}

Assertion memeriksa ekspresi cron hasil kompilasi dailyAt('02:30') — jika suatu hari seseorang tanpa sengaja mengubah jam jadwal, test ini memerah sebelum production yang merasakan.

Apa yang Tidak Bisa Difuji (dan Tidak Perlu) #

Berdasarkan pengecekan langsung pada sumber Illuminate\Support\Facades\Schedule di Laravel 13, facade ini tidak menyediakan fake() seperti Bus::fake() atau Queue::fake(). Pemisahan yang kita bangun sejak awal membuat hal itu bukan kehilangan: logika tinggal di kelas command/job yang teruji penuh, sementara due-ness tercakup oleh test setTestNow, dan registrasi tercakup oleh test introspeksi.

Yang tersisa di luar jangkauan test adalah perilaku infrastruktur nyata — mutex lintas proses, subproses shell, dan crontab di server. Untuk area itu, alat yang tepat bukan PHPUnit melainkan verifikasi operasional dari chapter sebelumnya: schedule:test --name=... untuk memicu task di staging, schedule:list --json untuk memeriksa ekspresi di production, dan heartbeat untuk memastikan cron hidup. Pola berlapis inilah yang membedakan jadwal yang “sepertinya jalan” dengan jadwal yang terbukti jalan.

Sepanjang section ini kita menempuh jalur lengkap task scheduling: dari konsep terpusat di routes/console.php, empat jenis task, frekuensi dan constraint, mutex withoutOverlapping(), eksekusi background, pemantauan berlapis, hingga pengujian dengan waktu yang dibekukan. Kemampuan menjadwalkan pekerjaan secara andal adalah penutup yang tepat sebelum melangkah ke topik lanjutan — pola-pola yang sama akan muncul kembali saat Anda menyusun strategi deployment, memantau aplikasi di production, dan merancang arsitektur yang teruji. Dokumentasi lengkapnya tetap menjadi rujukan utama: Task Scheduling dan Console Tests .