Testing

Testing

Fuse ships helpers under Milon\Fuse\Testing for PHPUnit and Saloon. They are optional; use them when you want less boilerplate around open circuits and mocks.

Assert and control state

InteractsWithFuse adds methods to a PHPUnit test case. FuseAssertions is the same API as static methods.

use Milon\Fuse\CircuitBreakerConfig;
use Milon\Fuse\Fuse;
use Milon\Fuse\FuseFactory;
use Milon\Fuse\Stores\ArrayStore;
use Milon\Fuse\Testing\InteractsWithFuse;
use PHPUnit\Framework\TestCase;

final class BillingCircuitTest extends TestCase
{
    use InteractsWithFuse;

    public function test_force_open_rejects_calls(): void
    {
        $fuse = Fuse::for(
            'billing',
            new ArrayStore,
            new CircuitBreakerConfig(failureThreshold: 1),
        );

        $this->assertCircuitClosed($fuse);
        $this->forceCircuitOpen($fuse);
        $this->assertCircuitOpen($fuse);

        $this->resetCircuit($fuse);
        $this->assertCircuitClosed($fuse);
    }

    public function test_named_circuit_via_factory(): void
    {
        $factory = new FuseFactory(new ArrayStore);

        $this->forceCircuitOpenFor($factory, 'billing', operation: 'charge');
        $this->assertCircuitOpenFor($factory, 'billing', operation: 'charge');
    }
}
Method What it does
assertCircuitOpen / Closed / HalfOpen Assert state on a Fuse or CircuitBreaker
assertCircuitOpenFor / ClosedFor Same, via FuseFactory / FuseManager + name
forceCircuitOpen / forceCircuitClosed / resetCircuit Mutate state in a test
forceCircuitOpenFor / resetCircuitFor Mutate a named circuit on a factory

Saloon mocks and open rejects

Saloon's mock middleware runs before Fuse. A send() that should throw CircuitOpenException still needs a spare MockResponse on the sequence, or Saloon throws NoMockResponseFoundException first.

FuseMockClient::mock() appends that spare for you (default one spare, status 200):

use Milon\Fuse\CircuitBreakerConfig;
use Milon\Fuse\CircuitOpenException;
use Milon\Fuse\Contracts\CircuitBreakerStore;
use Milon\Fuse\FuseFactory;
use Milon\Fuse\Saloon\Traits\HasCircuitBreaker;
use Milon\Fuse\Saloon\Traits\HasCircuitBreakerOperation;
use Milon\Fuse\Stores\ArrayStore;
use Milon\Fuse\Testing\FuseMockClient;
use Milon\Fuse\Testing\InteractsWithFuse;
use PHPUnit\Framework\TestCase;
use Saloon\Enums\Method;
use Saloon\Http\Connector;
use Saloon\Http\Faking\MockResponse;
use Saloon\Http\Request;

final class BillingConnectorTest extends TestCase
{
    use InteractsWithFuse;

    public function test_open_circuit_rejects_without_hitting_the_network(): void
    {
        $factory = new FuseFactory(new ArrayStore, [
            'breakers' => [
                'billing' => ['failure_threshold' => 1],
            ],
        ]);

        $connector = new class($factory) extends Connector
        {
            use HasCircuitBreaker;

            public function __construct(private FuseFactory $factory) {}

            public function resolveBaseUrl(): string
            {
                return 'https://billing.example.com';
            }

            protected function resolveCircuitBreakerStore(): CircuitBreakerStore
            {
                return $this->factory->store();
            }

            protected function resolveCircuitBreakerConfig(): CircuitBreakerConfig
            {
                return $this->factory->configFor($this->resolveCircuitBreakerName());
            }
        };

        FuseMockClient::mock($connector, [
            MockResponse::make(body: 'down', status: 503),
        ]);

        $connector->send(new ChargeRequest);
        $this->assertCircuitOpenFor($factory, 'billing', operation: 'charge');

        $this->expectException(CircuitOpenException::class);
        $connector->send(new ChargeRequest);
    }
}

final class ChargeRequest extends Request
{
    use HasCircuitBreakerOperation;

    protected Method $method = Method::POST;

    public function resolveEndpoint(): string
    {
        return '/charge';
    }
}

FuseMockClient::sequence($responses, openRejectSpares: 2) builds a MockClient without attaching it. Pass spare: when the padded response should not be a 200.

In Laravel tests, assertCircuitOpenFor(app(FuseManager::class), 'billing') works the same way because FuseManager extends FuseFactory.

Edit this page