--- name: php-test-writer description: Skill for creating and editing PHP tests following project conventions. Use when creating tests, updating test files, or refactoring tests. Applies proper structure, naming, factory usage, and Laravel/PHPUnit best practices. --- # PHP Test Writer Skill You are an expert at writing PHP tests for Laravel applications. Your role is to create well-structured, maintainable tests that follow the project's established conventions. ## Test Method Naming - CRITICAL Pattern **ALWAYS use the `test_` prefix. DO NOT use the `#[Test]` attribute.** ```php // ✅ CORRECT - Use ONLY test_ prefix public function test_order_calculates_total_correctly() { // test implementation } // ❌ WRONG - Do not use #[Test] attribute #[Test] public function test_order_calculates_total_correctly() { // test implementation } // ❌ WRONG - Do not use #[Test] without prefix #[Test] public function order_calculates_total_correctly() { // test implementation } ``` **Why:** The project uses the `test_` prefix pattern consistently. While `#[Test]` is valid in PHPUnit, it's unnecessary when using the prefix and adds visual noise to test files. ## Project Context **Important System Details:** - **Multitenancy**: Most models have `customer_id` - use `->recycle($customer)` to avoid N+1 customer creation - **Database Schema**: Uses squashed schema (`database/schema/testing-schema.sql`) - **Laravel Sail**: All commands must use `./vendor/bin/sail` prefix - **TestCase Properties**: Feature tests have protected properties like `$customer`, `$user`, `$customerUser` - **DO NOT override these** ## Critical Guidelines ### 1. Always Read TestCase.php First **MANDATORY**: Before writing any feature test, read `tests/TestCase.php` to understand: - Protected properties that cannot be overridden - Available helper methods (e.g., `getCustomer()`, `getAdminUser()`, `actingAsCustomerUser()`) - Setup methods that run automatically (e.g., `setupGroups()`, `setupCurrencies()`) ```php // ❌ BAD - Will cause errors class MyTest extends TestCase { protected $customer; // ERROR: Property already exists in TestCase } // ✅ GOOD - Use TestCase helper methods class MyTest extends TestCase { public function test_something() { $customer = $this->getCustomer(); // Use TestCase helper } } ``` ### 2. File Structure & Naming **Mirror the app/ directory structure:** ``` app/Services/DataObject/DataObjectService.php → tests/Feature/Services/DataObject/DataObjectService/DataObjectServiceTest.php app/Enums/Filtering/RelativeDatePointEnum.php → tests/Unit/Enums/Filtering/RelativeDatePointEnum/RelativeDatePointEnumResolveTest.php ``` **Prefer split over flat structure:** - When a class has many methods or complex edge cases, create a directory - Use subdirectories to organize related tests ``` ✅ Good (split structure): tests/Feature/Services/DataObject/DataObjectService/ ├── BaseDataObjectServiceTest.php # Base class ├── Create/ │ ├── BasicCreateTest.php │ ├── UserColumnTest.php │ └── FailedOperationTest.php └── Update/ ├── BasicUpdateTest.php └── UserColumnTest.php ❌ Avoid (flat structure for complex classes): tests/Feature/Services/DataObject/ └── DataObjectServiceTest.php # Too much in one file ``` ### 3. Test Method Naming ## Test Method Naming - CRITICAL Pattern **ALWAYS use the `test_` prefix. DO NOT use the `#[Test]` attribute.** ```php // ✅ CORRECT - Use ONLY test_ prefix public function test_order_calculates_total_correctly() { // test implementation } // ❌ WRONG - Do not use #[Test] attribute #[Test] public function test_order_calculates_total_correctly() { // test implementation } // ❌ WRONG - Do not use #[Test] without prefix #[Test] public function order_calculates_total_correctly() { // test implementation } ``` **Why:** The project uses the `test_` prefix pattern consistently. While `#[Test]` is valid in PHPUnit, it's unnecessary when using the prefix and adds visual noise to test files. **Formula**: `test_{methodUnderTest}__{conditions}__{expectedOutput}` ```php // ✅ Excellent examples: public function test_update_dispatches_data_object_received_event() public function test_process_converts_non_string_values_to_strings() public function test_last_month_with_year_transition() public function test_attempt_to_create_dataobject_with_existing_extref__throws_error() public function test_resolve_by_external_id_only_finds_users_for_correct_customer() // ❌ Avoid: public function test_update() // Too vague public function testUpdateMethod() // Not descriptive enough ``` **When a whole file tests a single method:** - Method name can be omitted from test name - Example: `RelativeDatePointEnumResolveTest.php` tests only `resolve()`, so methods are named like `test_current_quarter_boundaries()` **Always add PHPDoc:** ```php /** * Test that updating a DataObject dispatches DataObjectReceived event */ public function test_update_dispatches_data_object_received_event() { // Test implementation } ``` ### 4. Test Structure: Arrange-Act-Assert Use the AAA pattern when it makes sense: ```php public function test_update_object_fields() { // Arrange $objectDefinition = $this->getObjectDefinition( data_key: 'test_object_update' ); $dataObject = $this->dataObjectService->create( objectDefinition: $objectDefinition, objectFields: [ 'field1' => 'value1', 'field2' => 'value2', ] ); // Act $updatedDataObject = $this->dataObjectService->update( dataObject: $dataObject, objectFields: [ 'field1' => 'updated_value1', ], throwOnValidationErrors: true, ); // Assert $this->assertEquals('updated_value1', $updatedDataObject->object_fields['field1']); $this->assertEquals('value2', $updatedDataObject->object_fields['field2']); } ``` ### 5. Factory Usage **ALWAYS use factories - NEVER create models manually:** ```php // ✅ GOOD - Use factories $customer = Customer::factory()->create(); $user = User::factory()->create(); $customerUser = CustomerUser::factory() ->recycle($customer) ->recycle($user) ->create(); $objectDefinition = ObjectDefinition::factory() ->recycle($customer) ->create(); // ❌ BAD - Manual creation $customer = Customer::create(['name' => 'Test Customer']); $user = new User(['name' => 'Test', 'email' => 'test@test.com']); $user->save(); ``` **Use ->recycle() extensively for multitenancy:** ```php // ✅ EXCELLENT - Recycle customer across all models $customer = Customer::factory()->create(); $objectDefinition = ObjectDefinition::factory() ->recycle($customer) // Uses same customer ->create(); $dataObject = DataObject::factory() ->recycle($customer) // Same customer ->recycle($objectDefinition) // And its nested relations also use same customer ->createOneWithService(); // ❌ BAD - Creates multiple customers $objectDefinition = ObjectDefinition::factory()->create(); // Creates new customer $dataObject = DataObject::factory() ->recycle($objectDefinition) ->createOneWithService(); // objectDefinition and dataObject have different customers! ``` **Factory Tips:** - Check if factories have custom states before manually setting attributes - Use `->forCustomerUser()`, `->forUserGroup()`, etc. when available - DataObject uses `->createOneWithService()` or `->createWithService()` instead of `->create()` ### 6. Named Arguments **Always use named arguments for clarity:** ```php // ✅ GOOD $result = $this->processor->process( inputValue: 'test', processingContext: [], objectDefinition: $objectDefinition, columnData: $columnData ); $dataObject = $this->dataObjectService->create( objectDefinition: $objectDefinition, objectFields: ['name' => 'Test'], extRef: 'ext-123', visibleRef: 'VIS-123' ); // ❌ BAD $result = $this->processor->process('test', [], $objectDefinition, $columnData); $dataObject = $this->dataObjectService->create($objectDefinition, ['name' => 'Test'], 'ext-123'); ``` ### 7. Authentication & Session **Use TestCase helpers:** ```php // ✅ GOOD - Use TestCase helpers $customer = $this->getCustomer(); $adminUser = $this->getAdminUser(); $adminCustomerUser = $this->getAdminCustomerUser(); // Acting as a customer user $this->actingAsCustomerUser($adminCustomerUser); // Or for session only CustomerSession::store($customer); // ❌ BAD - Manual session manipulation session()->put('customer', CustomerSessionData::fromCustomer($customer)->toArray()); ``` ### 8. DataObject & ObjectDefinition Management **CRITICAL: Use services and helpers for data management** **DataObject Operations:** - **ALL DataObject changes MUST go through DataObjectService** - Never create or update DataObjects directly with Eloquent - Resolve the service using `app()->make()` NOT `app()` ```php // ✅ GOOD - Use DataObjectService /** @var DataObjectService $dataObjectService */ $dataObjectService = app()->make(DataObjectService::class); $dataObject = $dataObjectService->create( objectDefinition: $objectDefinition, extRef: 'test-ref', visibleRef: 'TEST-001', objectFields: ['field1' => 'value1'] ); $updated = $dataObjectService->update( dataObject: $dataObject, objectFields: ['field1' => 'updated_value'] ); // ❌ BAD - Direct model creation/update $dataObject = DataObject::create([...]); // NEVER DO THIS $dataObject->update([...]); // NEVER DO THIS ``` **ObjectDefinition Creation:** - **ALWAYS use TestCase helper methods** for creating ObjectDefinitions - Helper methods: `getObjectDefinition()` and `getManagedObjectDefinition()` - These helpers use ObjectDefinitionService internally ```php // ✅ GOOD - Use TestCase helper $objectDefinition = $this->getObjectDefinition( data_key: 'test_object', columns: [ ObjectDefinitionColumnData::stringColumn( column_key: 'name', column_name: 'Name' ), ObjectDefinitionColumnData::decimalColumn( column_key: 'amount', column_name: 'Amount' ), ], ); // For managed object definitions (e.g., Integration) $objectDefinition = $this->getManagedObjectDefinition( data_key: 'deal', manageable: $integration, primaryTitleColumn: 'name', columns: [ ObjectDefinitionColumnData::stringColumn( column_name: 'name', column_key: 'name' ), ], ); // ❌ BAD - Manual creation with factories $objectDefinition = ObjectDefinition::factory() ->recycle($this->customer) ->create(['data_key' => 'test']); ObjectDefinitionColumn::factory() ->recycle($objectDefinition) ->create(['column_key' => 'test']); ``` **Service Resolution Pattern:** ```php // ✅ GOOD - Use app()->make() for type-safe resolution /** @var DataObjectService $dataObjectService */ $dataObjectService = app()->make(DataObjectService::class); /** @var ObjectDefinitionService $objectDefinitionService */ $objectDefinitionService = app()->make(ObjectDefinitionService::class); // ❌ BAD - Using app() directly (no type safety) $dataObjectService = app(DataObjectService::class); ``` ### 9. Base Test Classes **Create base classes for shared setup:** ```php // Example: BaseDataObjectServiceTest.php abstract class BaseDataObjectServiceTest extends TestCase { protected ?DataObjectService $dataObjectService = null; protected ?ObjectDefinitionService $objectDefinitionService = null; protected function setUp(): void { parent::setUp(); $this->setupUserAndCustomer(); $this->dataObjectService = app()->make(DataObjectService::class); $this->objectDefinitionService = app()->make(ObjectDefinitionService::class); } } // Then extend in specific tests class BasicCreateTest extends BaseDataObjectServiceTest { public function test_something() { // $this->dataObjectService is already available } } ``` **Create custom assertion helpers:** ```php // Example: BaseProcessorTestCase.php protected function assertProcessedSuccessfully( ColumnProcessingResult $result, mixed $expectedValue, string $message = '' ): void { $this->assertFalse($result->hasErrors(), $message ?: 'Expected processing to succeed'); $this->assertTrue($result->isSuccess(), $message ?: 'Expected success'); $this->assertEquals($expectedValue, $result->value, $message ?: 'Value mismatch'); } // Usage in tests $result = $this->processValue(inputValue: 'test', columnData: $columnData); $this->assertProcessedSuccessfully(result: $result, expectedValue: 'test'); ``` ### 10. Common Patterns **Testing events:** ```php Event::fake(); // ... perform action ... Event::assertDispatched(DataObjectReceived::class, function ($event) use ($dataObject) { return $event->dataObject->id === $dataObject->id; }); ``` **Testing exceptions:** ```php $this->expectException(DuplicateExtRefException::class); $this->expectExceptionMessage('External reference already exists'); // ... code that should throw ... ``` **Using data providers:** ```php /** * @dataProvider nullAndEmptyValueProvider */ public function test_handles_null_and_empty($value) { // Test implementation } public static function nullAndEmptyValueProvider(): array { return [ 'null' => [null], 'empty string' => [''], ]; } ``` ### 11. Assertions **Use specific assertions with meaningful messages:** ```php // ✅ GOOD $this->assertEquals('expected', $actual, 'Default value was not applied correctly'); $this->assertNotNull($result, 'Result should not be null'); $this->assertCount(3, $items, 'Expected 3 items in collection'); $this->assertInstanceOf(DataObject::class, $result); $this->assertDatabaseHas('data_objects', ['ext_ref' => 'test-123']); // ❌ AVOID $this->assertTrue($actual == 'expected'); // Use assertEquals instead $this->assertTrue(!is_null($result)); // Use assertNotNull instead ``` ## Anti-Patterns to Avoid ### ❌ Hardcoded IDs ```php // BAD $dataObject = DataObject::create([ 'object_definition_id' => 1, 'customer_id' => 1, ]); // GOOD $dataObject = DataObject::factory() ->recycle($objectDefinition) ->recycle($customer) ->createOneWithService(); ``` ### ❌ Manual Model Creation ```php // BAD $user = User::create([ 'name' => 'Test', 'email' => 'test@example.com', 'password' => bcrypt('password'), ]); // GOOD $user = User::factory()->create([ 'email' => 'test@example.com' // Only specify what matters for the test ]); ``` ### ❌ Overriding TestCase Protected Properties ```php // BAD - Will cause errors class MyTest extends TestCase { protected $customer; // ERROR: Already defined in TestCase protected $user; // ERROR: Already defined in TestCase } // GOOD - Use TestCase helpers class MyTest extends TestCase { public function test_something() { $customer = $this->getCustomer(); $user = User::factory()->create(); } } ``` ### ❌ Using env() Directly ```php // BAD $apiKey = env('API_KEY'); // GOOD $apiKey = config('services.api.key'); ``` ## Test Execution **Running tests:** ```bash # All tests ./vendor/bin/sail php artisan test # Specific file ./vendor/bin/sail php artisan test tests/Feature/Services/DataObject/DataObjectService/Create/BasicCreateTest.php # Specific test method ./vendor/bin/sail php artisan test --filter=test_update_dispatches_data_object_received_event # With filter ./vendor/bin/sail php artisan test --filter=DataObjectService ``` **Schema regeneration (when migrations change):** ```bash ./vendor/bin/sail php artisan schema:regenerate-testing --env=testing ``` ## Examples from Codebase ### Feature Test Example (Integration) ```php getObjectDefinition( columns: [ ObjectDefinitionColumnData::stringColumn(column_key: 'test_field'), ], ); $this->dataObjectService->create( objectDefinition: $objectDefinition, objectFields: ['test_field' => 'Test Value'], extRef: 'test-create-ref', ); $this->expectException(DuplicateExtRefException::class); // Should throw an exception because the extRef already exists $this->dataObjectService->create( objectDefinition: $objectDefinition, objectFields: ['test_field' => 'Test Value'], extRef: 'test-create-ref', ); } } ``` ### Unit Test Example (Isolated) ```php resolve( contextPeriodStart: $periodStart, contextPeriodEnd: $periodEnd ); $endResult = RelativeDatePointEnum::END_OF_CONTEXT_PERIOD->resolve( contextPeriodStart: $periodStart, contextPeriodEnd: $periodEnd ); $this->assertEquals('2025-01-01 00:00:00', $startResult->format('Y-m-d H:i:s')); $this->assertEquals('2025-01-31 23:59:59', $endResult->format('Y-m-d H:i:s')); } /** * Test that context period boundaries throw exception when context is missing */ public function test_context_period_boundaries_throw_exception_when_missing(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('Cannot resolve relative date point'); RelativeDatePointEnum::START_OF_CONTEXT_PERIOD->resolve(); } } ``` ### Base Test Class Example ```php $columnKey, 'column_name' => $columnName ?? ucfirst(str_replace('_', ' ', $columnKey)), 'column_type' => $columnType, 'is_required' => $isRequired, 'default_value' => $defaultValue, ]); } /** * Process a value using the processor with standard test parameters */ protected function processValue( mixed $inputValue, ?ObjectDefinitionColumnData $columnData = null, array $processingContext = [] ): ColumnProcessingResult { $columnData = $columnData ?? $this->makeColumnData(); $objectDefinition = \Mockery::mock(ObjectDefinition::class); return $this->processor->process( inputValue: $inputValue, processingContext: $processingContext, objectDefinition: $objectDefinition, columnData: $columnData ); } /** * Assert that processing was successful and returned the expected value */ protected function assertProcessedSuccessfully( ColumnProcessingResult $result, mixed $expectedValue, string $message = '' ): void { $this->assertFalse($result->hasErrors(), $message ?: 'Expected processing to succeed but it had errors'); $this->assertTrue($result->isSuccess(), $message ?: 'Expected processing to be marked as successful'); $this->assertEquals($expectedValue, $result->value, $message ?: 'Expected processed value did not match'); } } ``` ## Workflow When writing tests: 1. **Read TestCase.php** to understand available helpers and protected properties 2. **Check for existing similar tests** to follow established patterns 3. **Read the PHPUnit guidelines** at `docs/development/guidelines/php/phpunit-guidelines.md` 4. **Determine test type**: Feature (integration) or Unit (isolated) 5. **Create proper directory structure** mirroring app/ directory 6. **Use factories exclusively** with `->recycle()` for multitenancy 7. **Write descriptive test names** following the convention 8. **Add PHPDoc** explaining what the test does 9. **Use named arguments** throughout 10. **Run the tests** to verify they pass 11. **Consider creating base test class** if you have multiple related test files ## Final Reminder - **ALWAYS read TestCase.php first** for feature tests - **NEVER override TestCase protected properties** - **ALWAYS use factories** with `->recycle($customer)` - **ALWAYS use named arguments** for clarity - **ALL DataObject changes through DataObjectService** - Never create/update DataObjects directly - **Use TestCase helpers for ObjectDefinitions** - `getObjectDefinition()` or `getManagedObjectDefinition()` - **Resolve services with `app()->make()`** - NOT `app()` for type safety - **Mirror app/ directory structure** in tests - **Prefer split over flat** structure for complex classes - **Run tests after writing** to ensure they pass Your goal is to create maintainable, readable tests that future developers can easily understand and extend.