- Remove middleware reference from Gitea Traefik labels (caused routing issues) - Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s) - Add explicit service reference in Traefik labels - Fix intermittent 504 timeouts by improving PostgreSQL connection handling Fixes Gitea unreachability via git.michaelschiemer.de
13 KiB
LiveComponents Test Harness
Comprehensive test harness für LiveComponents mit ComponentTestCase trait und ComponentFactory.
Übersicht
Der Test-Harness bietet:
- ComponentTestCase trait: Umfassende Test-Helper-Methoden
- ComponentFactory: Builder-Pattern für Test-Component-Erstellung
- Automatische Setup: CSRF, Authorization, State Validation Integration
- Assertions: State, Action, Authorization und Event Assertions
Quick Start
<?php
use Tests\Framework\LiveComponents\ComponentFactory;
use Tests\Framework\LiveComponents\ComponentTestCase;
// Use ComponentTestCase trait
uses(ComponentTestCase::class);
// Setup before each test
beforeEach(function () {
$this->setUpComponentTest();
});
it('executes component action', function () {
$component = ComponentFactory::counter(initialCount: 5);
$result = $this->callAction($component, 'increment');
expect($result->state->data['count'])->toBe(6);
});
ComponentTestCase Trait
Setup Methode
setUpComponentTest() - Initialisiert Test-Environment:
- Erstellt Session mit CSRF-Token-Generator
- Initialisiert LiveComponentHandler mit allen Dependencies (CSRF, Auth, Validation)
- Setzt EventDispatcher, AuthorizationChecker, StateValidator, SchemaCache auf
beforeEach(function () {
$this->setUpComponentTest();
});
Authentication Helper
actingAs(array $permissions = [], int $userId = 1) - Mock authenticated user:
$this->actingAs(['posts.edit', 'posts.delete']);
$result = $this->callAction($component, 'deletePost', ['id' => 123]);
Action Execution
callAction(LiveComponentContract $component, string $method, array $params = []) - Execute action with automatic CSRF:
$component = ComponentFactory::counter();
// Automatic CSRF token generation
$result = $this->callAction($component, 'increment');
// With parameters
$result = $this->callAction($component, 'addItem', ['item' => 'New Task']);
Action Assertions
assertActionExecutes() - Assert action executes successfully:
$result = $this->assertActionExecutes($component, 'increment');
expect($result->state->data['count'])->toBe(1);
assertActionThrows() - Assert action throws exception:
$component = ComponentFactory::make()
->withId('error:component')
->withState(['data' => 'test'])
->withAction('fail', function() {
throw new \RuntimeException('Expected error');
})
->create();
$this->assertActionThrows($component, 'fail', \RuntimeException::class);
assertActionRequiresAuth() - Assert action requires authentication:
// Note: Requires real component class with #[RequiresPermission] attribute
// ComponentFactory closures don't support attributes
$this->assertActionRequiresAuth($component, 'protectedAction');
assertActionRequiresPermission() - Assert action requires specific permission:
$this->actingAs(['posts.view']); // Insufficient permission
$this->assertActionRequiresPermission(
$component,
'deletePost',
['posts.view'] // Should fail with only 'view' permission
);
State Assertions
assertStateEquals(ComponentUpdate $result, array $expected) - Assert state matches expected:
$result = $this->callAction($component, 'increment');
$this->assertStateEquals($result, ['count' => 1]);
assertStateHas(ComponentUpdate $result, string $key) - Assert state has key:
$this->assertStateHas($result, 'items');
assertStateValidates(ComponentUpdate $result) - Assert state passes validation:
$result = $this->callAction($component, 'updateData', ['value' => 'test']);
$this->assertStateValidates($result);
getStateValue(ComponentUpdate $result, string $key) - Get specific state value:
$count = $this->getStateValue($result, 'count');
expect($count)->toBe(5);
Event Assertions
assertEventDispatched(ComponentUpdate $result, string $eventName) - Assert event was dispatched:
$result = $this->callAction($component, 'submitForm');
$this->assertEventDispatched($result, 'form:submitted');
assertNoEventsDispatched(ComponentUpdate $result) - Assert no events were dispatched:
$result = $this->callAction($component, 'increment');
$this->assertNoEventsDispatched($result);
assertEventCount(ComponentUpdate $result, int $count) - Assert event count:
$result = $this->callAction($component, 'bulkOperation');
$this->assertEventCount($result, 3);
ComponentFactory
Builder Pattern
ComponentFactory::make() - Start builder:
$component = ComponentFactory::make()
->withId('posts:manager')
->withState(['posts' => [], 'count' => 0])
->withAction('addPost', function(string $title) {
$this->state['posts'][] = $title;
$this->state['count']++;
return ComponentData::fromArray($this->state);
})
->create();
Builder Methods
withId(string $id)- Set component IDwithState(array $state)- Set initial state (cannot be empty!)withAction(string $name, callable $handler)- Add custom actionwithTemplate(string $template)- Set template namecreate()- Create component instance
Pre-configured Components
ComponentFactory::counter(int $initialCount = 0) - Counter component:
$component = ComponentFactory::counter(initialCount: 5);
// Actions: increment, decrement, reset
$result = $this->callAction($component, 'increment');
expect($result->state->data['count'])->toBe(6);
ComponentFactory::list(array $initialItems = []) - List component:
$component = ComponentFactory::list(['item1', 'item2']);
// Actions: addItem, removeItem, clear
$result = $this->callAction($component, 'addItem', ['item' => 'item3']);
expect($result->state->data['items'])->toHaveCount(3);
Integration Features
Automatic CSRF Protection
// CSRF token automatically generated and validated
$result = $this->callAction($component, 'action');
// CSRF token: 'livecomponent:{componentId}' form ID
Automatic State Validation
// State automatically validated against derived schema
$result = $this->callAction($component, 'updateState');
// Schema derived on first getData() call
// Cached for subsequent validations
Authorization Integration
// Mock authenticated user with permissions
$this->actingAs(['admin.access']);
// Authorization automatically checked for #[RequiresPermission] attributes
$result = $this->callAction($component, 'adminAction');
Best Practices
State Must Not Be Empty
// ❌ Empty state causes schema derivation error
$component = ComponentFactory::make()
->withState([])
->create();
// ✅ Always provide at least one state field
$component = ComponentFactory::make()
->withState(['initialized' => true])
->create();
Authorization Testing Requires Real Classes
// ❌ Closures don't support attributes for authorization
$component = ComponentFactory::make()
->withAction('protectedAction',
#[RequiresPermission('admin')] // Attribute wird ignoriert
function() { }
)
->create();
// ✅ Use real component class for authorization testing
final readonly class TestComponent implements LiveComponentContract
{
#[RequiresPermission('admin')]
public function protectedAction(): ComponentData
{
// Implementation
}
}
Action Closures Have Access to Component State
$component = ComponentFactory::make()
->withState(['count' => 0])
->withAction('increment', function() {
// $this->state available via closure binding
$this->state['count']++;
return ComponentData::fromArray($this->state);
})
->create();
Multiple Actions in Sequence
it('handles multiple actions', function () {
$component = ComponentFactory::counter();
$result1 = $this->callAction($component, 'increment');
$result2 = $this->callAction($component, 'increment');
$result3 = $this->callAction($component, 'decrement');
// Note: Component state is immutable
// Each call returns new state, doesn't mutate original
expect($result1->state->data['count'])->toBe(1);
expect($result2->state->data['count'])->toBe(2);
expect($result3->state->data['count'])->toBe(1);
});
Test Organization
tests/
├── Framework/LiveComponents/
│ ├── ComponentTestCase.php # Trait with helper methods
│ └── ComponentFactory.php # Builder for test components
└── Feature/Framework/LiveComponents/
├── TestHarnessDemo.php # Demo of all features
├── SimpleTestHarnessTest.php # Simple examples
└── ExceptionTestHarnessTest.php # Exception handling examples
Complete Example
<?php
use Tests\Framework\LiveComponents\ComponentFactory;
use Tests\Framework\LiveComponents\ComponentTestCase;
uses(ComponentTestCase::class);
beforeEach(function () {
$this->setUpComponentTest();
});
describe('Shopping Cart Component', function () {
it('adds items to cart', function () {
$component = ComponentFactory::make()
->withId('shopping-cart')
->withState(['items' => [], 'total' => 0])
->withAction('addItem', function(string $product, int $price) {
$this->state['items'][] = ['product' => $product, 'price' => $price];
$this->state['total'] += $price;
return ComponentData::fromArray($this->state);
})
->create();
$result = $this->callAction($component, 'addItem', [
'product' => 'Laptop',
'price' => 999
]);
$this->assertStateHas($result, 'items');
expect($result->state->data['items'])->toHaveCount(1);
expect($result->state->data['total'])->toBe(999);
});
it('requires authentication for checkout', function () {
$component = ComponentFactory::make()
->withId('shopping-cart')
->withState(['items' => [['product' => 'Laptop', 'price' => 999]]])
->withAction('checkout', function() {
// Checkout logic
return ComponentData::fromArray($this->state);
})
->create();
// Without authentication
// Note: For authorization testing, use real component classes
// With authentication
$this->actingAs(['checkout.access']);
$result = $this->assertActionExecutes($component, 'checkout');
});
});
Known Limitations
1. Closure Attributes
Attributes on closures passed to withAction() are not supported for authorization checks:
// ❌ Doesn't work - attribute ignored
$component = ComponentFactory::make()
->withAction('protectedAction',
#[RequiresPermission('admin')]
function() { }
)
->create();
Workaround: Create real component class for authorization testing.
2. Empty State Not Allowed
Components must have at least one state field for schema derivation:
// ❌ Throws InvalidArgumentException: 'Schema cannot be empty'
$component = ComponentFactory::make()
->withState([])
->create();
// ✅ Provide at least one field
$component = ComponentFactory::make()
->withState(['initialized' => true])
->create();
3. Magic Method Reflection
ComponentFactory uses __call() for actions, which limits reflection-based parameter analysis. The handler falls back to direct parameter passing for magic methods.
Performance Considerations
- Schema Caching: Schema derived once per component class and cached
- CSRF Generation: CSRF token generated per test, not reused
- Session State: Session state reset in
setUpComponentTest() - Event Dispatcher: Events collected per action call, not persisted
Troubleshooting
"Method not found" Error
BadMethodCallException: Method increment not found on component
Fix: Ensure method_exists() check supports __call() magic methods:
// LiveComponentHandler checks both real and magic methods
if (!method_exists($component, $method) && !is_callable([$component, $method])) {
throw new \BadMethodCallException(...);
}
"Schema cannot be empty" Error
InvalidArgumentException: Schema cannot be empty
Fix: Provide non-empty state:
// ❌ Empty state
->withState([])
// ✅ Non-empty state
->withState(['data' => 'test'])
Reflection Exception for Actions
ReflectionException: Method increment() does not exist
Fix: Handler catches ReflectionException and falls back to direct call:
try {
$reflection = new \ReflectionMethod($component, $method);
// Parameter analysis
} catch (\ReflectionException $e) {
// Direct call for magic methods
return $component->$method(...$params->toArray());
}
Summary
Der Test-Harness bietet:
- ✅ Einfache Component-Erstellung via ComponentFactory
- ✅ Umfassende Assertions für State, Actions, Events
- ✅ Automatische Integration mit CSRF, Auth, Validation
- ✅ Flexible Test-Components via Builder Pattern
- ✅ Pre-configured Components (Counter, List)
- ⚠️ Known Limitations mit Closure-Attributes
Framework-Integration: Vollständig integriert mit LiveComponentHandler, StateValidator, AuthorizationChecker und EventDispatcher.