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.
Faking responses
Section titled “Faking responses”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 factoryIt 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 |
DTO factories
Section titled “DTO factories”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 payloadPlayerSummaryFactory::new()->make(); // the PlayerSummary DTOReach 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 profilePlayerSummaryFactory::new()->friendsOnly(); // friends-only profilePlayerSummaryFactory::new()->online()->inGame('730', 'CS2'); // online, in a gamePlayerBanFactory::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.
Faking a news feed
Section titled “Faking a news feed”->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 requestsfeeds, 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.
Faking failures
Section titled “Faking failures”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);use Fkrzski\SteamApiSdk\Exceptions\ProfileNotPublicException;
Steam::fake()->users()->friendsNotPublic();
$this->expectException(ProfileNotPublicException::class);
Steam::users()->friends($steamId);->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.
Retries
Section titled “Retries”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.
A key for the requests that need one
Section titled “A key for the requests that need one”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> <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.
Asserting
Section titled “Asserting”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.
Recorded traffic
Section titled “Recorded traffic”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);Steam::fake([/* … */]);
Steam::users()->summaries([$steamId]);
$this->assertCount(1, Steam::recorded());$this->assertInstanceOf(GetPlayerSummariesRequest::class, Steam::lastRequest());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.
Events
Section titled “Events”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,);A complete example
Section titled “A complete example”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);});use Fkrzski\LaravelSteamApiSdk\Facades\Steam;use Fkrzski\LaravelSteamApiSdk\Testing\Factories\PlayerSummaryFactory;use Fkrzski\SteamApiSdk\Http\Requests\ISteamUser\GetPlayerSummariesRequest;use Fkrzski\SteamApiSdk\ValueObjects\SteamId;
final class PlayerSummariesTest extends TestCase{ public function test_it_fetches_player_summaries(): void { Steam::fake()->users()->summaries( PlayerSummaryFactory::new()->personaName('Gabe'), );
$summaries = Steam::users()->summaries([SteamId::fromSteamId64('76561198000000000')]);
$this->assertSame('Gabe', $summaries[0]->personaName);
Steam::assertSent(GetPlayerSummariesRequest::class); }}