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.

tests/PlayerSummariesTest.php
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');
});

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 factory

The 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

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 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, 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

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 requests

A request that asks for none of the faked players gets all of them, so a test that never matched the IDs keeps working.

->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.

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);

->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.

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:

tests/Pest.php
use Saloon\RateLimitPlugin\Stores\MemoryStore;
beforeEach(fn () => MemoryStore::clear());

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.

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.