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

  1. Use Pest syntax: All tests use Pest PHP syntax
  2. One assertion per test: Keep tests focused
  3. Descriptive names: Test names should describe behavior
  4. Use datasets: For testing multiple similar cases
  5. Use factories: For creating test data
  6. 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