Testing Guide
Version: 1.0.0
This guide covers the testing strategy, running tests, and writing new tests for notifito.
Test Structure
Test Types
| Type | Purpose | Location | Framework |
|---|---|---|---|
| Unit | Pure functions, value objects | tests/Unit/ |
Pest |
| Feature | HTTP endpoints, database | tests/Feature/ |
Pest |
| Contract | Event payloads, shared interfaces | packages/contracts/tests/ |
Pest |
| E2E | Full loop verification | tests/e2e/ |
Pest + Guzzle |
| Browser | Widget UI testing | widget/tests/browser/ |
Playwright |
Test Organization
notifito/
├── packages/contracts/tests/
│ ├── Bus/
│ │ └── EventBusTest.php
│ ├── Catalog/
│ │ ├── AttributeDefinitionTest.php
│ │ ├── AttributeSchemaTest.php
│ │ └── AttributeBagValidatorTest.php
│ └── Events/
│ └── PayloadContractTest.php
├── apps/core/tests/
│ ├── Unit/
│ │ └── Matching/
│ │ ├── AttributeMatcherScalarTest.php
│ │ ├── AttributeMatcherNumericTest.php
│ │ └── AttributeMatcherDateTest.php
│ └── Feature/
│ ├── Tenancy/
│ │ ├── ApiKeyTest.php
│ │ └── TenantTest.php
│ ├── Catalog/
│ │ ├── ItemTypeTest.php
│ │ └── SchemaRegistryTest.php
│ ├── Subscriptions/
│ │ ├── SubscribeActionTest.php
│ │ ├── ConfirmActionTest.php
│ │ └── UnsubscribeActionTest.php
│ ├── Matching/
│ │ ├── SubscriptionMatcherTest.php
│ │ └── MatchAvailabilityEventHandlerTest.php
│ └── Internal/
│ ├── AuthTest.php
│ └── InternalApiTest.php
├── apps/edge/tests/
│ └── Feature/
│ ├── ApiKeyAuthTest.php
│ ├── WidgetApiTest.php
│ ├── PushApiTest.php
│ └── ConsentPagesTest.php
├── apps/dispatch/tests/
│ └── Feature/
│ ├── Channels/
│ │ └── PostmarkEmailDriverTest.php
│ ├── Delivery/
│ │ ├── DeliveryStateMachineTest.php
│ │ ├── SendHandlersTest.php
│ │ └── PostmarkWebhookTest.php
│ └── Templates/
│ └── TemplateRendererTest.php
├── apps/dashboard/tests/
│ └── Feature/
│ └── AuthTest.php
├── tests/e2e/
│ ├── LoopTest.php
│ └── Support/
│ └── Loop.php
└── widget/tests/
├── browser/
│ └── widget.spec.mjs
└── form.test.mjs
Running Tests
Quick Reference
# Run all tests
./bin/test packages/contracts
./bin/test apps/core
./bin/test apps/edge
./bin/test apps/dispatch
./bin/test apps/dashboard
# Run specific test file
./bin/test apps/core tests/Feature/Tenancy/ApiKeyTest.php
# Run tests matching pattern
./bin/test apps/core --filter="ApiKey"
# Run with coverage
./bin/test apps/core --coverage
# Run E2E tests
./bin/e2e
# Run widget tests
cd widget && npm test
Contracts Package
# Run all contract tests
./bin/test packages/contracts
# Expected output:
# Tests: 59 passed (149 assertions)
# Duration: 0.24s
Core Application
# Run all core tests
./bin/test apps/core
# Expected output:
# Tests: 243 passed (410 assertions)
# Duration: 5.87s
Edge Application
# Run all edge tests
./bin/test apps/edge
# Expected output:
# Tests: 42 passed (70 assertions)
# Duration: 1.41s
Dispatch Application
# Run all dispatch tests
./bin/test apps/dispatch
# Expected output:
# Tests: 65 passed (123 assertions)
# Duration: 2.14s
Dashboard Application
# Run all dashboard tests
./bin/test apps/dashboard
# Expected output:
# Tests: 7 passed (19 assertions)
# Duration: 0.44s
End-to-End Tests
# Run E2E tests (requires full stack running)
./bin/e2e
# Expected output:
# Tests: 6 passed
# Duration: ~2 minutes
Widget Tests
# Navigate to widget directory
cd widget
# Install dependencies
npm install
# Run unit tests
npm test
# Run browser tests
npm run test:browser
# Build and test
npm run build && npm test
Writing Tests
Test Conventions
- Use Pest syntax: All tests use Pest PHP syntax
- One assertion per test: Keep tests focused
- Descriptive names: Test names should describe behavior
- Use datasets: For testing multiple similar cases
- Use factories: For creating test data
- Mock external services: Never make real API calls
Unit Test Example
<?php
declare(strict_types=1);
use Notifito\Contracts\Catalog\AttributeDefinition;
use Notifito\Contracts\Catalog\AttributeType;
it('exposes exactly the seven types the spec allows', function () {
$values = array_map(fn (AttributeType $t) => $t->value, AttributeType::cases());
expect($values)->toEqualCanonicalizing([
'enum', 'integer', 'decimal', 'date', 'date_range', 'boolean', 'text',
]);
});
it('builds an enum definition from the array form', function () {
$definition = AttributeDefinition::fromArray([
'key' => 'size',
'label' => 'Size',
'type' => 'enum',
'options' => ['40', '41', '42'],
'required' => true,
'matchable' => true,
]);
expect($definition->key)->toBe('size')
->and($definition->type)->toBe(AttributeType::Enum)
->and($definition->options)->toBe(['40', '41', '42']);
});
Feature Test Example
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('authenticates a seller and returns their tenant', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$response = $this->postJson('/internal/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$response->assertOk()
->assertJsonStructure(['user', 'tenant']);
});
it('rejects a wrong password', function () {
$user = User::factory()->create();
$response = $this->postJson('/internal/auth/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$response->assertStatus(401);
});
Livewire Test Example
<?php
declare(strict_types=1);
use App\Livewire\ItemTypeEditor;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
beforeEach(function () {
config()->set('notifito.core_url', 'http://core.test');
$this->post('/login', ['email' => 'owner@shop.test', 'password' => 'x']);
});
it('loads an existing item type into the editor', function () {
Http::fake([
'http://core.test/internal/tenants/ten-1/item-types/it-1' => Http::response([
'id' => 'it-1',
'name' => 'Sneaker',
'slug' => 'sneaker',
'schema_version' => 3,
'attribute_schema' => [
['key' => 'size', 'label' => 'Size', 'type' => 'enum',
'options' => ['40', '41'], 'required' => true],
],
]),
]);
Livewire::test(ItemTypeEditor::class, ['ref' => 'it-1'])
->assertSet('name', 'Sneaker')
->assertSet('attributes.0.key', 'size');
});
E2E Test Example
<?php
declare(strict_types=1);
use Notifito\E2E\Loop;
beforeEach(function () {
$this->loop = new Loop();
$this->loop->reset();
});
it('runs the whole loop for a structurally different tenant', function () {
$tenant = $this->loop->credentials('sneaker-store');
$email = 'ada+'.bin2hex(random_bytes(4)).'@example.test';
// 1. Fetch widget schema
$schema = $this->loop->widgetSchema($tenant, 'sneaker');
expect($schema['item_type']['slug'])->toBe('sneaker');
// 2. Subscribe
$this->loop->subscribe($tenant, 'sneaker', ['size' => '42'], $email);
// 3. Confirm
$this->loop->drain();
$confirmation = $this->loop->awaitMessage($email);
$this->loop->visit($this->loop->extractLink($confirmation['TextBody'], 'c'));
// 4. Push availability
$this->loop->pushAvailability($tenant, 'sneaker', ['size' => '42']);
$this->loop->drain();
// 5. Verify notification
expect($this->loop->messagesFor($email))->toHaveCount(2);
});
Test Data
Factories
Use factories to create test data:
// Create a tenant
$tenant = Tenant::factory()->create();
// Create a user
$user = User::factory()->create(['tenant_id' => $tenant->id]);
// Create an API key
$apiKey = ApiKey::factory()->create(['tenant_id' => $tenant->id]);
// Create an item type
$itemType = ItemType::factory()->create(['tenant_id' => $tenant->id]);
Seeded Data
For E2E tests, use the seeded tenants:
$credentials = json_decode(
$this->artisan('core', 'notifito:seed-credentials'),
true
);
$sneakerStore = $credentials['sneaker-store'];
// $sneakerStore['tenant_id']
// $sneakerStore['publishable_key']
// $sneakerStore['secret_key']
Mocking
HTTP Mocking
use Illuminate\Support\Facades\Http;
// Mock specific endpoints
Http::fake([
'http://core.test/internal/auth/login' => Http::response([
'user' => ['id' => 'u-1', 'name' => 'Owner'],
'tenant' => ['id' => 'ten-1', 'name' => 'Store'],
]),
]);
// Mock all requests
Http::fake(fn ($request) => Http::response(['ok' => true]));
// Assert requests were made
Http::assertSent(fn ($request) => $request->url() === 'http://core.test/...');
Event Mocking
use Notifito\Contracts\Bus\FakeEventBus;
$bus = new FakeEventBus();
$bus->publish($event);
// Assert event was published
$bus->assertPublished('availability.received');
// Get all published events
$events = $bus->published('availability.received');
Queue Mocking
use Illuminate\Support\Facades\Queue;
Queue::fake();
// Assert job was dispatched
Queue::assertPushed(SendDeliveryJob::class);
Test Coverage
Generate Coverage Report
# Run with coverage
./bin/test apps/core --coverage
# Generate HTML report
./bin/test apps/core --coverage-html=coverage/
# Generate Clover report (for CI)
./bin/test apps/core --coverage-clover=coverage.xml
Coverage Targets
| Component | Target | Current |
|---|---|---|
| Contracts | 90% | 95% |
| Core Domain | 85% | 88% |
| Edge API | 80% | 82% |
| Dispatch | 80% | 85% |
| Dashboard | 70% | 75% |
Continuous Integration
GitHub Actions
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker images
run: docker compose build
- name: Start services
run: docker compose up -d postgres redis
- name: Run doctor
run: ./bin/doctor
- name: Install dependencies
run: |
./bin/dev composer install --working-dir=packages/contracts
for app in core edge dispatch dashboard; do
./bin/dev composer install --working-dir="apps/$app"
done
- name: Run migrations
run: |
./bin/artisan core migrate --force
./bin/artisan dispatch migrate --force
- name: Run tests
run: |
./bin/test packages/contracts
./bin/test apps/core
./bin/test apps/edge
./bin/test apps/dispatch
./bin/test apps/dashboard
- name: Build widget
run: |
cd widget
npm ci
npm test
npm run build
Debugging Tests
Running Specific Tests
# Run specific test file
./bin/test apps/core tests/Feature/Tenancy/ApiKeyTest.php
# Run specific test by name
./bin/test apps/core --filter="it issues a publishable key"
# Run tests matching pattern
./bin/test apps/core --filter="ApiKey"
# Run with verbose output
./bin/test apps/core -v
Debugging Failed Tests
# Run with stop on failure
./bin/test apps/core --stop-on-failure
# Run with detailed output
./bin/test apps/core --verbose
# Check test logs
docker compose logs core
Common Issues
Database not reset:
# Fresh migration
./bin/artisan core migrate:fresh
Cache issues:
# Clear all caches
./bin/artisan core optimize:clear
Queue issues:
# Restart queue workers
./bin/artisan core queue:restart
Related Documentation
- Development Setup — Local environment setup
- Contributing — Code style and workflow
- Configuration Reference — Environment variables