php-test-writer

PHP Test Writer Skill

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "php-test-writer" with this command: npx skills add rasmusgodske/dev-agent-workflow/rasmusgodske-dev-agent-workflow-php-test-writer

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.

// ✅ 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() )

// ❌ 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 } }

  1. 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

  1. Test Method Naming

Test Method Naming - CRITICAL Pattern

ALWAYS use the test_ prefix. DO NOT use the #[Test] attribute.

// ✅ 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}

// ✅ 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:

/**

  • Test that updating a DataObject dispatches DataObjectReceived event */ public function test_update_dispatches_data_object_received_event() { // Test implementation }
  1. 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']);

}

  1. 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()

  1. 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');

  1. 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());

  1. 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()

// ✅ 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

// ✅ 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);

  1. 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');

  1. 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' => [''], ]; }

  1. 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() 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.

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

linear-project-management

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

frontend-developer

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

backend-developer

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

openclaw-version-monitor

监控 OpenClaw GitHub 版本更新,获取最新版本发布说明,翻译成中文, 并推送到 Telegram 和 Feishu。用于:(1) 定时检查版本更新 (2) 推送版本更新通知 (3) 生成中文版发布说明

Archived SourceRecently Updated