Aplikasi keuangan bisa dimaafkan bila tampilannya sederhana, tetapi tidak bila hitungannya salah — satu assertion yang gagal hari ini adalah satu laporan yang menipu penggunanya besok. Bagian ini menutup proyek dengan feature test yang membuktikan tiga lapis kebenaran sekaligus: aturan pencatatan (validasi dan kepemilikan), kebenaran hitungan laporan, sampai kebenaran visual batang grafik. Seluruh test berjalan lewat php artisan test dengan database SQLite dalam memori yang dimigrasi ulang untuk setiap test.

Strategi Pengujian #

Lima file test dibagi berdasarkan area yang dijaganya:

File testYang dibuktikan
AuthTesttamu dialihkan ke login; kredensial salah ditolak, yang benar diterima
ExampleTesthalaman depan meneruskan ke daftar transaksi
TransactionCrudTestCRUD penuh, nominal bulat positif, tanggal masa depan, konsistensi kategori, kepemilikan, filter bulan
CategoryTestCRUD kategori, warna hex valid, nama unik, jenis beku, penghapusan ditolak
ReportCalculationTestmatematika ringkasan, total per kategori, tren enam bulan, isi halaman laporan

Seluruh test memakai trait RefreshDatabase sehingga setiap test mulai dari skema bersih. Data dibuat eksplisit lewat create() dan createMany() — bukan factory acak — karena test keuangan adalah test angka: nominal 5.000.000 dan tanggal 2026-10-01 ditulis apa adanya agar skenario terbaca sebagai perhitungan yang bisa diperiksa manual, bukan konfigurasi factory yang harus ditelusuri ke file lain.

Menguji Hitungan Laporan #

ReportCalculationTest menanam sebelas transaksi tetap untuk tiga bulan, lalu menuntut hasil yang sudah dihitung kepala. Ringkasan Oktober adalah contohnya:

php
public function test_ringkasan_bulan_oktober(): void
{
    $budi = $this->seedBudi();

    $summary = app(ReportService::class)->monthlySummary($budi, '2026-10');

    $this->assertSame(5500000, $summary['income']);
    $this->assertSame(245000, $summary['expense']);
    $this->assertSame(5255000, $summary['balance']);
}

Angka-angka itu bisa diverifikasi siapa pun dari data yang ditanam: pemasukan Oktober terdiri dari gaji 5.000.000 dan bonus 500.000 (total 5.500.000), sedangkan pengeluarannya 25.000 + 45.000 + 60.000 (Makanan), 15.000 + 20.000 (Transportasi), dan 80.000 (Hiburan) — total 245.000, selisih 5.255.000. Test setara memeriksa total per kategori sampai urutan barisnya (Makanan 130.000, Hiburan 80.000, Transportasi 35.000), isi jendela tren enam bulan, dan isolasi antar-pengguna: transaksi Siti tidak pernah bocor ke laporan Budi. Bulan tanpa transaksi juga diuji mengembalikan tiga angka nol — inilah yang dijamin COALESCE pada query Bagian 4.

Menguji Batang Grafik #

Pengujian visual berhenti di satu hal yang paling berisiko: lebar batang. Test berikut membuka halaman laporan seperti pengguna sungguhan lalu memeriksa angka dan width yang dirender:

php
public function test_halaman_laporan_menampilkan_angka_dan_batang(): void
{
    $budi = $this->seedBudi();

    $response = $this->actingAs($budi)->get(route('reports.show', '2026-10'));

    $response->assertOk()
        ->assertSee('Laporan Oktober 2026')
        ->assertSee('Rp 5.500.000')
        ->assertSee('Rp 245.000')
        ->assertSee('Rp 5.255.000')
        // Lebar batang kategori: persentase dari total pengeluaran.
        ->assertSee('width: 53%')   // Makanan: 130.000 / 245.000
        ->assertSee('width: 33%')   // Hiburan: 80.000 / 245.000
        ->assertSee('width: 14%')   // Transportasi: 35.000 / 245.000
        // Lebar batang tren: persentase dari nilai terbesar (5.500.000).
        ->assertSee('width: 100%')  // Pemasukan Oktober.
        ->assertSee('width: 4%');   // Pengeluaran Oktober: 245.000 / 5.500.000.
}

Inilah buah dari keputusan memilih batang CSS di Bagian 5: assertSee('width: 53%') memeriksa bahwa presentasi, hitungan, dan render berjalan dalam satu request yang sama. Dua assertion terakhir bahkan menyentuh matematika lintas-bulan — 245.000 dari 5.500.000 memang 4% setelah dibulatkan.

Sisa test pada kedua file itu menjaga tepi-tepi yang sering dilupakan. test_bulan_tidak_valid_ditolak meminta /reports/2026-13 dan /reports/bulan-ini lalu menuntut assertNotFound() — memastikan dua lapis penjaga regex pada Bagian 5 benar-benar menahan URL rusak. Pada TransactionCrudTest, test transaksi pengguna lain tidak bisa diakses memeriksa tiga aksi (edit, update, delete) milik Budi dari sesi Siti: ketiganya harus assertForbidden(), daftar milik Siti tidak boleh assertSee('Catatan milik Budi'), dan baris Budi harus tetap utuh di database. Test filter bulan menanam dua transaksi di bulan berbeda lalu memastikan hanya satu yang tampil per filter.

Hasil Eksekusi Test #

bash
php artisan test

Output yang Dihasilkan:

text
  PASS  Tests\Unit\ExampleTest
  ✓ that true is true

  PASS  Tests\Feature\AuthTest
  ✓ tamu diarahkan ke halaman login                                     0.15s
  ✓ login berhasil dan gagal                                            0.23s

  PASS  Tests\Feature\CategoryTest
  ✓ bisa menambah kategori                                              0.02s
  ✓ warna harus kode hex enam digit                                     0.01s
  ✓ nama kategori harus unik                                            0.01s
  ✓ bisa mengubah kategori dengan nama sendiri                          0.01s
  ✓ jenis tidak bisa berubah setelah dipakai                            0.01s
  ✓ kategori dipakai tidak bisa dihapus                                 0.01s

  PASS  Tests\Feature\ExampleTest
  ✓ halaman depan meneruskan ke daftar transaksi                        0.01s

  PASS  Tests\Feature\ReportCalculationTest
  ✓ ringkasan bulan oktober                                             0.01s
  ✓ ringkasan bulan september                                           0.01s
  ✓ bulan tanpa transaksi menghasilkan nol                              0.01s
  ✓ total pengeluaran per kategori bulan oktober                        0.01s
  ✓ total pemasukan per kategori bulan oktober                          0.01s
  ✓ tren arus kas enam bulan                                            0.01s
  ✓ laporan hanya menghitung transaksi milik sendiri                    0.01s
  ✓ halaman laporan menampilkan angka dan batang                        0.02s
  ✓ bulan tidak valid ditolak                                           0.01s

  PASS  Tests\Feature\TransactionCrudTest
  ✓ bisa mencatat transaksi pengeluaran                                 0.02s
  ✓ bisa mencatat transaksi pemasukan                                   0.01s
  ✓ nominal wajib bulat dan minimal satu                                0.02s
  ✓ tanggal masa depan ditolak                                          0.01s
  ✓ kategori harus cocok dengan jenis transaksi                         0.01s
  ✓ bisa mengubah transaksi                                             0.01s
  ✓ bisa menghapus transaksi                                            0.01s
  ✓ transaksi pengguna lain tidak bisa diakses                          0.02s
  ✓ filter bulan pada daftar transaksi                                  0.01s

  Tests:    28 passed (103 assertions)
  Duration: 0.78s

Dua puluh delapan test dengan 103 assertion berlalu dalam waktu di bawah satu detik. Proyek pencatat keuangan tuntas: skema dua tabel berelasi, pencatatan yang divalidasi tiga lapis, kategori berwarna yang terlindungi, laporan yang hitungannya bisa diperiksa manual, grafik yang bisa diuji, dan rangkaian test yang menjaga semuanya tetap demikian. Dari sini pintu pengembangan terbuka — ekspor laporan ke PDF, anggaran bulanan per kategori, atau notifikasi saat pengeluaran melewati batas — dan setiap pintu tersebut kini punya jaring pengaman bernama php artisan test.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

app/Http/Controllers/AuthController.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class AuthController extends Controller
{
    public function showLogin(): View
    {
        return view('auth.login');
    }

    public function login(Request $request): RedirectResponse
    {
        $credentials = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required'],
        ]);

        if (Auth::attempt($credentials)) {
            $request->session()->regenerate();

            return redirect()->intended(route('transactions.index'));
        }

        return back()
            ->withErrors(['email' => 'Email atau password salah.'])
            ->onlyInput('email');
    }

    public function logout(Request $request): RedirectResponse
    {
        Auth::logout();

        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return redirect()->route('login');
    }
}

app/Http/Controllers/CategoryController.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\CategoryRequest;
use App\Models\Category;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;

class CategoryController extends Controller
{
    public function index(): View
    {
        $categories = Category::query()
            ->withCount('transactions')
            ->orderBy('type')
            ->orderBy('name')
            ->get();

        return view('categories.index', [
            'categories' => $categories,
        ]);
    }

    public function create(): View
    {
        return view('categories.create', [
            'category' => new Category([
                'type' => old('type', 'expense'),
                'color' => old('color', '#64748b'),
            ]),
        ]);
    }

    public function store(CategoryRequest $request): RedirectResponse
    {
        Category::create($request->validated());

        return redirect()
            ->route('categories.index')
            ->with('status', 'Kategori berhasil ditambahkan.');
    }

    public function edit(Category $category): View
    {
        return view('categories.edit', [
            'category' => $category,
        ]);
    }

    public function update(CategoryRequest $request, Category $category): RedirectResponse
    {
        // Jenis kategori tidak boleh berubah setelah dipakai transaksi,
        // karena setiap transaksi menyimpan salinan jenisnya sendiri.
        if ($category->transactions()->exists()
            && $request->validated('type') !== $category->type) {
            return back()
                ->withErrors(['type' => 'Jenis kategori tidak dapat diubah karena sudah dipakai transaksi.'])
                ->withInput();
        }

        $category->update($request->validated());

        return redirect()
            ->route('categories.index')
            ->with('status', 'Kategori berhasil diubah.');
    }

    public function destroy(Category $category): RedirectResponse
    {
        if ($category->transactions()->exists()) {
            return redirect()
                ->route('categories.index')
                ->withErrors(['name' => 'Kategori masih dipakai transaksi dan tidak dapat dihapus.']);
        }

        $category->delete();

        return redirect()
            ->route('categories.index')
            ->with('status', 'Kategori berhasil dihapus.');
    }
}

app/Http/Controllers/Controller.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

abstract class Controller
{
    //
}

app/Http/Controllers/ReportController.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

use App\Models\Transaction;
use App\Services\ReportService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;

class ReportController extends Controller
{
    public function __construct(private ReportService $reports)
    {
    }

    public function show(Request $request, string $month): View
    {
        if (! preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) {
            abort(404);
        }

        Carbon::setLocale('id');

        $user = $request->user();

        return view('reports.show', [
            'month' => $month,
            'title' => Carbon::parse($month . '-01')->translatedFormat('F Y'),
            'summary' => $this->reports->monthlySummary($user, $month),
            'expensesByCategory' => $this->reports->byCategory($user, $month, Transaction::TYPE_EXPENSE),
            'trend' => $this->reports->monthlyTrend($user, 6, $month),
            'previous' => Carbon::parse($month . '-01')->subMonth()->format('Y-m'),
            'next' => Carbon::parse($month . '-01')->addMonth()->format('Y-m'),
        ]);
    }

    /**
     * Laporan bulan berjalan: cukup teruskan ke /reports/{bulan-ini}.
     */
    public function current(): RedirectResponse
    {
        return redirect()->route('reports.show', now()->format('Y-m'));
    }
}

app/Http/Controllers/TransactionController.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\TransactionRequest;
use App\Models\Category;
use App\Models\Transaction;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class TransactionController extends Controller
{
    public function index(Request $request): View
    {
        // String kosong (filter dikosongkan) diperlakukan tanpa filter.
        $month = $request->input('month') ?: null;

        if ($month !== null && ! preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) {
            abort(404);
        }

        $transactions = Transaction::query()
            ->with('category')
            ->where('user_id', $request->user()->id)
            ->when($month, fn ($query) => $query->inMonth($month))
            ->orderByDesc('transaction_date')
            ->orderByDesc('id')
            ->paginate(10)
            ->withQueryString();

        return view('transactions.index', [
            'transactions' => $transactions,
            'month' => $month,
        ]);
    }

    public function create(Request $request): View
    {
        return view('transactions.create', [
            'transaction' => new Transaction(),
            'categories' => $this->categoriesForForm(),
            'type' => old('type', 'expense'),
        ]);
    }

    public function store(TransactionRequest $request): RedirectResponse
    {
        $request->user()->transactions()->create($request->validated());

        return redirect()
            ->route('transactions.index')
            ->with('status', 'Transaksi berhasil dicatat.');
    }

    public function edit(Request $request, Transaction $transaction): View
    {
        $this->authorizeOwner($request, $transaction);

        return view('transactions.edit', [
            'transaction' => $transaction,
            'categories' => $this->categoriesForForm(),
            'type' => old('type', $transaction->type),
        ]);
    }

    public function update(TransactionRequest $request, Transaction $transaction): RedirectResponse
    {
        $this->authorizeOwner($request, $transaction);

        $transaction->update($request->validated());

        return redirect()
            ->route('transactions.index')
            ->with('status', 'Transaksi berhasil diubah.');
    }

    public function destroy(Request $request, Transaction $transaction): RedirectResponse
    {
        $this->authorizeOwner($request, $transaction);

        $transaction->delete();

        return redirect()
            ->route('transactions.index')
            ->with('status', 'Transaksi berhasil dihapus.');
    }

    /**
     * Kategori dikelompokkan per jenis untuk select ber-optgroup.
     *
     * @return array<string, \Illuminate\Database\Eloquent\Collection<int, Category>>
     */
    private function categoriesForForm(): array
    {
        return [
            'income' => Category::query()->ofType(Category::TYPE_INCOME)->orderBy('name')->get(),
            'expense' => Category::query()->ofType(Category::TYPE_EXPENSE)->orderBy('name')->get(),
        ];
    }

    private function authorizeOwner(Request $request, Transaction $transaction): void
    {
        abort_if($transaction->user_id !== $request->user()->id, 403);
    }
}

app/Http/Requests/CategoryRequest.php

Unduh berkas
php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class CategoryRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Dipakai untuk store dan update; saat update, nama kategori
     * sendiri dikecualikan dari aturan unique.
     *
     * @return array<string, array<int, string|Rule>>
     */
    public function rules(): array
    {
        $unique = Rule::unique('categories', 'name');

        if ($category = $this->route('category')) {
            $unique->ignore($category);
        }

        return [
            'name' => ['required', 'string', 'max:60', $unique],
            'type' => ['required', 'in:income,expense'],
            'color' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'],
        ];
    }

    /**
     * @return array<string, string>
     */
    public function attributes(): array
    {
        return [
            'name' => 'nama kategori',
            'type' => 'jenis kategori',
            'color' => 'warna',
        ];
    }

    /**
     * @return array<string, string>
     */
    public function messages(): array
    {
        return [
            'color.regex' => 'Warna harus berupa kode hex 6 digit, contoh: #ef4444.',
        ];
    }
}

app/Http/Requests/TransactionRequest.php

Unduh berkas
php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class TransactionRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Aturan store dan update identik, jadi satu class cukup.
     *
     * @return array<string, array<int, string|Rule>>
     */
    public function rules(): array
    {
        return [
            'category_id' => [
                'required',
                'integer',
                // Kategori harus tersedia DAN jenisnya cocok dengan
                // jenis transaksi yang dipilih (income/expense).
                Rule::exists('categories', 'id')->where('type', $this->input('type')),
            ],
            'type' => ['required', 'in:income,expense'],
            'amount' => ['required', 'integer', 'min:1', 'max:999999999999'],
            'transaction_date' => [
                'required',
                'date',
                'after:2000-01-01',
                'before_or_equal:today',
            ],
            'note' => ['nullable', 'string', 'max:255'],
        ];
    }

    /**
     * @return array<string, string>
     */
    public function attributes(): array
    {
        return [
            'category_id' => 'kategori',
            'type' => 'jenis transaksi',
            'amount' => 'nominal',
            'transaction_date' => 'tanggal transaksi',
            'note' => 'catatan',
        ];
    }

    /**
     * @return array<string, string>
     */
    public function messages(): array
    {
        return [
            'category_id.exists' => 'Kategori tidak ditemukan atau tidak cocok dengan jenis transaksi.',
        ];
    }
}

app/Models/Category.php

Unduh berkas
php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Category extends Model
{
    public const TYPE_INCOME = 'income';

    public const TYPE_EXPENSE = 'expense';

    protected $fillable = [
        'name',
        'type',
        'color',
    ];

    protected function casts(): array
    {
        return [
            'created_at' => 'datetime',
            'updated_at' => 'datetime',
        ];
    }

    public function transactions(): HasMany
    {
        return $this->hasMany(Transaction::class);
    }

    /**
     * Ambil kategori untuk satu jenis transaksi saja (income/expense).
     */
    public function scopeOfType(Builder $query, string $type): Builder
    {
        return $query->where('type', $type);
    }
}

app/Models/Transaction.php

Unduh berkas
php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;

class Transaction extends Model
{
    public const TYPE_INCOME = 'income';

    public const TYPE_EXPENSE = 'expense';

    protected $fillable = [
        'category_id',
        'type',
        'amount',
        'transaction_date',
        'note',
    ];

    protected function casts(): array
    {
        return [
            'category_id' => 'integer',
            'user_id' => 'integer',
            'amount' => 'integer',
            'transaction_date' => 'date:Y-m-d',
        ];
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

    /**
     * Batasi query pada satu bulan (format Y-m, contoh: 2026-10).
     */
    public function scopeInMonth(Builder $query, string $month): Builder
    {
        $start = Carbon::parse($month . '-01');

        return $query->whereBetween('transaction_date', [
            $start->toDateString(),
            $start->copy()->endOfMonth()->toDateString(),
        ]);
    }
}

app/Models/User.php

Unduh berkas
php
<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
    /** @use HasFactory<UserFactory> */
    use HasFactory, Notifiable;

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    public function transactions(): HasMany
    {
        return $this->hasMany(Transaction::class);
    }
}

app/Services/ReportService.php

Unduh berkas
php
<?php

namespace App\Services;

use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class ReportService
{
    /**
     * Ringkasan satu bulan: total pemasukan, pengeluaran, dan selisihnya.
     *
     * @return array{income: int, expense: int, balance: int}
     */
    public function monthlySummary(User $user, string $month): array
    {
        $start = Carbon::parse($month . '-01');

        $row = DB::table('transactions')
            ->where('user_id', $user->id)
            ->whereBetween('transaction_date', [
                $start->toDateString(),
                $start->copy()->endOfMonth()->toDateString(),
            ])
            ->selectRaw("COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) AS income")
            ->selectRaw("COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) AS expense")
            ->first();

        $income = (int) $row->income;
        $expense = (int) $row->expense;

        return [
            'income' => $income,
            'expense' => $expense,
            'balance' => $income - $expense,
        ];
    }

    /**
     * Total per kategori untuk satu bulan dan satu jenis transaksi,
     * diurutkan dari total terbesar.
     *
     * @return \Illuminate\Support\Collection<int, array{id: int, name: string, color: string, total: int}>
     */
    public function byCategory(User $user, string $month, string $type): Collection
    {
        $start = Carbon::parse($month . '-01');

        return DB::table('transactions')
            ->join('categories', 'categories.id', '=', 'transactions.category_id')
            ->where('transactions.user_id', $user->id)
            ->where('transactions.type', $type)
            ->whereBetween('transaction_date', [
                $start->toDateString(),
                $start->copy()->endOfMonth()->toDateString(),
            ])
            ->groupBy('categories.id', 'categories.name', 'categories.color')
            ->selectRaw('categories.id AS id')
            ->selectRaw('categories.name AS name')
            ->selectRaw('categories.color AS color')
            ->selectRaw('SUM(transactions.amount) AS total')
            ->orderByDesc('total')
            ->get()
            ->map(fn (object $row) => [
                'id' => (int) $row->id,
                'name' => $row->name,
                'color' => $row->color,
                'total' => (int) $row->total,
            ]);
    }

    /**
     * Tren arus kas N bulan terakhir sampai (dan termasuk) $untilMonth.
     *
     * @return \Illuminate\Support\Collection<int, array{month: string, label: string, income: int, expense: int}>
     */
    public function monthlyTrend(User $user, int $months = 6, ?string $untilMonth = null): Collection
    {
        $until = Carbon::parse(($untilMonth ?? now()->format('Y-m')) . '-01')->endOfMonth();
        $from = $until->copy()->startOfMonth()->subMonths($months - 1);

        $totals = DB::table('transactions')
            ->where('user_id', $user->id)
            ->whereBetween('transaction_date', [$from->toDateString(), $until->toDateString()])
            ->groupBy(DB::raw('substr(transaction_date, 1, 7)'))
            ->selectRaw("substr(transaction_date, 1, 7) AS month")
            ->selectRaw("COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) AS income")
            ->selectRaw("COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) AS expense")
            ->get()
            ->keyBy('month');

        return Collection::times($months, function (int $i) use ($from, $totals) {
            $cursor = $from->copy()->addMonths($i - 1);
            $key = $cursor->format('Y-m');
            $row = $totals->get($key);

            return [
                'month' => $key,
                'label' => $cursor->translatedFormat('M Y'),
                'income' => (int) ($row->income ?? 0),
                'expense' => (int) ($row->expense ?? 0),
            ];
        });
    }
}

app/Support/Rupiah.php

Unduh berkas
php
<?php

namespace App\Support;

/**
 * Format angka rupiah: selalu bulat (tanpa sen), pemisah ribuan titik.
 */
final class Rupiah
{
    public static function format(int $amount): string
    {
        return 'Rp ' . number_format($amount, 0, ',', '.');
    }
}

database/migrations/2026_11_01_000010_create_categories_table.php

Unduh berkas
php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('categories', function (Blueprint $table) {
            $table->id();
            $table->string('name', 60)->unique();
            $table->enum('type', ['income', 'expense']);
            $table->char('color', 7)->default('#64748b');
            $table->timestamps();
            $table->index(['type', 'name']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('categories');
    }
};

database/migrations/2026_11_01_000020_create_transactions_table.php

Unduh berkas
php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('transactions', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')
                ->constrained()
                ->cascadeOnDelete();
            $table->foreignId('category_id')
                ->constrained()
                ->restrictOnDelete();
            $table->decimal('amount', 14, 0);
            $table->enum('type', ['income', 'expense']);
            $table->date('transaction_date');
            $table->string('note')->nullable();
            $table->timestamps();
            $table->index(['user_id', 'transaction_date']);
            $table->index(['category_id', 'transaction_date']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('transactions');
    }
};

database/seeders/DatabaseSeeder.php

Unduh berkas
php
<?php

namespace Database\Seeders;

use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $budi = User::create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
            'password' => Hash::make('password'),
        ]);

        $categories = [
            'Gaji' => ['type' => 'income', 'color' => '#16a34a'],
            'Bonus' => ['type' => 'income', 'color' => '#22c55e'],
            'Makanan' => ['type' => 'expense', 'color' => '#ef4444'],
            'Transportasi' => ['type' => 'expense', 'color' => '#f97316'],
            'Hiburan' => ['type' => 'expense', 'color' => '#a855f7'],
        ];

        foreach ($categories as $name => $attributes) {
            Category::create(['name' => $name] + $attributes);
        }

        $rows = [
            // [kategori, jenis, nominal, tanggal, catatan]
            ['Gaji', 'income', 5000000, '2026-10-01', 'Gaji bulan Oktober'],
            ['Bonus', 'income', 500000, '2026-10-10', 'Bonus proyek aplikasi'],
            ['Makanan', 'expense', 25000, '2026-10-04', 'Makan siang di kantin'],
            ['Makanan', 'expense', 45000, '2026-10-07', 'Kopi dan camilan'],
            ['Makanan', 'expense', 60000, '2026-10-12', 'Belanja bahan makanan'],
            ['Transportasi', 'expense', 15000, '2026-10-03', 'Ojek ke kantor'],
            ['Transportasi', 'expense', 20000, '2026-10-09', 'Isi bensin motor'],
            ['Hiburan', 'expense', 80000, '2026-10-11', 'Tiket bioskop akhir pekan'],
            ['Gaji', 'income', 4800000, '2026-09-01', 'Gaji bulan September'],
            ['Makanan', 'expense', 40000, '2026-09-16', 'Makan malam bersama teman'],
            ['Makanan', 'expense', 30000, '2026-08-02', 'Camilan akhir bulan'],
        ];

        foreach ($rows as [$categoryName, $type, $amount, $date, $note]) {
            $budi->transactions()->create([
                'category_id' => Category::where('name', $categoryName)->value('id'),
                'type' => $type,
                'amount' => $amount,
                'transaction_date' => $date,
                'note' => $note,
            ]);
        }

        $this->command?->info('Transaksi terisi: ' . Transaction::count());
    }
}

routes/web.php

Unduh berkas
php
<?php

use App\Http\Controllers\AuthController;
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\ReportController;
use App\Http\Controllers\TransactionController;
use Illuminate\Support\Facades\Route;

Route::get('/', fn () => redirect()->route('transactions.index'))->name('home');

Route::middleware('guest')->group(function () {
    Route::get('login', [AuthController::class, 'showLogin'])->name('login');
    Route::post('login', [AuthController::class, 'login'])->name('login.attempt');
});

Route::post('logout', [AuthController::class, 'logout'])
    ->middleware('auth')
    ->name('logout');

Route::middleware('auth')->group(function () {
    Route::resource('transactions', TransactionController::class);

    Route::resource('categories', CategoryController::class)
        ->except('show');

    Route::get('reports', [ReportController::class, 'current'])->name('reports.index');
    Route::get('reports/{month}', [ReportController::class, 'show'])
        ->where('month', '[0-9]{4}-[0-9]{2}')
        ->name('reports.show');
});

tests/Feature/AuthTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class AuthTest extends TestCase
{
    use RefreshDatabase;

    public function test_tamu_diarahkan_ke_halaman_login(): void
    {
        $this->get(route('transactions.index'))
            ->assertRedirect(route('login'));
    }

    public function test_login_berhasil_dan_gagal(): void
    {
        User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
            'password' => bcrypt('password-rahasia'),
        ]);

        // Kredensial salah: kembali dengan error.
        $this->from(route('login'))
            ->post(route('login.attempt'), [
                'email' => '[email protected]',
                'password' => 'salah',
            ])
            ->assertSessionHasErrors('email');

        // Kredensial benar: masuk dan diarahkan ke daftar transaksi.
        $this->post(route('login.attempt'), [
            'email' => '[email protected]',
            'password' => 'password-rahasia',
        ])
        ->assertRedirect(route('transactions.index'));

        $this->assertAuthenticated();
    }
}

tests/Feature/CategoryTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use App\Models\Category;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class CategoryTest extends TestCase
{
    use RefreshDatabase;

    public function test_bisa_menambah_kategori(): void
    {
        $user = User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
        ]);

        $response = $this->actingAs($user)->post(route('categories.store'), [
            'name' => 'Transportasi',
            'type' => 'expense',
            'color' => '#f97316',
        ]);

        $response->assertRedirect(route('categories.index'));
        $this->assertDatabaseHas('categories', [
            'name' => 'Transportasi',
            'type' => 'expense',
            'color' => '#f97316',
        ]);
    }

    public function test_warna_harus_kode_hex_enam_digit(): void
    {
        $user = User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
        ]);

        foreach (['merah', '#12345', '#zzzzzz'] as $invalidColor) {
            $this->actingAs($user)->post(route('categories.store'), [
                'name' => 'Kategori Uji',
                'type' => 'expense',
                'color' => $invalidColor,
            ])->assertSessionHasErrors('color');
        }

        $this->assertDatabaseCount('categories', 0);
    }

    public function test_nama_kategori_harus_unik(): void
    {
        $user = User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
        ]);

        Category::create([
            'name' => 'Makanan',
            'type' => 'expense',
            'color' => '#ef4444',
        ]);

        $this->actingAs($user)->post(route('categories.store'), [
            'name' => 'Makanan',
            'type' => 'expense',
            'color' => '#ef4444',
        ])->assertSessionHasErrors('name');

        $this->assertDatabaseCount('categories', 1);
    }

    public function test_bisa_mengubah_kategori_dengan_nama_sendiri(): void
    {
        $user = User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
        ]);

        $category = Category::create([
            'name' => 'Makanan',
            'type' => 'expense',
            'color' => '#ef4444',
        ]);

        // Nama yang sama tetap boleh: aturan unique mengabaikan baris sendiri.
        $this->actingAs($user)
            ->put(route('categories.update', $category), [
                'name' => 'Makanan',
                'type' => 'expense',
                'color' => '#dc2626',
            ])
            ->assertRedirect(route('categories.index'));

        $this->assertDatabaseHas('categories', [
            'id' => $category->id,
            'name' => 'Makanan',
            'color' => '#dc2626',
        ]);
    }

    public function test_jenis_tidak_bisa_berubah_setelah_dipakai(): void
    {
        $user = User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
        ]);

        $category = Category::create([
            'name' => 'Makanan',
            'type' => 'expense',
            'color' => '#ef4444',
        ]);

        $user->transactions()->create([
            'category_id' => $category->id,
            'type' => 'expense',
            'amount' => 45000,
            'transaction_date' => '2026-10-05',
        ]);

        $this->actingAs($user)
            ->put(route('categories.update', $category), [
                'name' => 'Makanan',
                'type' => 'income',
                'color' => '#ef4444',
            ])
            ->assertSessionHasErrors('type');

        $this->assertDatabaseHas('categories', [
            'id' => $category->id,
            'type' => 'expense',
        ]);
    }

    public function test_kategori_dipakai_tidak_bisa_dihapus(): void
    {
        $user = User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
        ]);

        $used = Category::create([
            'name' => 'Makanan',
            'type' => 'expense',
            'color' => '#ef4444',
        ]);
        $unused = Category::create([
            'name' => 'Hiburan',
            'type' => 'expense',
            'color' => '#a855f7',
        ]);

        $user->transactions()->create([
            'category_id' => $used->id,
            'type' => 'expense',
            'amount' => 45000,
            'transaction_date' => '2026-10-05',
        ]);

        $this->actingAs($user)
            ->delete(route('categories.destroy', $used))
            ->assertRedirect(route('categories.index'))
            ->assertSessionHasErrors('name');

        $this->assertDatabaseHas('categories', ['id' => $used->id]);

        // Kategori tanpa transaksi boleh dihapus.
        $this->actingAs($user)
            ->delete(route('categories.destroy', $unused))
            ->assertRedirect(route('categories.index'));

        $this->assertDatabaseMissing('categories', ['id' => $unused->id]);
    }
}

tests/Feature/ExampleTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use Tests\TestCase;

class ExampleTest extends TestCase
{
    /**
     * Halaman depan cukup meneruskan pengunjung ke daftar transaksi;
     * dari sana middleware auth yang meneruskan tamu ke halaman login.
     */
    public function test_halaman_depan_meneruskan_ke_daftar_transaksi(): void
    {
        $this->get('/')
            ->assertRedirect(route('transactions.index'));
    }
}

tests/Feature/ReportCalculationTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use App\Models\Category;
use App\Models\User;
use App\Services\ReportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ReportCalculationTest extends TestCase
{
    use RefreshDatabase;

    /**
     * Data tetap dengan angka yang mudah dihitung manual.
     *
     * Oktober 2026 (milik Budi):
     * - Pemasukan: Gaji 5.000.000 + Bonus 500.000            = 5.500.000
     * - Pengeluaran: Makanan 130.000, Hiburan 80.000,
     *   Transportasi 35.000                                    = 245.000
     * - Selisih                                                 = 5.255.000
     */
    private function seedBudi(): User
    {
        $budi = User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
        ]);

        $gaji = Category::create(['name' => 'Gaji', 'type' => 'income', 'color' => '#16a34a']);
        $bonus = Category::create(['name' => 'Bonus', 'type' => 'income', 'color' => '#22c55e']);
        $makanan = Category::create(['name' => 'Makanan', 'type' => 'expense', 'color' => '#ef4444']);
        $transportasi = Category::create(['name' => 'Transportasi', 'type' => 'expense', 'color' => '#f97316']);
        $hiburan = Category::create(['name' => 'Hiburan', 'type' => 'expense', 'color' => '#a855f7']);

        $budi->transactions()->createMany([
            ['category_id' => $gaji->id, 'type' => 'income', 'amount' => 5000000, 'transaction_date' => '2026-10-01', 'note' => 'Gaji bulan Oktober'],
            ['category_id' => $bonus->id, 'type' => 'income', 'amount' => 500000, 'transaction_date' => '2026-10-10', 'note' => 'Bonus proyek aplikasi'],
            ['category_id' => $makanan->id, 'type' => 'expense', 'amount' => 25000, 'transaction_date' => '2026-10-04', 'note' => 'Makan siang di kantin'],
            ['category_id' => $makanan->id, 'type' => 'expense', 'amount' => 45000, 'transaction_date' => '2026-10-07', 'note' => 'Kopi dan camilan'],
            ['category_id' => $makanan->id, 'type' => 'expense', 'amount' => 60000, 'transaction_date' => '2026-10-12', 'note' => 'Belanja bahan makanan'],
            ['category_id' => $transportasi->id, 'type' => 'expense', 'amount' => 15000, 'transaction_date' => '2026-10-03', 'note' => 'Ojek ke kantor'],
            ['category_id' => $transportasi->id, 'type' => 'expense', 'amount' => 20000, 'transaction_date' => '2026-10-09', 'note' => 'Isi bensin motor'],
            ['category_id' => $hiburan->id, 'type' => 'expense', 'amount' => 80000, 'transaction_date' => '2026-10-11', 'note' => 'Tiket bioskop akhir pekan'],
            ['category_id' => $gaji->id, 'type' => 'income', 'amount' => 4800000, 'transaction_date' => '2026-09-01', 'note' => 'Gaji bulan September'],
            ['category_id' => $makanan->id, 'type' => 'expense', 'amount' => 40000, 'transaction_date' => '2026-09-16', 'note' => 'Makan malam bersama teman'],
            ['category_id' => $makanan->id, 'type' => 'expense', 'amount' => 30000, 'transaction_date' => '2026-08-02', 'note' => 'Camilan akhir bulan'],
        ]);

        return $budi;
    }

    public function test_ringkasan_bulan_oktober(): void
    {
        $budi = $this->seedBudi();

        $summary = app(ReportService::class)->monthlySummary($budi, '2026-10');

        $this->assertSame(5500000, $summary['income']);
        $this->assertSame(245000, $summary['expense']);
        $this->assertSame(5255000, $summary['balance']);
    }

    public function test_ringkasan_bulan_september(): void
    {
        $budi = $this->seedBudi();

        $summary = app(ReportService::class)->monthlySummary($budi, '2026-09');

        $this->assertSame(4800000, $summary['income']);
        $this->assertSame(40000, $summary['expense']);
        $this->assertSame(4760000, $summary['balance']);
    }

    public function test_bulan_tanpa_transaksi_menghasilkan_nol(): void
    {
        $budi = $this->seedBudi();

        $summary = app(ReportService::class)->monthlySummary($budi, '2025-01');

        $this->assertSame(0, $summary['income']);
        $this->assertSame(0, $summary['expense']);
        $this->assertSame(0, $summary['balance']);
    }

    public function test_total_pengeluaran_per_kategori_bulan_oktober(): void
    {
        $budi = $this->seedBudi();

        $rows = app(ReportService::class)
            ->byCategory($budi, '2026-10', 'expense');

        $this->assertSame(
            [
                ['id' => 3, 'name' => 'Makanan', 'color' => '#ef4444', 'total' => 130000],
                ['id' => 5, 'name' => 'Hiburan', 'color' => '#a855f7', 'total' => 80000],
                ['id' => 4, 'name' => 'Transportasi', 'color' => '#f97316', 'total' => 35000],
            ],
            $rows->all(),
        );
    }

    public function test_total_pemasukan_per_kategori_bulan_oktober(): void
    {
        $budi = $this->seedBudi();

        $rows = app(ReportService::class)
            ->byCategory($budi, '2026-10', 'income');

        $this->assertSame(
            [
                ['id' => 1, 'name' => 'Gaji', 'color' => '#16a34a', 'total' => 5000000],
                ['id' => 2, 'name' => 'Bonus', 'color' => '#22c55e', 'total' => 500000],
            ],
            $rows->all(),
        );
    }

    public function test_tren_arus_kas_enam_bulan(): void
    {
        $budi = $this->seedBudi();

        $trend = app(ReportService::class)
            ->monthlyTrend($budi, 6, '2026-10');

        $this->assertSame(
            ['2026-05', '2026-06', '2026-07', '2026-08', '2026-09', '2026-10'],
            $trend->pluck('month')->all(),
        );

        $byMonth = $trend->keyBy('month');

        $this->assertSame(0, $byMonth['2026-05']['income']);
        $this->assertSame(0, $byMonth['2026-05']['expense']);
        $this->assertSame(30000, $byMonth['2026-08']['expense']);
        $this->assertSame(4800000, $byMonth['2026-09']['income']);
        $this->assertSame(40000, $byMonth['2026-09']['expense']);
        $this->assertSame(5500000, $byMonth['2026-10']['income']);
        $this->assertSame(245000, $byMonth['2026-10']['expense']);
    }

    public function test_laporan_hanya_menghitung_transaksi_milik_sendiri(): void
    {
        $budi = $this->seedBudi();

        $siti = User::factory()->create([
            'name' => 'Siti Rahayu',
            'email' => '[email protected]',
        ]);

        $gaji = Category::where('name', 'Gaji')->first();

        $siti->transactions()->create([
            'category_id' => $gaji->id,
            'type' => 'income',
            'amount' => 2000000,
            'transaction_date' => '2026-10-02',
        ]);

        $service = app(ReportService::class);

        // Laporan Budi tidak berubah oleh transaksi Siti.
        $this->assertSame(5500000, $service->monthlySummary($budi, '2026-10')['income']);

        // Laporan Siti hanya berisi transaksinya sendiri.
        $this->assertSame(2000000, $service->monthlySummary($siti, '2026-10')['income']);
        $this->assertSame(0, $service->monthlySummary($siti, '2026-10')['expense']);
    }

    public function test_halaman_laporan_menampilkan_angka_dan_batang(): void
    {
        $budi = $this->seedBudi();

        $response = $this->actingAs($budi)->get(route('reports.show', '2026-10'));

        $response->assertOk()
            ->assertSee('Laporan Oktober 2026')
            ->assertSee('Rp 5.500.000')
            ->assertSee('Rp 245.000')
            ->assertSee('Rp 5.255.000')
            // Lebar batang kategori: persentase dari total pengeluaran.
            ->assertSee('width: 53%')   // Makanan: 130.000 / 245.000
            ->assertSee('width: 33%')   // Hiburan: 80.000 / 245.000
            ->assertSee('width: 14%')   // Transportasi: 35.000 / 245.000
            // Lebar batang tren: persentase dari nilai terbesar (5.500.000).
            ->assertSee('width: 100%')  // Pemasukan Oktober.
            ->assertSee('width: 4%');   // Pengeluaran Oktober: 245.000 / 5.500.000.
    }

    public function test_bulan_tidak_valid_ditolak(): void
    {
        $budi = $this->seedBudi();

        $this->actingAs($budi)->get('/reports/2026-13')->assertNotFound();
        $this->actingAs($budi)->get('/reports/bulan-ini')->assertNotFound();
    }
}

tests/Feature/TransactionCrudTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class TransactionCrudTest extends TestCase
{
    use RefreshDatabase;

    private function user(): User
    {
        return User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
        ]);
    }

    /**
     * @return array<string, Category>
     */
    private function categories(): array
    {
        return [
            'income' => Category::create([
                'name' => 'Gaji',
                'type' => 'income',
                'color' => '#16a34a',
            ]),
            'expense' => Category::create([
                'name' => 'Makanan',
                'type' => 'expense',
                'color' => '#ef4444',
            ]),
        ];
    }

    public function test_bisa_mencatat_transaksi_pengeluaran(): void
    {
        $user = $this->user();
        $categories = $this->categories();

        $response = $this->actingAs($user)->post(route('transactions.store'), [
            'category_id' => $categories['expense']->id,
            'type' => 'expense',
            'amount' => 45000,
            'transaction_date' => '2026-10-05',
            'note' => 'Makan siang di kantin',
        ]);

        $response->assertRedirect(route('transactions.index'));
        $this->assertDatabaseHas('transactions', [
            'user_id' => $user->id,
            'category_id' => $categories['expense']->id,
            'type' => 'expense',
            'amount' => 45000,
            'transaction_date' => '2026-10-05',
            'note' => 'Makan siang di kantin',
        ]);
    }

    public function test_bisa_mencatat_transaksi_pemasukan(): void
    {
        $user = $this->user();
        $categories = $this->categories();

        $this->actingAs($user)->post(route('transactions.store'), [
            'category_id' => $categories['income']->id,
            'type' => 'income',
            'amount' => 5000000,
            'transaction_date' => '2026-10-01',
            'note' => 'Gaji bulan Oktober',
        ]);

        $this->assertDatabaseHas('transactions', [
            'user_id' => $user->id,
            'type' => 'income',
            'amount' => 5000000,
        ]);
    }

    public function test_nominal_wajib_bulat_dan_minimal_satu(): void
    {
        $user = $this->user();
        $categories = $this->categories();

        foreach ([0, -100, 2500.5] as $invalidAmount) {
            $this->actingAs($user)->post(route('transactions.store'), [
                'category_id' => $categories['expense']->id,
                'type' => 'expense',
                'amount' => $invalidAmount,
                'transaction_date' => '2026-10-05',
                'note' => 'Nominal tidak valid',
            ])->assertSessionHasErrors('amount');
        }

        $this->assertDatabaseCount('transactions', 0);
    }

    public function test_tanggal_masa_depan_ditolak(): void
    {
        $user = $this->user();
        $categories = $this->categories();

        $this->actingAs($user)->post(route('transactions.store'), [
            'category_id' => $categories['expense']->id,
            'type' => 'expense',
            'amount' => 15000,
            'transaction_date' => now()->addDay()->toDateString(),
            'note' => 'Belum terjadi',
        ])->assertSessionHasErrors('transaction_date');
    }

    public function test_kategori_harus_cocok_dengan_jenis_transaksi(): void
    {
        $user = $this->user();
        $categories = $this->categories();

        // Jenis expense, tetapi kategori yang dikirim bertipe income.
        $this->actingAs($user)->post(route('transactions.store'), [
            'category_id' => $categories['income']->id,
            'type' => 'expense',
            'amount' => 50000,
            'transaction_date' => '2026-10-05',
            'note' => 'Salah kombinasi',
        ])->assertSessionHasErrors('category_id');

        $this->assertDatabaseCount('transactions', 0);
    }

    public function test_bisa_mengubah_transaksi(): void
    {
        $user = $this->user();
        $categories = $this->categories();

        $transaction = $user->transactions()->create([
            'category_id' => $categories['expense']->id,
            'type' => 'expense',
            'amount' => 45000,
            'transaction_date' => '2026-10-05',
            'note' => 'Kopi sore',
        ]);

        $this->actingAs($user)
            ->put(route('transactions.update', $transaction), [
                'category_id' => $categories['expense']->id,
                'type' => 'expense',
                'amount' => 55000,
                'transaction_date' => '2026-10-06',
                'note' => 'Kopi sore dan camilan',
            ])
            ->assertRedirect(route('transactions.index'));

        $this->assertDatabaseHas('transactions', [
            'id' => $transaction->id,
            'amount' => 55000,
            'transaction_date' => '2026-10-06',
        ]);
        $this->assertDatabaseCount('transactions', 1);
    }

    public function test_bisa_menghapus_transaksi(): void
    {
        $user = $this->user();
        $categories = $this->categories();

        $transaction = $user->transactions()->create([
            'category_id' => $categories['expense']->id,
            'type' => 'expense',
            'amount' => 45000,
            'transaction_date' => '2026-10-05',
        ]);

        $this->actingAs($user)
            ->delete(route('transactions.destroy', $transaction))
            ->assertRedirect(route('transactions.index'));

        $this->assertDatabaseMissing('transactions', ['id' => $transaction->id]);
    }

    public function test_transaksi_pengguna_lain_tidak_bisa_diakses(): void
    {
        $budi = $this->user();
        $categories = $this->categories();

        $transaction = $budi->transactions()->create([
            'category_id' => $categories['expense']->id,
            'type' => 'expense',
            'amount' => 45000,
            'transaction_date' => '2026-10-05',
            'note' => 'Catatan milik Budi',
        ]);

        $siti = User::factory()->create([
            'name' => 'Siti Rahayu',
            'email' => '[email protected]',
        ]);

        $this->actingAs($siti)->get(route('transactions.edit', $transaction))->assertForbidden();
        $this->actingAs($siti)->put(route('transactions.update', $transaction), [
            'category_id' => $categories['expense']->id,
            'type' => 'expense',
            'amount' => 1,
            'transaction_date' => '2026-10-05',
        ])->assertForbidden();
        $this->actingAs($siti)->delete(route('transactions.destroy', $transaction))->assertForbidden();

        // Daftar milik Siti tidak menampilkan transaksi milik Budi.
        $this->actingAs($siti)
            ->get(route('transactions.index'))
            ->assertOk()
            ->assertDontSee('Catatan milik Budi');

        $this->assertDatabaseHas('transactions', ['id' => $transaction->id, 'amount' => 45000]);
    }

    public function test_filter_bulan_pada_daftar_transaksi(): void
    {
        $user = $this->user();
        $categories = $this->categories();

        $user->transactions()->createMany([
            [
                'category_id' => $categories['expense']->id,
                'type' => 'expense',
                'amount' => 40000,
                'transaction_date' => '2026-09-16',
                'note' => 'Makan malam bulan lalu',
            ],
            [
                'category_id' => $categories['expense']->id,
                'type' => 'expense',
                'amount' => 25000,
                'transaction_date' => '2026-10-04',
                'note' => 'Makan siang bulan ini',
            ],
        ]);

        $this->actingAs($user)
            ->get(route('transactions.index', ['month' => '2026-10']))
            ->assertOk()
            ->assertSee('Makan siang bulan ini')
            ->assertDontSee('Makan malam bulan lalu');
    }
}

tests/Unit/ExampleTest.php

Unduh berkas
php
<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

class ExampleTest extends TestCase
{
    /**
     * A basic test example.
     */
    public function test_that_true_is_true(): void
    {
        $this->assertTrue(true);
    }
}