15 KiB
Testing Guide
Tests are written only on explicit request. When asked, follow this document exactly and keep the coverage matrix at the bottom up to date so remaining gaps are always visible.
1. What we test (and what we don't)
Business logic lives in app/Services/. Models are mostly relations and $fillable. Testing is
prioritised accordingly:
| Priority | Layer | Why |
|---|---|---|
| P1 | Services | Money, auth, scheduling, case flow — the only place real logic lives |
| P1 | Form Requests | Cheap, catches every invalid input path before it reaches a service |
| P1 | HTTP (critical routes) | Authentication, authorization/ownership, payment callback |
| P2 | Remaining HTTP | Doctor & Admin CRUD |
| P3 | Enums | Only methods with logic (price(), processingHours(), fromDate(), …) |
| P3 | Models | Only scopes, accessors and helper methods — never relations or $fillable |
Never test: Eloquent relations, $fillable/$casts, framework behaviour, API Resources on their
own (they are asserted through HTTP tests), Vue components (no JS test stack in this project).
2. Directory structure — one source file ↔ one test file
Test files mirror the source path. This is what makes gap detection mechanical.
app/Services/Wallet/WalletService.php → tests/Feature/Services/Wallet/WalletServiceTest.php
app/Http/Requests/Client/PaymentRequest.php → tests/Feature/Requests/Client/PaymentRequestTest.php
app/Http/Controllers/Client/PaymentController.php → tests/Feature/Http/Client/PaymentControllerTest.php
app/Enums/ShiftDayEnum.php → tests/Unit/Enums/ShiftDayEnumTest.php
app/Models/Shift.php → tests/Feature/Models/ShiftTest.php
tests/Unit/— no database. Enums only; extendPHPUnit\Framework\TestCaseunless a framework helper is needed (EnumHasOptionsTestextendsTests\TestCasebecauseoptions()calls__()).tests/Feature/— boots the app, usesRefreshDatabase(sqlite:memory:, already configured inphpunit.xml).- The default
ExampleTestplaceholders have been removed; never re-add them.
3. Conventions
- PHPUnit 11, class-based tests extending
Tests\TestCase,RefreshDatabasetrait on every Feature test. - Method names:
test_{action}_{expected}_when_{condition}in snake_case, e.g.test_deposit_increases_balance,test_withdraw_throws_when_balance_is_insufficient. - One behaviour per test. Arrange / Act / Assert, separated by a blank line, no comments.
- Resolve services from the container (
app(WalletService::class)), nevernew. - Every model touched by a test must have a factory in
database/factories/. Create the missing factory first, in the same style as the existing ones. Factories must produce valid rows with all foreign keys. - Assert outcomes, not implementation:
assertDatabaseHas, returned values, thrown exceptions,assertInertiaprops / JSON structure for HTTP. - Use enums in assertions (
DocumentStatusEnum::Pending), never raw strings.
Fakes — never hit a real external system
| Dependency | Fake |
|---|---|
Zibal (ZibalPaymentService), IranPayamak (IranPayamakService) |
Http::fake([...]) with Http::assertSent |
SendPatternSms job / SmsNotificationService |
Queue::fake() + Queue::assertPushed(SendPatternSms::class) |
FileStorageService, Medium |
Storage::fake('public'), Storage::fake('private') |
| Time-dependent logic (shifts, OTP expiry, processing hours) | Carbon::setTestNow(...) |
| Authenticated user | $this->actingAs(User::factory()->create()) |
| Spatie roles/permissions | InteractsWithAdmin::adminWithAllPermissions() (seeds PermissionSeeder) or $user->givePermissionTo(...) |
Excel exports (Maatwebsite) |
Excel::fake() + Excel::assertDownloaded('file.xlsx') |
4. Required coverage per layer
Service — happy path; every guard / exception branch; every side effect (DB rows, dispatched jobs, transactions written); for money code, balance invariants before and after.
Form Request — use the Tests\Concerns\ValidatesFormRequests trait (assertPasses, assertFailsOn,
formRequestErrors); it runs prepareForValidation, rules, withValidator and route/user resolvers without HTTP.
Cover authorize() when it is not return true; each rule with an invalid input; one fully valid payload.
HTTP — guest is redirected to login; wrong role or non-owner gets 403/404; happy path returns
the expected status and persisted state; Inertia page tests assert the component name and key props.
Tests\TestCase already calls withoutVite() and points inertia.testing.page_paths at
resources/js/Client/Pages and resources/js/Admin/Pages. Admin tests use the Tests\Concerns\InteractsWithAdmin
trait: adminWithAllPermissions() seeds PermissionSeeder and returns admin@gmail.com; adminWithoutPermissions()
returns a bare مدیر user for 403 checks. A resource collection passed as an Inertia prop is wrapped in data
(has('items.data', n)). Use Excel::fake() + Excel::assertDownloaded() for export routes.
Enum — every method containing logic, including edge values.
Model — each scope filters correctly; each accessor / helper method (isExpired, fullUrl, humanSize, …).
5. Workflow when asked to write tests
- Read this file. If the user names a target, test that; otherwise take the next
☐row by priority (P1 → P3). - Create missing factories first.
- Write the test file at the mirrored path (section 2).
- Run
php artisan test --filter={ClassName}and make it green. Then run the full suite. - Flip the row to
☑in the matrix below. Add rows for any new Service / Request / Controller / Enum that exists in the code but is missing from the matrix. - Do not modify unrelated tests or source files.
Detecting gaps mechanically
The matrix is the source of truth, but this catches rows that were never added. Anything it lists
that is not in the matrix must be added as ☐ or, if it has no logic worth testing, as —:
for f in $(find app/Services app/Http/Requests app/Http/Controllers app/Enums -name '*.php' ! -name 'Controller.php' ! -name 'EnumHasOptions.php'); do t=$(echo "$f" | sed -E 's#^app/Services/#tests/Feature/Services/#; s#^app/Http/Requests/#tests/Feature/Requests/#; s#^app/Http/Controllers/#tests/Feature/Http/#; s#^app/Enums/#tests/Unit/Enums/#; s#\.php$#Test.php#'); [ -f "$t" ] || echo "MISSING $t"; done
6. Coverage matrix
Legend: ☐ not written · ☑ written · — intentionally not tested
Services
| Priority | Source | Test | Status |
|---|---|---|---|
| P1 | app/Services/Wallet/WalletService.php |
tests/Feature/Services/Wallet/WalletServiceTest.php |
☑ |
| P1 | app/Services/Wallet/WithdrawRequestService.php |
tests/Feature/Services/Wallet/WithdrawRequestServiceTest.php |
☑ |
| P1 | app/Services/Invoice/InvoiceService.php |
tests/Feature/Services/Invoice/InvoiceServiceTest.php |
☑ |
| P1 | app/Services/Payment/ZibalPaymentService.php |
tests/Feature/Services/Payment/ZibalPaymentServiceTest.php |
☑ |
| P1 | app/Services/Auth/OtpService.php |
tests/Feature/Services/Auth/OtpServiceTest.php |
☑ |
| P1 | app/Services/Auth/AuthService.php |
tests/Feature/Services/Auth/AuthServiceTest.php |
☑ |
| P1 | app/Services/Auth/RegistrationService.php |
tests/Feature/Services/Auth/RegistrationServiceTest.php |
☑ |
| P1 | app/Services/Shift/ShiftScheduleService.php |
tests/Feature/Services/Shift/ShiftScheduleServiceTest.php |
☑ |
| P1 | app/Services/Document/CaseService.php |
tests/Feature/Services/Document/CaseServiceTest.php |
☑ |
| P1 | app/Services/Document/FastRequestService.php |
tests/Feature/Services/Document/FastRequestServiceTest.php |
☑ |
| P2 | app/Services/Document/DocumentCodeService.php |
tests/Feature/Services/Document/DocumentCodeServiceTest.php |
☑ |
| P2 | app/Services/Sms/SmsNotificationService.php |
tests/Feature/Services/Sms/SmsNotificationServiceTest.php |
☑ |
| P2 | app/Services/FileStorageService.php |
tests/Feature/Services/FileStorageServiceTest.php |
☑ |
| P3 | app/Services/Sms/IranPayamakService.php |
tests/Feature/Services/Sms/IranPayamakServiceTest.php |
☑ |
Form Requests
| Priority | Source | Test | Status |
|---|---|---|---|
| P1 | app/Http/Requests/Client/Auth/UserRegistrationRequest.php |
tests/Feature/Requests/Client/Auth/UserRegistrationRequestTest.php |
☑ |
| P1 | app/Http/Requests/Client/Auth/VerifyOtpRequest.php |
tests/Feature/Requests/Client/Auth/VerifyOtpRequestTest.php |
☑ |
| P1 | app/Http/Requests/Client/PaymentRequest.php |
tests/Feature/Requests/Client/PaymentRequestTest.php |
☑ |
| P1 | app/Http/Requests/Client/WalletDepositRequest.php |
tests/Feature/Requests/Client/WalletDepositRequestTest.php |
☑ |
| P1 | app/Http/Requests/Client/WithdrawRequestStoreRequest.php |
tests/Feature/Requests/Client/WithdrawRequestStoreRequestTest.php |
☑ |
| P1 | app/Http/Requests/Client/BankCardStoreRequest.php |
tests/Feature/Requests/Client/BankCardStoreRequestTest.php |
☑ |
| P1 | app/Http/Requests/Client/FastRequestRequest.php |
tests/Feature/Requests/Client/FastRequestRequestTest.php |
☑ |
| P2 | app/Http/Requests/Client/CaseRejectRequest.php |
tests/Feature/Requests/Client/CaseRejectRequestTest.php |
☑ |
| P2 | app/Http/Requests/Client/ReportSubmitRequest.php |
tests/Feature/Requests/Client/ReportSubmitRequestTest.php |
☑ |
| P2 | app/Http/Requests/Client/ProfileUpdateRequest.php |
tests/Feature/Requests/Client/ProfileUpdateRequestTest.php |
☑ |
| P2 | app/Http/Requests/Admin/WithdrawRejectRequest.php |
tests/Feature/Requests/Admin/WithdrawRejectRequestTest.php |
☑ |
| P2 | app/Http/Requests/Admin/DocumentRequest.php |
tests/Feature/Requests/Admin/DocumentRequestTest.php |
☑ |
| P2 | app/Http/Requests/Admin/ShiftRequest.php |
tests/Feature/Requests/Admin/ShiftRequestTest.php |
☑ |
| P2 | app/Http/Requests/Admin/UserRequest.php |
tests/Feature/Requests/Admin/UserRequestTest.php |
☑ |
| P2 | app/Http/Requests/Admin/PetRequest.php |
tests/Feature/Requests/Admin/PetRequestTest.php |
☑ |
| P2 | app/Http/Requests/Admin/ReportRequest.php |
tests/Feature/Requests/Admin/ReportRequestTest.php |
☑ |
| P2 | app/Http/Requests/Admin/SettingProfileRequest.php |
tests/Feature/Requests/Admin/SettingProfileRequestTest.php |
☑ (known bug: validates users.name, first_name, last_name which do not exist; passes on sqlite only because of literal quoting) |
HTTP
| Priority | Source | Test | Status |
|---|---|---|---|
| P1 | app/Http/Controllers/Client/AuthController.php |
tests/Feature/Http/Client/AuthControllerTest.php |
☑ |
| P1 | app/Http/Controllers/Client/PaymentController.php |
tests/Feature/Http/Client/PaymentControllerTest.php |
☑ |
| P1 | app/Http/Controllers/Client/WalletController.php |
tests/Feature/Http/Client/WalletControllerTest.php |
☑ |
| P1 | app/Http/Controllers/Client/FastRequestController.php |
tests/Feature/Http/Client/FastRequestControllerTest.php |
☑ |
| P1 | app/Http/Controllers/Client/InvoiceController.php |
tests/Feature/Http/Client/InvoiceControllerTest.php |
☑ |
| P1 | app/Http/Controllers/Admin/WithdrawRequestController.php |
tests/Feature/Http/Admin/WithdrawRequestControllerTest.php |
☑ |
| P2 | app/Http/Controllers/Client/Doctor/CaseController.php |
tests/Feature/Http/Client/Doctor/CaseControllerTest.php |
☑ |
| P2 | app/Http/Controllers/Client/Doctor/ReportController.php |
tests/Feature/Http/Client/Doctor/ReportControllerTest.php |
☑ |
| P2 | app/Http/Controllers/Client/Doctor/ShiftController.php |
tests/Feature/Http/Client/Doctor/ShiftControllerTest.php |
☑ |
| P2 | app/Http/Controllers/Client/Doctor/DocumentController.php |
tests/Feature/Http/Client/Doctor/DocumentControllerTest.php |
☑ |
| P2 | app/Http/Controllers/Client/Doctor/DashboardController.php |
tests/Feature/Http/Client/Doctor/DashboardControllerTest.php |
☑ |
| P2 | app/Http/Controllers/Client/MediaController.php |
tests/Feature/Http/Client/MediaControllerTest.php |
☑ |
| P2 | app/Http/Controllers/Client/ProfileController.php |
tests/Feature/Http/Client/ProfileControllerTest.php |
☑ |
| P2 | app/Http/Controllers/Admin/AuthController.php |
tests/Feature/Http/Admin/AuthControllerTest.php |
☑ (known: loginAction returns request()->json(...) instead of response()->json(...), works by accident) |
| P2 | app/Http/Controllers/Admin/DocumentController.php |
tests/Feature/Http/Admin/DocumentControllerTest.php |
☑ |
| P2 | app/Http/Controllers/Admin/ShiftController.php |
tests/Feature/Http/Admin/ShiftControllerTest.php |
☑ |
| P2 | app/Http/Controllers/Admin/UserController.php |
tests/Feature/Http/Admin/UserControllerTest.php |
☑ (known: update() writes nullable username/national_code straight to NOT NULL columns; the form always sends them) |
| P2 | app/Http/Controllers/Admin/ReportController.php |
tests/Feature/Http/Admin/ReportControllerTest.php |
☑ (known: edit() renders Reports/edit which has no Vue page; Report model lacks SoftDeletes despite deleted_at column) |
| P2 | app/Http/Controllers/Admin/PetController.php |
tests/Feature/Http/Admin/PetControllerTest.php |
☑ |
| P2 | app/Http/Controllers/Admin/MediumController.php |
tests/Feature/Http/Admin/MediumControllerTest.php |
☑ |
| P2 | app/Http/Controllers/Admin/SettingUserController.php |
tests/Feature/Http/Admin/SettingUserControllerTest.php |
☑ |
| P3 | app/Http/Controllers/Admin/Role/RoleController.php |
tests/Feature/Http/Admin/Role/RoleControllerTest.php |
☑ |
| P3 | app/Http/Controllers/Admin/Role/PermissionController.php |
tests/Feature/Http/Admin/Role/PermissionControllerTest.php |
☑ |
| P3 | app/Http/Controllers/Admin/PanelController.php |
tests/Feature/Http/Admin/PanelControllerTest.php |
☑ (known: guests hit auth before admin.access, so they land on the client login, not admin.auth.login) |
Enums
| Priority | Source | Test | Status |
|---|---|---|---|
| P3 | app/Enums/ShiftDayEnum.php |
tests/Unit/Enums/ShiftDayEnumTest.php |
☑ |
| P3 | app/Enums/DocumentModalityEnum.php |
tests/Unit/Enums/DocumentModalityEnumTest.php |
☑ |
| P3 | app/Enums/DocumentPriorityEnum.php |
tests/Unit/Enums/DocumentPriorityEnumTest.php |
☑ |
| P3 | app/Enums/EnumHasOptions.php (via any enum using it) |
tests/Unit/Enums/EnumHasOptionsTest.php |
☑ |
| — | all other enums (values only) | — |
Models
| Priority | Source | Test | Status |
|---|---|---|---|
| P3 | app/Models/Shift.php (scopeActive, scopeOpen) |
tests/Feature/Models/ShiftTest.php |
☑ |
| P3 | app/Models/Medium.php (fullUrl, humanSize, exists, isPublic, isPrivate, deleteFile) |
tests/Feature/Models/MediumTest.php |
☑ |
| P3 | app/Models/OtpCode.php (isExpired) |
tests/Feature/Models/OtpCodeTest.php |
☑ |
| — | all other models (relations / fillable only) | — |
Factories
Existing: User, Pet, Document, DocumentAssignment, Medium, MediaAssignment, Report.
Missing — create when first needed by a test:
| Model | Status |
|---|---|
Wallet |
☑ |
WalletTransaction |
☑ |
WithdrawRequest |
☑ |
BankCard |
☑ |
Invoice |
☑ |
Payment |
☑ |
OtpCode |
☑ |
Shift |
☑ |