Steam::fake() attaches Testing\SteamFake — a Saloon MockClient — to the connector: your tests never hit the real Steam Web API, and assert on what they sent through the facade itself.

Fake each endpoint through the resource your code reaches it by, with the payload built from the DTO factories:

use Fkrzski\LaravelSteamApiSdk\Facades\Steam;
use Fkrzski\LaravelSteamApiSdk\Testing\Factories\PlayerSummaryFactory;
Steam::fake()
->users()->summaries(
PlayerSummaryFactory::new()->personaName('Gabe')->online(),
)
->stats()->currentPlayers(12_345);
$summaries = Steam::users()->summaries([$steamId]);

Each method registers the response for the request class its endpoint sends and hands the mock back, so the chain moves resource by resource. Faking the same endpoint twice keeps the second.

A method wrapping a collection takes as many factories as you want — an empty call fakes an empty result. The rest take exactly what their envelope carries: one container factory, a plain number, a SteamId, or a moment:

$fake = Steam::fake();
$fake->users()->summaries(); // no players matched
$fake->users()->friends($first, $second, $third); // three friends
$fake->players()->badges(PlayerBadgesFactory::new()); // the badges envelope
$fake->players()->steamLevel(42); // a level, no factory
$fake->stats()->currentPlayers(12_345); // a head count, no factory
$fake->webApi()->serverInfo(now()); // Steam's clock, no factory

It sits on the connector, so it covers every call through the facade — resources, newsFeed(), pool() and send() alike — and goes away with the request that installed it.

Steam::fake() also takes a map of request class → response, for a request of your own sent through send(), or a MockResponse the chain does not build. SteamResponse holds the builder behind every method, and the map combines with a chain:

use Fkrzski\LaravelSteamApiSdk\Testing\SteamResponse;
use Fkrzski\SteamApiSdk\Http\Requests\ISteamUser\GetPlayerBansRequest;
Steam::fake([
GetPlayerBansRequest::class => SteamResponse::playerBans(
PlayerBanFactory::new()->vacBanned(),
),
])->users()->summaries();
Chain Builder Request
->users()->summaries() SteamResponse::playerSummaries() GetPlayerSummariesRequest
->users()->bans() SteamResponse::playerBans() GetPlayerBansRequest
->users()->friends() SteamResponse::friendList() GetFriendListRequest
->users()->groups() SteamResponse::userGroupList() GetUserGroupListRequest
->users()->resolveVanityUrl() SteamResponse::vanityUrl() ResolveVanityUrlRequest
->players()->ownedGames() SteamResponse::ownedGames() GetOwnedGamesRequest
->players()->recentlyPlayedGames() SteamResponse::recentlyPlayedGames() GetRecentlyPlayedGamesRequest
->players()->steamLevel() SteamResponse::steamLevel() GetSteamLevelRequest
->players()->badges() SteamResponse::badges() GetBadgesRequest
->players()->communityBadgeProgress() SteamResponse::communityBadgeProgress() GetCommunityBadgeProgressRequest
->stats()->userStats() SteamResponse::userStats() GetUserStatsForGameRequest
->stats()->achievements() SteamResponse::playerAchievements() GetPlayerAchievementsRequest
->stats()->currentPlayers() SteamResponse::currentPlayers() GetNumberOfCurrentPlayersRequest
->stats()->globalAchievements() SteamResponse::globalAchievements() GetGlobalAchievementPercentagesForAppRequest
->stats()->schema() SteamResponse::gameSchema() GetSchemaForGameRequest
->apps()->upToDateCheck() SteamResponse::upToDateCheck() UpToDateCheckRequest
->apps()->serversAtAddress() SteamResponse::serversAtAddress() GetServersAtAddressRequest
->apps()->sdrConfig() SteamResponse::sdrConfig() GetSdrConfigRequest
->news()->appNews() SteamResponse::appNews() GetNewsForAppRequest
->news()->newsFeed() SteamResponse::newsFeed() GetNewsForAppRequest
->webApi()->serverInfo() SteamResponse::serverInfo() GetServerInfoRequest
->webApi()->supportedApiList() SteamResponse::supportedApiList() GetSupportedApiListRequest

Every DTO the facade returns has a factory for the payload Steam sends. ::new() gives a complete, realistic record you can narrow:

use Fkrzski\LaravelSteamApiSdk\Testing\Factories\PlayerSummaryFactory;
PlayerSummaryFactory::new()->toArray(); // the raw payload
PlayerSummaryFactory::new()->make(); // the PlayerSummary DTO

Reach for make() when a test needs the DTO directly — seeding a repository, or exercising a method that takes a PlayerSummary with no HTTP in the picture.

Named states cover the variations worth naming, and compose:

PlayerSummaryFactory::new()->private(); // hidden profile
PlayerSummaryFactory::new()->friendsOnly(); // friends-only profile
PlayerSummaryFactory::new()->online()->inGame('730', 'CS2'); // online, in a game
PlayerBanFactory::new()->vacBanned(2)->communityBanned();
OwnedGameFactory::new()->withAppInfo()->neverPlayed();
RecentlyPlayedGamesFactory::new()->nothingPlayed();
BadgeFactory::new()->communityItem()->withoutApp();
CommunityBadgeQuestFactory::new()->incomplete();
PlayerAchievementFactory::new()->locked();
UserStatsFactory::new()->empty();
SchemaAchievementFactory::new()->hidden()->withoutDescription();
GameSchemaFactory::new()->empty();
AppVersionCheckFactory::new()->outOfDate(requiredVersion: 10828683);
GameServerFactory::new()->insecure()->withoutSpectatorPort();
SdrPointOfPresenceFactory::new()->withoutRelays();
NewsItemFactory::new()->communityAnnouncement()->internalUrl()->withoutAuthor();
WebApiMethodFactory::new()->post()->withoutDescription();
WebApiParameterFactory::new()->optional()->emptyDescription();

private() withholds everything Steam does on a hidden profile, the creation date included — the summary it builds carries a null timeCreated. friendsOnly() withholds the same, and reads back as CommunityVisibility::FriendsOnly.

empty() on GameSchemaFactory builds what an app publishing no schema answers with: Steam drops every key, so the DTO comes back with a null gameName and two empty lists. unnamed() is the softer case — GameSchemaFactory and SchemaStatFactory both send the name as the empty string Steam really sends, which the DTO reads back as null.

withoutSpectatorPort() sends the 0 Steam sends for a server without SourceTV, which the DTO reads back as null, and withoutAuthor() does the same with the '' of a post without a byline. withoutRelays() drops the key the way Steam does. A Web API description goes missing both ways: withoutDescription() drops the key, as Steam does on most methods, and emptyDescription() sends the '' some parameters carry, and the DTO reads either as null.

state() covers the rest — raw payload keys merged over the defaults:

PlayerSummaryFactory::new()->state(['profileurl' => 'https://example.test/']);

The container factories take the item factories:

UserStatsFactory::new()->stats(
UserStatFactory::new()->name('DBD_KillerSkulls')->value(1200),
);
PlayerAchievementsFactory::new()->achievements(
PlayerAchievementFactory::new(),
PlayerAchievementFactory::new()->locked(),
);
GameSchemaFactory::new()->stats(
SchemaStatFactory::new()->apiName('DBD_KillerSkulls'),
)->achievements(
SchemaAchievementFactory::new()->apiName('ACH_ESCAPE'),
);
SdrConfigFactory::new()->pointsOfPresence(
SdrPointOfPresenceFactory::new()->code('waw')->latitude(52.23)->longitude(21.01)->relays(
SdrRelayFactory::new()->ipv4('155.133.240.10')->ports(27015, 27060),
),
);
WebApiInterfaceFactory::new()->name('ISteamUser')->methods(
WebApiMethodFactory::new()->name('GetPlayerSummaries')->version(2)->parameters(
WebApiParameterFactory::new()->name('steamids'),
),
);

A point of presence carries its code into the key of pops rather than the payload, and latitude() and longitude() write geo in Valve’s [longitude, latitude] order — set them by name rather than through state().

RecentlyPlayedGamesFactory carries a figure of its own: games() syncs total_count to what it lists, so totalCount() comes after it to fake the window Steam counted more games in than it returned:

RecentlyPlayedGamesFactory::new()->games(
RecentlyPlayedGameFactory::new()->name('Dead by Daylight')->playedRecently(240),
)->totalCount(12);

AppNewsFactory does the same with total(), so a test can fake page one of many:

Steam::fake()->news()->appNews(
AppNewsFactory::new()->items(
NewsItemFactory::new()->title('Patch notes'),
)->total(250),
);

Available factories: PlayerSummaryFactory, PlayerBanFactory, FriendFactory, UserGroupFactory, OwnedGameFactory, RecentlyPlayedGameFactory, RecentlyPlayedGamesFactory, BadgeFactory, PlayerBadgesFactory, CommunityBadgeQuestFactory, UserStatFactory, UserStatAchievementFactory, UserStatsFactory, PlayerAchievementFactory, PlayerAchievementsFactory, GlobalAchievementFactory, GameSchemaFactory, SchemaStatFactory, SchemaAchievementFactory, AppVersionCheckFactory, GameServerFactory, SdrConfigFactory, SdrPointOfPresenceFactory, SdrRelayFactory, AppNewsFactory, NewsItemFactory, WebApiInterfaceFactory, WebApiMethodFactory and WebApiParameterFactory, all under Fkrzski\LaravelSteamApiSdk\Testing\Factories.

->news()->newsFeed() takes a whole feed rather than one page: the items newest first, each with its own id(). It answers every GetNewsForAppRequest with the page Steam would send for its count and enddate, so Steam::newsFeed() walks it the way it walks Steam:

$items = array_map(
fn (int $n): NewsItemFactory => NewsItemFactory::new()
->id((string) $n)
->publishedAt(now()->subHours($n)),
range(1, 45),
);
Steam::fake()->news()->newsFeed(...$items);
Steam::newsFeed(appId: 440)->count(); // 45, over three requests

feeds, tags and maxlength are not applied, so pass only the items a filtered feed would hold. SteamResponse::newsFeed() returns the closure behind it, which the array form takes too.

Each endpoint names the refusals Steam answers it with beside it, so the test gets the exception the base SDK raises for that endpoint:

Chain Builder Raises
->users()->friendsNotPublic(), ->users()->groupsNotPublic() SteamResponse::profileNotPublic() ProfileNotPublicException
->players()->ownedGamesNotPublic(), and the same on the other four IPlayerService endpoints SteamResponse::playerServiceNotPublic() ProfileNotPublicException
->stats()->userStatsNotPublic() SteamResponse::statsRefused() ProfileNotPublicException
->stats()->achievementsUnavailable() SteamResponse::statsRefused() StatsUnavailableException
->stats()->currentPlayersAppNotFound() SteamResponse::appNotFound() AppNotFoundException
->stats()->schemaAppNotFound() SteamResponse::schemaAppNotFound() AppNotFoundException
->stats()->globalAchievementsUnavailable() SteamResponse::globalAchievementsRefused() StatsUnavailableException
->users()->vanityUrlNotFound() SteamResponse::vanityNotFound() SteamUserNotFoundException
->apps()->upToDateCheckUnavailable() SteamResponse::appVersionUnavailable() AppVersionUnavailableException
->apps()->serversAtAddressInvalid() SteamResponse::invalidServerAddress() InvalidServerAddressException
->apps()->serversAtAddressRefused() SteamResponse::serversAtAddressRefused() SteamApiException
->apps()->sdrConfigAppNotFound() SteamResponse::sdrConfigAppNotFound() AppNotFoundException
->news()->appNewsUnavailable() SteamResponse::appNewsUnavailable() AppNewsUnavailableException
->webApi()->supportedApiListInvalidKey() SteamResponse::supportedApiListInvalidKey() InvalidApiKeyException
->invalidApiKey() SteamResponse::invalidApiKey() InvalidApiKeyException
— SteamResponse::apiKeyUnauthorized() InvalidApiKeyException
— SteamResponse::apiKeyMissing() InvalidApiKeyException
->connectionFailed() SteamResponse::connectionFailed() SteamConnectionException
use Fkrzski\SteamApiSdk\Exceptions\ProfileNotPublicException;
it('surfaces a private friend list', function (): void {
Steam::fake()->users()->friendsNotPublic();
Steam::users()->friends($steamId);
})->throws(ProfileNotPublicException::class);

->invalidApiKey() and ->connectionFailed() sit on the fake itself and answer every request that has no response of its own, so a chain can fake one endpoint and let the rest fail. The key builders without a method fit any request class in the array form.

Three shapes because Steam genuinely answers three ways: profileNotPublic() is a 401, playerServiceNotPublic() a 200 whose only tell is the missing key the endpoint reads — game_count for ownedGames(), player_level for steamLevel() and badges() — and statsRefused() a 400 with an empty JSON object, which the two ISteamUserStats endpoints read as different exceptions, because the cause is ambiguous and the SDK does not guess.

The endpoints taking an app ID refuse it four more ways: appNotFound() is a 404 carrying result: 42, schemaAppNotFound() a bare 400, globalAchievementsRefused() a 403 with an empty JSON object — that one covers a game with no achievements and a game ID Steam does not know alike — and sdrConfigAppNotFound() a 500 carrying Failed to get appinfo, raised before the connector can retry it. schemaAppNotFound() returns byte for byte what statsRefused() does and keeps its own name because nothing but the endpoint says which failure a test is faking. appNewsUnavailable() is the empty 403 again, which GetNewsForApp answers an unknown app and one whose news it does not publish with.

UpToDateCheck and GetServersAtAddress refuse with a 200 and success: false. appVersionUnavailable() covers an unknown app and one without server versions alike, and serversAtAddressRefused() is the per-IP refusal the base SDK raises as the root SteamApiException, so only the builder’s name says which failure it is.

A rejected key lands on 403 or 401, whichever the endpoint picks — invalidApiKey() fakes the first, apiKeyUnauthorized() the second, which is what GetCommunityBadgeProgressRequest answers with. GetSupportedAPIList answers a JSON 403 with an empty interface list instead of the HTML page, and supportedApiListInvalidKey() fakes that.

A faked 429 raises SteamRateLimitException the way Steam’s own does, and starts the cool-down with it: every request after it in that test is refused locally, before the fake can answer.

Steam::fake() keeps the tries the config sets but drops the pause between them, so a faked 5xx is retried at once and every attempt is counted. Set the tries first — Steam::fake() builds the connector:

use Saloon\Http\Faking\MockResponse;
config(['steam-api.http.retry.tries' => 3]);
Steam::fake([
GetPlayerSummariesRequest::class => MockResponse::make(status: 503),
]);
try {
Steam::users()->summaries([$steamId]);
} catch (SteamApiException) {
// raised once the third attempt fails too
}
Steam::assertSentCount(3);

Saloon pauses with a bare usleep(), which Sleep::fake() cannot reach, so the fake skips the wait rather than leaving a suite to sit it out. The pause a sleep rate limit takes is not skipped, so a test sending more than such a limit allows waits the window out. connectionFailed() is retried the same way, but Saloon throws it before recording a response: recorded() and the assertions never see the attempt, so assert on the exception.

Steam::fake() swaps the HTTP client, not the connector, so a faked request that needs a key still raises ApiKeyNotConfiguredException when none is configured. No request leaves your machine, so the value is irrelevant:

phpunit.xml
<phpunit>
<php>
<env name="APP_ENV" value="testing"/>
<env name="STEAM_API_KEY" value="testing"/>
</php>
</phpunit>

A suite covering only the apps(), news() and webApi() endpoints, currentPlayers(), globalAchievements() and newsFeed() needs no key at all — Steam serves those anonymously, and the connector sends them without one.

Saloon’s assertions are proxied onto the facade, so nothing has to hold the mock:

Steam::assertSent(GetPlayerSummariesRequest::class);
Steam::assertNotSent(GetPlayerBansRequest::class);
Steam::assertSentCount(1);
Steam::assertSentCount(1, GetPlayerSummariesRequest::class);
Steam::assertNothingSent();
Steam::assertSentInOrder([
ResolveVanityUrlRequest::class,
GetPlayerSummariesRequest::class,
]);

assertSent() and assertNotSent() also take a closure — assert on the request’s query parameters, body or headers when the class alone isn’t enough:

Steam::assertSent(
fn (GetPlayerBansRequest $request): bool => $request->steamIds === $ids,
);

Saloon’s assertSentJson() is deprecated upstream, so the facade does not proxy it — call it on the mock if a test still needs it.

When an assertion isn’t the shape you want, the mock’s readers sit on the facade too — the recorded responses, and the last exchange:

Steam::fake([/* … */]);
Steam::users()->summaries([$steamId]);
expect(Steam::recorded())->toHaveCount(1)
->and(Steam::lastRequest())->toBeInstanceOf(GetPlayerSummariesRequest::class)
->and(Steam::lastResponse()?->json('response.players'))->toHaveCount(1);

recorded() returns the Saloon Response objects in the order they came back. Both lastRequest() and lastResponse() return null when nothing was sent — an empty history is not a failure here, that is what assertNothingSent() is for.

The events come from the connector, not from the HTTP client the fake swaps, so a faked call dispatches them and Event::fake() records them, whichever of the two is faked first:

use Fkrzski\LaravelSteamApiSdk\Events\SteamResponseReceived;
use Illuminate\Support\Facades\Event;
Event::fake([SteamResponseReceived::class]);
Steam::fake()->users()->summaries(PlayerSummaryFactory::new());
Steam::users()->summaries([$steamId]);
Event::assertDispatched(
SteamResponseReceived::class,
fn (SteamResponseReceived $event): bool => $event->status === 200,
);
tests/Feature/PlayerSummariesTest.php
use Fkrzski\LaravelSteamApiSdk\Facades\Steam;
use Fkrzski\LaravelSteamApiSdk\Testing\Factories\PlayerSummaryFactory;
use Fkrzski\SteamApiSdk\Http\Requests\ISteamUser\GetPlayerSummariesRequest;
use Fkrzski\SteamApiSdk\ValueObjects\SteamId;
it('fetches player summaries', function (): void {
Steam::fake()->users()->summaries(
PlayerSummaryFactory::new()->personaName('Gabe'),
);
$summaries = Steam::users()->summaries([SteamId::fromSteamId64('76561198000000000')]);
expect($summaries[0]->personaName)->toBe('Gabe');
Steam::assertSent(GetPlayerSummariesRequest::class);
});