---
title: "Testing — Safe Fetch for Laravel | Roundly"
description: "SafeFetch::fake() records every verdict and keeps the real guard; fakeDns() answers lookups from a table; every assertion and the pin in a test."
url: https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel/testing
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Safe Fetch for Laravel](https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel.md)

# Testing

Two helpers, both on the facade. SafeFetch::fake() records every verdict and still performs it — it keeps the real guard, so a private address is still refused. SafeFetch::fakeDns() answers the guard’s lookups from a table. Neither fakes the HTTP requests: answer those with Http::fake().

## SafeFetch::fake() — record every verdict

```php
use RoundlyConsulting\SafeFetch\DataTransferObjects\VettedDestination;
use RoundlyConsulting\SafeFetch\Enums\Reason;

$fake = SafeFetch::fake(['pinned.example' => '151.101.1.1']);   // optional DNS pins
Http::fake(['*' => Http::response('ok')]);

// The code under test:
SafeFetch::fetch('https://feed.example/rss');
Http::safe()->post('https://hooks.example/a', ['event' => 'ping']);
SafeFetch::allows('https://10.0.0.1/');

$fake->assertFetched('https://feed.example/rss');                // or a Closure(string $url): bool
$fake->assertVetted('https://hooks.example/a');                  // or a Closure(RecordedVet): bool
$fake->assertNotVetted('https://other.example/');
$fake->assertRefused('https://10.0.0.1/', Reason::Blocked);      // reason optional
$fake->assertGuardedRequestSent(fn (VettedDestination $hop) => $hop->host === 'hooks.example');
$fake->assertNothingDownloaded();
$fake->recorded();                                               // list<RecordedVet>

SafeFetch::assertRefused('https://10.0.0.1/', Reason::Blocked);  // the same assertions, on the facade
```

- It swaps the manager behind the facade and in the container — Testing\\SafeFetchFake extends SafeFetchManager, so an injected manager is the fake too.
- It records vet(), allows(), precheck() (via precheck, no vetted destination — nothing is resolved), every hop of a guarded request (via http) and every fetch() and download(). URLs match as written or once both are rebuilt the guard’s way.
- Pass \['host' => 'address'\] (or a list of addresses, or \[\] for unresolvable) to pin names. Unmapped names resolve to the public 93.184.216.34; ->strictDns() makes them unresolvable, and ->dns() returns the table.
- A guarded request no Http::fake() stub answered is refused before dialling with a LogicException — fetch() and download() throw it as is, never as a FetchFailed — so a test that forgot Http::fake() never reaches a real server. ->allowRealRequests() lets it through.
- Every assertion is also callable on the facade once the fake is installed.

## Assertions

| Assertion | Passes when |
| --- | --- |
| `assertVetted(string\|Closure $url)` | The URL was judged (vet, allows, precheck, a hop), or a verdict matched the callback. |
| `assertNotVetted(string $url)` | The URL was never judged. |
| `assertRefused(string $url, ?Reason $reason = null)` | The URL was refused (for $reason, when given). |
| `assertNothingRefused()` | No verdict was a refusal — the failure message lists each refused URL and its reason. |
| `assertNothingVetted()` | Nothing was judged. |
| `assertFetched(string\|Closure $url) · assertNothingFetched()` | fetch() was (not) called for it. |
| `assertDownloaded(string\|Closure $url) · assertNothingDownloaded()` | download() was (not) called for it. |
| `assertGuardedRequestSent(?Closure $callback = null)` | A guarded request (Http::safe(), guard(), http(), fetch(), download()) passed the guard on a hop the callback accepts. |

## Recorded verdicts

recorded() returns list<Testing\\RecordedVet>, one per verdict in order — the shape an assertVetted(fn (RecordedVet $vet) => …) callback receives:

| Field | Meaning |
| --- | --- |
| `url (string)` | The URL as it was handed in. |
| `via (string)` | vet, allows, precheck, or http for a hop of a guarded request (Http::safe(), guard(), http(), fetch(), download()). |
| `vetted (?VettedDestination)` | The accepted destination; null for a refusal and for every precheck (nothing is resolved). |
| `reason (?Reason)` | Why it was refused; null when it passed. |
| `refused(): bool` | reason !== null. |

## SafeFetch::fakeDns() — a DNS table

```php
$dns = SafeFetch::fakeDns([
    'hooks.example' => '93.184.216.34',
    'dual.example' => ['93.184.216.34', '2606:4700::1111'],
    'gone.example' => [],                       // does not resolve
]);
$dns->resolve('cdn.example', '151.101.1.1');    // pins add up across calls
$dns->unresolvable('hooks.example');

Http::fake(['hooks.example/*' => Http::response('ok')]);
```

| Method | Does |
| --- | --- |
| `resolve(string $host, string\|array $addresses)` | Pin a name to one or more addresses; pins add up and chain. |
| `unresolvable(string $host)` | The name does not resolve. |
| `lookups()` | The names looked up, in order. |
| `assertLookedUp(string $host, ?int $times = null)` | The name was looked up (exactly $times, when given). |
| `assertNotLookedUp(string $host) · assertNothingLookedUp()` | The name, or no name at all, was looked up. |

- A name the table was not given does not resolve, so a test never reaches real DNS.
- Names are matched the way the guard writes them — lowercase, no trailing dot, punycode.
- Pinning an address literal, a non-name (/?#@:\[\]) or a non-address throws InvalidArgumentException. localhost and \*.localhost may be pinned — a trusted private host is looked up.
- fakeDns() and $fake->dns() return the same Testing\\FakeHostResolver; the table lives in the container and ends with the test.

## See the pin in a test

Laravel hands a stub callback the request options, so a test can see the pin:

```php
$dns = SafeFetch::fakeDns(['hooks.example.com' => '93.184.216.34', 'internal.example.com' => '10.0.0.5']);
Http::fake(fn (Request $request, array $options) => Http::response($options['curl'][CURLOPT_RESOLVE][0]));

$pinned = $safeFetch->guard(Http::timeout(10), $policy)->post('https://hooks.example.com/in', ['event' => 'ping'])->body();

$dns->assertLookedUp('hooks.example.com', 1);                    // $pinned === 'hooks.example.com:443:93.184.216.34'
```

## Exceptions in your own doubles

The named constructors (see Verdicts and exceptions) let a test double throw exactly what the package throws.

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel.md)
