| name | testing-patterns |
| description | Writing and fixing tests, test factories, Event::fake patterns, auth guard testing, PHPUnit configuration, deprecated test groups, and common test pitfalls. |
Testing Patterns
1033+ tests passing (1 skipped), 0 failures, 0 flaky. PHPUnit 10 with RefreshDatabase. Base TestCase flushes Redis + array cache in setUp() — prevents rate limiter state leaking between tests. 0 deprecated tests remaining (all 40 previously-deprecated files resolved: 18 dead removed, 22 fixed and undeprecated). Dead tests deleted: DecreaseTeamTotalPhotosTest, IncreaseTeamTotalPhotosTest (listeners removed), CalculateTagsDifferenceActionTest (action removed). 32 dead files deleted across v5 audit sessions.
Key Files
phpunit.xml — Config: excludes deprecated group, uses olm_test DB, Redis DB 2
tests/TestCase.php — Base class: RefreshDatabase + Redis flush + TagKeyCache::forgetAll()
tests/Feature/Admin/AdminQueueTest.php — 11 tests (queue endpoint: filters, pagination, exclusions, auth)
tests/Feature/UploadValidationTest.php — 11 tests (EXIF datetime, GPS DMS conversion, zero denominator, 0,0 coords)
tests/Feature/HasPhotoUploads.php — Trait for old upload-based tests (deprecated)
database/factories/PhotoFactory.php — Photo with user, country, state, geom
database/factories/Location/CountryFactory.php — Country with shortcode
database/factories/Location/StateFactory.php — State with country FK
database/factories/Location/CityFactory.php — City with country + state FKs
tests/Feature/Bbox/BoundingBoxRetiredTest.php — 5 tests (all bbox endpoints return 410 Gone)
database/factories/Litter/Tags/CategoryFactory.php — Category with unique key
database/factories/Litter/Tags/LitterObjectFactory.php — LitterObject with unique key
database/factories/Litter/Tags/LitterObjectTypeFactory.php — LitterObjectType with unique key + name
tests/Feature/Tags/TaggingArchitecturePhase1Test.php — 20 tests (seeding, relationships, API, idempotency)
tests/Feature/Tags/ReplacePhotoTagsTest.php — 5 tests (replace tags, ownership, auth, extra tags cleanup)
tests/Feature/Teams/TeamPhotosTest.php — 35 tests (new_tags format, CLO tag edits, member stats, safeguarding, delete, revoke, approval, map)
tests/Feature/User/PublicProfileTest.php — 4 tests (public profile data, private returns, privacy settings, 404)
tests/Feature/Leaderboard/LeaderboardTest.php — 18 tests (all paths: global, country, state, city scopes)
tests/Feature/Auth/SanctumTokenAuthTest.php — Mobile token auth tests
tests/Feature/Signup/CreateNewUserTest.php — Registration flow tests
tests/Feature/Tags/ClassifyTagsServiceTest.php — 12 tests (category aliases, deprecated tag mapping, unknown tags, getCategory)
tests/Feature/User/UsersUploadsControllerTest.php — 9 tests (picked_up filter, pagination, tagged/untagged filters)
tests/Feature/Photos/PhotoVisibilityTest.php — 7 tests (per-photo visibility toggle: owner-only, school block, is_public persistence, PhotoObserver dirty tiles)
Invariants
- RefreshDatabase on every test. The base
TestCase uses RefreshDatabase and flushes Redis in setUp() and tearDown().
photo_tags uses FK columns. Tests must create Category/LitterObject records and use their IDs — not strings.
- Deprecated tests are excluded by default. Run with
--group=deprecated to include them. They use old routes (/submit, /add-tags) that no longer work with v5.
Event::fake() prevents listeners. If testing event dispatch AND listener side effects (metrics), split into two tests or don't fake.
- Notifications table may not exist. Fake events if testing notification-dispatching code, or create the
notifications table.
Patterns
Base TestCase setup
abstract class TestCase extends BaseTestCase
{
use CreatesApplication, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Redis::connection()->flushdb();
Cache::flush();
TagKeyCache::forgetAll();
}
protected function tearDown(): void
{
Redis::connection()->flushdb();
parent::tearDown();
}
}
Auth guard patterns
$this->actingAs($user, 'api')->postJson('/api/photos/submit', [...]);
$this->actingAs($user)->postJson('/api/settings/update', [...]);
$this->actingAs($user, 'api')->postJson('/api/v3/tags', [...]);
$this->actingAs($user)->postJson('/api/v3/tags', [...]);
Using factories instead of uploading in tests
$this->actingAs($user);
$this->post('/api/photos/submit', [...]);
$photo = $user->photos->last();
$photo = Photo::factory()->create(['user_id' => $user->id]);
$this->actingAs($user)->post('/api/profile/photos/delete', ['photoid' => $photo->id]);
Event::fake patterns
Event::fake([TagsVerifiedByAdmin::class]);
Event::assertDispatched(TagsVerifiedByAdmin::class, 1);
Event::assertNotDispatched(SchoolDataApproved::class);
Event::assertDispatched(
TagsVerifiedByAdmin::class,
fn (TagsVerifiedByAdmin $e) => $e->photo_id === $photo->id
);
$this->postJson('/api/v3/tags', $payload);
$photo->refresh();
$this->assertNotNull($photo->processed_at);
Spatie Permissions setup (required for team tests)
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
protected function setUp(): void
{
parent::setUp();
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$permissions = collect([
'create school team', 'manage school team',
'toggle safeguarding', 'view student identities',
])->map(fn ($name) => Permission::firstOrCreate([
'name' => $name, 'guard_name' => 'web'
]));
$role = Role::firstOrCreate(['name' => 'school_manager', 'guard_name' => 'web']);
$role->syncPermissions($permissions);
}
Factory usage — let factories create related models
$photo = Photo::factory()->create([
'is_public' => true,
'verified' => VerificationStatus::ADMIN_APPROVED->value,
'city_id' => City::factory(), // PhotoFactory doesn't include city_id by default
]);
// BAD: Hardcoding IDs
$photo = Photo::factory()->create(['country_id' => 1]); // May not exist
Team type setup (required for team tests)
$communityType = TeamType::create(['team' => 'community', 'price' => 0]);
$schoolType = TeamType::create(['team' => 'school', 'price' => 0]);
Seeding tags for tagging tests
protected function setUp(): void
{
parent::setUp();
$this->seed([
GenerateTagsSeeder::class,
GenerateBrandsSeeder::class,
]);
}
Seeding tags + types for v5.1 architecture tests
protected function setUp(): void
{
parent::setUp();
$this->seed(GenerateTagsSeeder::class);
}
VerificationStatus in assertions
$photo->refresh();
$this->assertEquals(VerificationStatus::ADMIN_APPROVED, $photo->verified);
$this->assertTrue($photo->verified->value >= VerificationStatus::ADMIN_APPROVED->value);
$this->assertEquals(2, $photo->verified);
Soft-delete assertions
$this->assertSoftDeleted('photos', ['id' => $photo->id]);
$this->assertDatabaseMissing('photos', ['id' => $photo->id]);
Running tests
php artisan test --compact
php artisan test --compact tests/Feature/Teams/
php artisan test --compact tests/Feature/Photos/AddTagsToPhotoTest.php
php artisan test --compact --filter=test_method_name
php artisan test --compact --group=deprecated
Leaderboard test patterns
$this->actingAs($user)->getJson('/api/leaderboard?locationType=global');
Redis::zadd(RedisKeys::xpRanking(RedisKeys::global()), $xp, (string)$user->id);
DB::table('metrics')->insert([
'timescale' => 3, // monthly
'location_type' => 0, // global
'location_id' => 0,
'user_id' => $user->id, // > 0 for per-user
'year' => now()->year,
'month' => now()->month,
'xp' => 100,
// ... other counters
]);
Test DB restoration (if all tests fail with "table doesn't exist")
DB_DATABASE=olm_test DB_USERNAME=root DB_PASSWORD=secret php artisan migrate:fresh --no-interaction
DB_DATABASE=olm_test DB_USERNAME=root DB_PASSWORD=secret php artisan db:seed --class=GenerateTagsSeeder --no-interaction
DB_DATABASE=olm_test DB_USERNAME=root DB_PASSWORD=secret php artisan db:seed --class=GenerateBrandsSeeder --no-interaction
Common Mistakes
- Forgetting
PermissionRegistrar::forgetCachedPermissions() in setUp. Spatie caches permissions across tests. Reset explicitly.
- Not providing
'price' => 0 for TeamType. Column has no default — insert fails.
- Faking events when you need side effects.
Event::fake() prevents all listeners. If you need MetricsService to run, don't fake TagsVerifiedByAdmin.
- Using
assertDatabaseMissing for soft-deleted records. Use assertSoftDeleted instead.
- Creating PhotoTags with string keys.
photo_tags.category_id and litter_object_id are integer FKs. Create Category/LitterObject records first.
- Missing
city_id in photo factory. The default PhotoFactory doesn't include city_id. Add 'city_id' => City::factory() when testing location-dependent features.
- Mismatching
actingAs() guard with route middleware. actingAs($user) (web guard) fails on auth:api routes. actingAs($user, 'api') fails on auth:sanctum routes. The mismatch causes silent 401s — no error message, just empty responses.
- Uploading photos via
/api/photos/submit in web-guard tests. That route uses auth:api. If your test uses actingAs($user), the upload returns 401 and no photo is created. Use Photo::factory() for tests that aren't testing upload behavior.
- Expecting
geom in JSON responses. Photo::$hidden = ['geom'] — binary spatial data is excluded from serialization.
- Using
assertNull for Redis::zScore() on missing members. PHP Redis returns false (not null) when a ZSET member doesn't exist. Use assertFalse(Redis::zScore($key, $member)).
HasPhotoUploads trait double-encoding address_array. The trait was json_encode()-ing address_array before insert, but the Photo model has an 'array' cast on that column — causing double-encoding. Fixed to pass the raw array directly and let the model cast handle serialization.
- Empty tags on PUT /api/v3/tags returns 200, not 422.
ReplacePhotoTagsRequest validates tags as present|array (not required|array|min:1). Sending tags: [] clears all tags on a photo. Tests expecting 422 for empty tags must be updated to expect 200.
- Flaky 429s from rate limiter state.
CACHE_DRIVER=array in phpunit.xml means rate limiter entries persist between tests in the same PHPUnit process. The base TestCase::setUp() calls Cache::flush() to prevent this. If you add a new test file that hits throttled routes and see intermittent 429s, verify it extends the base TestCase.
- Upload tests need both S3 and bbox disk fakes.
UploadPhotoController writes to two disks: the main S3 disk and the bbox disk (for thumbnail). Both must be faked: Storage::fake('s3'); Storage::fake('bbox');. Missing one causes a real filesystem/S3 call that fails in CI.