Agent skill
php-test-writer
Skill for creating and editing PHP tests following Prowi conventions. Use when creating tests, updating test files, or refactoring tests. Applies proper structure, naming, factory usage, and Laravel/PHPUnit best practices.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/testing/php-test-writer
SKILL.md
PHP Test Writer Skill
You are an expert at writing PHP tests for the Prowi Laravel application. Your role is to create well-structured, maintainable tests that follow the project's established conventions.
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/sailprefix - 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())
// ❌ 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
Formula: test_{methodUnderTest}__{conditions}__{expectedOutput}
// ✅ 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.phptests onlyresolve(), so methods are named liketest_current_quarter_boundaries()
Always add PHPDoc:
/**
* 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:
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:
// ✅ 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:
// ✅ 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:
// ✅ 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:
// ✅ 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()NOTapp()
// ✅ 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()andgetManagedObjectDefinition() - These helpers use ObjectDefinitionService internally
// ✅ 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:
// ✅ 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:
// 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:
// 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:
Event::fake();
// ... perform action ...
Event::assertDispatched(DataObjectReceived::class, function ($event) use ($dataObject) {
return $event->dataObject->id === $dataObject->id;
});
Testing exceptions:
$this->expectException(DuplicateExtRefException::class);
$this->expectExceptionMessage('External reference already exists');
// ... code that should throw ...
Using data providers:
/**
* @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:
// ✅ 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
// BAD
$dataObject = DataObject::create([
'object_definition_id' => 1,
'customer_id' => 1,
]);
// GOOD
$dataObject = DataObject::factory()
->recycle($objectDefinition)
->recycle($customer)
->createOneWithService();
❌ Manual Model Creation
// 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
// 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
// BAD
$apiKey = env('API_KEY');
// GOOD
$apiKey = config('services.api.key');
Test Execution
Running tests:
# 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):
./vendor/bin/sail php artisan schema:regenerate-testing --env=testing
Examples from Codebase
Feature Test Example (Integration)
<?php
namespace Tests\Feature\Services\DataObject\DataObjectService\Create;
use App\Data\ObjectDefinition\ObjectDefinitionColumnData;
use App\Exceptions\DataObject\DuplicateExtRefException;
use Tests\Feature\Services\DataObject\DataObjectService\BaseDataObjectServiceTest;
/**
* Test basic creation functionality of DataObjectService
*/
class BasicCreateTest extends BaseDataObjectServiceTest
{
public function test_attempt_to_create_dataobject_with_existing_extref__throws_error()
{
$objectDefinition = $this->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
namespace Tests\Unit\Enums\Filtering\RelativeDatePointEnum;
use App\Enums\Filtering\RelativeDatePointEnum;
use Carbon\Carbon;
use Exception;
use Tests\Unit\BaseUnitTestCase;
class RelativeDatePointEnumResolveTest extends BaseUnitTestCase
{
/**
* Test that context period boundaries resolve correctly
*/
public function test_context_period_boundaries_resolve_correctly(): void
{
$periodStart = Carbon::parse('2025-01-01 00:00:00', 'UTC');
$periodEnd = Carbon::parse('2025-01-31 23:59:59', 'UTC');
$startResult = RelativeDatePointEnum::START_OF_CONTEXT_PERIOD->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
namespace Tests\Feature\Services\DataObject\ObjectFields\ColumnTypeProcessors;
use App\Data\ObjectDefinition\ObjectDefinitionColumnData;
use App\Enums\DataObject\Error\DataObjectErrorCode;
use App\Enums\ObjectDefinition\ObjectDefinitionColumn\ColumnTypeEnum;
use App\Models\ObjectDefinition;
use App\Services\DataObject\ObjectFields\ColumnProcessingResult;
use App\Services\DataObject\ObjectFields\ColumnTypeProcessors\AbstractColumnProcessor;
use Tests\TestCase;
/**
* Base test case for column processor tests with helpful assertion methods
*/
abstract class BaseProcessorTestCase extends TestCase
{
protected AbstractColumnProcessor $processor;
/**
* Create a simple column data object for testing
*/
protected function makeColumnData(
string $columnKey = 'test_field',
ColumnTypeEnum $columnType = ColumnTypeEnum::STRING,
bool $isRequired = false,
mixed $defaultValue = null,
?string $columnName = null
): ObjectDefinitionColumnData {
return ObjectDefinitionColumnData::from([
'column_key' => $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:
- Read TestCase.php to understand available helpers and protected properties
- Check for existing similar tests to follow established patterns
- Read the PHPUnit guidelines at
docs/development/guidelines/php/phpunit-guidelines.md - Determine test type: Feature (integration) or Unit (isolated)
- Create proper directory structure mirroring app/ directory
- Use factories exclusively with
->recycle()for multitenancy - Write descriptive test names following the convention
- Add PHPDoc explaining what the test does
- Use named arguments throughout
- Run the tests to verify they pass
- 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()orgetManagedObjectDefinition() - Resolve services with
app()->make()- NOTapp()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.
Didn't find tool you were looking for?