Testing\SteamFake is a Saloon MockClient
that fakes each endpoint through the same resource chain your code calls. Attach it with
withMockClient(): no request leaves the machine, and the DTOs come from the payloads the
factories build.
Faking responses
Section titled “Faking responses”use Fkrzski\SteamApiSdk\SteamConfig;use Fkrzski\SteamApiSdk\SteamConnector;use Fkrzski\SteamApiSdk\Testing\Factories\ISteamUser\PlayerSummaryFactory;use Fkrzski\SteamApiSdk\Testing\SteamFake;use Fkrzski\SteamApiSdk\ValueObjects\SteamId;
it('reads the persona name off a summary', function (): void { $fake = new SteamFake()->users()->summaries( PlayerSummaryFactory::new()->personaName('Gabe')->online(), );
$connector = new SteamConnector(new SteamConfig('test-key'))->withMockClient($fake);
$summaries = $connector->users()->summaries([SteamId::fromSteamId64('76561198000000000')]);
expect($summaries[0]->personaName)->toBe('Gabe');});use Fkrzski\SteamApiSdk\SteamConfig;use Fkrzski\SteamApiSdk\SteamConnector;use Fkrzski\SteamApiSdk\Testing\Factories\ISteamUser\PlayerSummaryFactory;use Fkrzski\SteamApiSdk\Testing\SteamFake;use Fkrzski\SteamApiSdk\ValueObjects\SteamId;
final class PlayerSummariesTest extends TestCase{ public function test_it_reads_the_persona_name_off_a_summary(): void { $fake = new SteamFake()->users()->summaries( PlayerSummaryFactory::new()->personaName('Gabe')->online(), );
$connector = new SteamConnector(new SteamConfig('test-key'))->withMockClient($fake);
$summaries = $connector->users()->summaries([SteamId::fromSteamId64('76561198000000000')]);
$this->assertSame('Gabe', $summaries[0]->personaName); }}Each method registers the response under the request class its endpoint sends and hands the fake 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, and 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 = new SteamFake() ->users()->summaries() // no players matched ->users()->friends($first, $second, $third) // three friends ->players()->badges(PlayerBadgesFactory::new()) // the badges envelope ->players()->steamLevel(42) // a level, no factory ->stats()->currentPlayers(12_345) // a head count, no factory ->webApi()->serverInfo(new DateTimeImmutable()); // Steam's clock, no factoryThe fake swaps the HTTP client, not the connector, so a request that needs a key still raises
ApiKeyNotConfiguredException on a config without one.
Give the test config any key: it never leaves the machine.
new SteamFake() also takes a map of request class → response, for a
request of your own or a MockResponse the chain does not
build. SteamResponse holds the builder behind every method, and the map combines with a chain:
use Fkrzski\SteamApiSdk\Http\Requests\ISteamUser\GetPlayerBansRequest;use Fkrzski\SteamApiSdk\Testing\SteamResponse;
$fake = new SteamFake([ 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 SDK returns has a factory for the payload Steam sends, under
Testing\Factories and the interface whose endpoint returns it, as the requests are.
::new() gives a complete, realistic record you can narrow:
use Fkrzski\SteamApiSdk\Testing\Factories\ISteamUser\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,
so the summary 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()
on GameSchemaFactory and SchemaStatFactory sends the empty name Steam really sends, which
the DTO reads back as null.
withoutSpectatorPort() sends the 0 Steam sends for a server without SourceTV and
withoutAuthor() the '' of a post without a byline; the DTO reads both as null.
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, 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, so set
them by name rather than through state().
RecentlyPlayedGamesFactory::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.
AppNewsFactory::items() and total() work the same way, for page one of many:
RecentlyPlayedGamesFactory::new()->games( RecentlyPlayedGameFactory::new()->name('Dead by Daylight')->playedRecently(240),)->totalCount(12);
AppNewsFactory::new()->items( NewsItemFactory::new()->title('Patch notes'),)->total(250);Namespace under Fkrzski\SteamApiSdk\Testing\Factories |
Factories |
|---|---|
ISteamUser |
PlayerSummaryFactory, PlayerBanFactory, FriendFactory, UserGroupFactory |
IPlayerService |
OwnedGameFactory, RecentlyPlayedGameFactory, RecentlyPlayedGamesFactory, BadgeFactory, PlayerBadgesFactory, CommunityBadgeQuestFactory |
ISteamUserStats |
UserStatFactory, UserStatAchievementFactory, UserStatsFactory, PlayerAchievementFactory, PlayerAchievementsFactory, GlobalAchievementFactory, GameSchemaFactory, SchemaStatFactory, SchemaAchievementFactory |
ISteamApps |
AppVersionCheckFactory, GameServerFactory, SdrConfigFactory, SdrPointOfPresenceFactory, SdrRelayFactory |
ISteamNews |
AppNewsFactory, NewsItemFactory |
ISteamWebAPIUtil |
WebApiInterfaceFactory, WebApiMethodFactory, WebApiParameterFactory |
More than 100 Steam IDs
Section titled “More than 100 Steam IDs”summaries() and bans() cut a list over 100 IDs
into requests of 100. On the fake, each request gets the faked players it asks for, so 150 IDs
against 150 faked players return each player once:
$fake = new SteamFake()->users()->summaries(...array_map( fn (SteamId $steamId): PlayerSummaryFactory => PlayerSummaryFactory::new()->steamId($steamId), $steamIds,));
$connector->withMockClient($fake)->users()->summaries($steamIds); // 150 summaries, 2 requestsA request that asks for none of the faked players gets all of them, so a test that never matched the IDs keeps working.
Faking paged news
Section titled “Faking paged news”->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, 20 without one, and endDate, for code that pages through older news:
$fake = new SteamFake()->news()->newsFeed(...$items);
$page = $connector->news()->appNews(appId: 440, count: 20);$older = $connector->news()->appNews(appId: 440, count: 20, endDate: array_last($page->items)->publishedAt);Steam counts endDate in, so the next page opens on the item the last one ended with.
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 map 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 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 |
->tooManyRequests() |
SteamResponse::tooManyRequests() |
SteamRateLimitException |
use Fkrzski\SteamApiSdk\Exceptions\ProfileNotPublicException;
it('surfaces a private friend list', function (): void { $connector = new SteamConnector(new SteamConfig('test-key')) ->withMockClient(new SteamFake()->users()->friendsNotPublic());
$connector->users()->friends($steamId);})->throws(ProfileNotPublicException::class);use Fkrzski\SteamApiSdk\Exceptions\ProfileNotPublicException;
$connector = new SteamConnector(new SteamConfig('test-key')) ->withMockClient(new SteamFake()->users()->friendsNotPublic());
$this->expectException(ProfileNotPublicException::class);
$connector->users()->friends($steamId);->invalidApiKey(), ->connectionFailed() and ->tooManyRequests() 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 map.
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 for a game with no achievements and an unknown one 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 only 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 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 GetCommunityBadgeProgress answers with.
GetSupportedAPIList answers a JSON 403 with an empty interface list instead of the HTML
page, and supportedApiListInvalidKey() fakes that.
tooManyRequests() raises SteamRateLimitException the way Steam’s own 429 does, and starts
the cool-down with it: every request after it
is refused locally, before the fake can answer, for Retry-After seconds when you pass them.
TooManySteamIdsException comes from a request constructor given more than 100 IDs, and a
spent local budget from the limiter before any response exists, so neither has a builder.
Rate limits in tests
Section titled “Rate limits in tests”The default MemoryStore keeps its backing array static, so the budget and the lock after a
faked 429 outlive the test, and the next one using the same key fails with
SteamRateLimitException. new SteamFake() clears the store, so a test that builds its fake
first starts with a fresh budget and no lock.
A test on a plain MockClient, or one sending before it builds the fake, still needs the store
cleared in its setup. A store of your own
is yours to reset:
use Saloon\RateLimitPlugin\Stores\MemoryStore;
beforeEach(fn () => MemoryStore::clear());use Saloon\RateLimitPlugin\Stores\MemoryStore;
protected function setUp(): void{ parent::setUp();
MemoryStore::clear();}Retries
Section titled “Retries”The fake keeps the tries the connector’s config
sets, so a faked 5xx is retried and every attempt is counted. retryInterval is 0 unless
the test config sets one, and Saloon sleeps for real between tries when it does:
use Saloon\Http\Faking\MockResponse;
$fake = new SteamFake([ GetPlayerSummariesRequest::class => MockResponse::make(status: 503),]);
$connector = new SteamConnector(new SteamConfig('test-key', tries: 3))->withMockClient($fake);
try { $connector->users()->summaries([$steamId]);} catch (SteamApiException) { // raised once the third attempt fails too}
$fake->assertSentCount(3);connectionFailed() is retried the same way, but Saloon throws it before recording a
response, so the recorded traffic and the assertions never see the attempt. Assert on the
exception.
Asserting
Section titled “Asserting”SteamFake is a MockClient, so Saloon’s assertions run on it directly:
$fake->assertSent(GetPlayerSummariesRequest::class);$fake->assertNotSent(GetPlayerBansRequest::class);$fake->assertSentCount(1);$fake->assertSentCount(1, GetPlayerSummariesRequest::class);$fake->assertNothingSent();
$fake->assertSentInOrder([ ResolveVanityUrlRequest::class, GetPlayerSummariesRequest::class,]);assertSent() and assertNotSent() also take a closure, for the request’s query, body or
headers when the class alone isn’t enough:
$fake->assertSent( fn (GetPlayerBansRequest $request): bool => $request->steamIds === $ids,);getRecordedResponses() returns the Saloon Response objects in the order they came back,
and getLastRequest() and getLastResponse() the last exchange, or null when nothing was
sent.