PlayerMock
In-memory stand-in for a Roblox Player used by tests. A real Player cannot be
Instance.new'd and no client joins a headless Open Cloud test place, so this builds a
Folder that carries the marker and identity attributes production code reads -- letting a
"player" flow through guards and providers without a real join.
It is the shared replacement for the ad-hoc Instance.new("Folder") :: Player stand-ins that
had accreted across the test suites. Guards keep their hard Player assert and add an explicit
OR clause so support for the mock is greppable rather than a silent weakening:
assert(player:IsA("Player") or PlayerMock.isMock(player), "Bad player")
Native Player members a Folder cannot expose are read through PlayerMock.read, which is
mock-only and errors on anything else (including a real Player). Consumers branch explicitly --
if PlayerMock.isMock(player) then PlayerMock.read(player, "UserId") else player.UserId -- so the
real-Player path stays plain member access that luau-lsp can type-check. Each property is backed by
a same-named attribute (Instance-valued members like Character by an ObjectValue child), so a test
can mock a value and observe changes via [PlayerMock.getPropertyChangedSignal]:
local player = PlayerMock.new({ UserId = 12345, AccountAge = 30 })
player.Parent = game:GetService("Players") -- where real players live; see PlayerMockService.CreatePlayer
assert(PlayerMock.isMock(player))
assert(PlayerMock.read(player, "UserId") == 12345)
assert(PlayerMock.read(player, "MembershipType") == Enum.MembershipType.None)
PlayerMock.write(player, "AccountAge", 31) -- fires GetAttributeChangedSignal("AccountAge")
Native Player events follow the same shape through PlayerMock.getSignal -- mock-only, with
the name validated against the engine's reflected Player events so a typo errors instead of
returning a signal that can never fire -- and PlayerMock.fireSignal as the test-side trigger:
local chatted = if PlayerMock.isMock(player) then PlayerMock.getSignal(player, "Chatted") else player.Chatted
maid:GiveTask(chatted:Connect(onChatted))
PlayerMock.fireSignal(player, "Chatted", "hello") -- test-side
Results of ID-keyed engine calls (group rank, gamepass/asset ownership, ...) that production
code fetches from a Roblox web API by (player, id) follow the same shape through
PlayerMock.writeLookup / PlayerMock.readLookup, with the domain named after the canonical
Service.Method being intercepted. The injected result lives on the mock -- one attribute per
(domain, key) -- so it is centralized per player: every consumer whose answer derives from the
same engine call resolves the same value, instead of each call site stubbing its own copy that
can drift.
PlayerMock.writeLookup(player, "GroupService.GetRolesInGroupAsync", 372, {
IsMember = true,
Roles = { { Name = "Admin", Rank = 230 } },
})
-- GroupUtils.promiseRankInGroup(player, 372) now resolves 230 everywhere it is asked,
-- and promiseRoleInGroup(player, 372) resolves the matching "Admin". Values are the raw
-- engine result shape (see GroupTestUtils.assignGroupInfo for a friendlier writer).
Properties
TAG
This item is read only and cannot be modified. Read OnlyPlayerMock.TAG: string
The CollectionService tag every mock carries from construction: the place-wide discovery channel
PlayerMockService and PlayerMockServiceClient observe. Replication is the default -- a real
Player exists for every peer, so a mock is discoverable from any ServiceBag in either realm the
moment it is parented into the DataModel (tag resolution is DataModel-scoped), and a destroyed
(or kicked) mock drops out automatically.
Functions
new
PlayerMock.new(overrides: {[string]: any}?--
Per-property seed values, keyed by native property name.
) → Player
Constructs a mock player. The returned value is typed as Player for drop-in use, but is really
a marked Folder; it is unparented -- the caller parents and/or maid:Adds it as needed. Once
parented into the DataModel it is replicated: discoverable place-wide through PlayerMockService /
PlayerMockServiceClient in any ServiceBag, either realm (see PlayerMock.TAG).
Every stand-in property (see PlayerMock.read) is seeded as an attribute from overrides (keyed by
native property name, e.g. UserId), or the pre-authored default, so reads resolve without a real
Player.
isMock
PlayerMock.isMock(value: any) → boolean
Returns whether the given value is a PlayerMock. Intended for use alongside a real-Player check
in a guard, e.g. player:IsA("Player") or PlayerMock.isMock(player).
findFirstAncestorMock
Returns the nearest ancestor of instance that is a PlayerMock, or nil. The mock counterpart
of FindFirstAncestorWhichIsA("Player") -- a mock's backing Folder is invisible to an IsA
walk, so code resolving the owning player from a descendant adds the explicit OR clause:
local player = instance:FindFirstAncestorWhichIsA("Player") or PlayerMock.findFirstAncestorMock(instance)
Like the engine call, the walk starts at the parent -- instance itself is never returned.
getMockByUserId
Returns the mock currently in the DataModel whose UserId stand-in matches, or nil. The mock
counterpart of Players:GetPlayerByUserId -- the resolver for code paths keyed by userId alone
(e.g. MarketplaceUtils.promiseUserOwnsGamePass), where no player value is in hand to
isMock-branch on:
local mockPlayer = PlayerMock.getMockByUserId(userId)
if mockPlayer ~= nil then
result = PlayerMock.readLookup(mockPlayer, "MarketplaceService.UserOwnsGamePassAsync", gamePassId)
else
result = MarketplaceService:UserOwnsGamePassAsync(userId, gamePassId)
end
Like the engine call, only mocks in the game resolve -- discovery runs over PlayerMock.TAG, and tag resolution is DataModel-scoped, so an unparented (or destroyed) mock reads back as nil. Real UserIds are unique; seed mocks the same way, since the first match wins.
getMockByUsername
Returns the mock currently in the DataModel whose username stand-in matches, or nil. The mock
counterpart of Players:GetUserIdFromNameAsync -- the resolver for code paths keyed by username
alone (e.g. PlayersServicePromises.promiseUserIdFromName). A mock's username is its
"UserService.GetUserInfosByUserIdsAsync" lookup's Username, which defaults to the mock's
Name -- the same member that holds a real Player's username.
Like PlayerMock.getMockByUserId, only mocks in the game resolve, and the first match wins.
getMockFromCharacter
Returns the mock currently in the DataModel whose Character stand-in is the given model, or
nil. The mock counterpart of Players:GetPlayerFromCharacter -- the resolver for code paths
that start from a character (or a part of one) with no player value in hand to isMock-branch
on (e.g. CharacterUtils.getPlayerFromCharacter):
local player = Players:GetPlayerFromCharacter(model) or PlayerMock.getMockFromCharacter(model)
Like the engine call, only the exact character model matches -- a descendant part resolves nil, so callers walking up from a descendant keep their own ancestor walk -- and only mocks in the game resolve (discovery runs over PlayerMock.TAG, and tag resolution is DataModel-scoped).
read
Reads a stand-in native Player property off a mock: the seeded backing attribute, or the
pre-authored typed default when unset. Errors on anything that is not a PlayerMock -- including a
real Player -- so call sites must branch explicitly:
local userId = if PlayerMock.isMock(player) then PlayerMock.read(player, "UserId") else player.UserId
Keeping the real-Player read as plain member access preserves luau-lsp's native property typing on
the hot path instead of funneling every read through this any-returning helper.
write
Mocks a native Player property on a mock by writing its backing attribute, which also fires the
instance's GetAttributeChangedSignal(propertyName) so observers see the change.
Writing Character = nil carries the engine's despawn semantics (the model is destroyed) --
see PlayerMock.removeCharacter.
getPropertyChangedSignal
Returns the signal that fires when the given stand-in property changes on a mock: the backing
attribute's changed signal, or the backing ObjectValue's Value-changed signal for Instance-valued
members like Character. Mock-only, like PlayerMock.read -- the real-Player path stays
player:GetPropertyChangedSignal(propertyName).
readLookup
PlayerMock.readLookup(domain: string,--
a known lookup domain, e.g. "GroupService.GetRolesInGroupAsync"
key: number | EnumItem | string--
what the call is keyed by (groupId, gamePassId, CoreGuiType, subscriptionId, ...)
) → any
Reads the injected result for an ID-keyed engine call off a mock: the value a test injected
through PlayerMock.writeLookup, or the domain's pre-authored default when unset. The domain is
the canonical Service.Method the production code path bottoms out in, validated against the
pre-authored set so a typo errors instead of silently reading a default.
Like PlayerMock.read, this is mock-only and errors on anything else (including a real
Player), so consumers branch explicitly and the real-Player path keeps calling the real
service:
if PlayerMock.isMock(player) then
-- same raw result shape the engine call returns, so parsing below runs unchanged
return PlayerMock.readLookup(player, "GroupService.GetRolesInGroupAsync", groupId)
end
return GroupService:GetRolesInGroupAsync(player.UserId, groupId)
Effect-recording domains (e.g. StarterGui.SetCoreGuiEnabled) run the same machinery in the
other direction: production wrote the value through PlayerMock.writeLookup, and the test reads
it here to assert the engine effect.
writeLookup
PlayerMock.writeLookup(domain: string,--
a known lookup domain, e.g. "MarketplaceService.UserOwnsGamePassAsync"
key: number | EnumItem | string,--
what the call is keyed by (groupId, gamePassId, CoreGuiType, subscriptionId, ...)
value: any--
must match the domain's value shape; nil clears back to the default
) → ()
Injects the result a mock answers for an ID-keyed engine call, or clears it back to the
domain default with nil. Because the value is stored on the mock -- one attribute per
(domain, key) -- every consumer whose answer derives from the same engine call resolves the
same value, and the write fires the backing attribute's GetAttributeChangedSignal so
observers see the change.
PlayerMock.writeLookup(player, "GroupService.GetRolesInGroupAsync", 372, {
IsMember = true,
Roles = { { Name = "Admin", Rank = 230 } },
})
PlayerMock.writeLookup(player, "MarketplaceService.UserOwnsGamePassAsync", 12345, true)
For effect-recording domains (e.g. StarterGui.SetCoreGuiEnabled) the writer is production
code instead: it performs the engine effect on the mock, and the test observes it through
PlayerMock.readLookup or the backing attribute's changed signal.
getLookupChangedSignal
PlayerMock.getLookupChangedSignal(domain: string,--
a known lookup domain, e.g. "Player.IsFriendsWithAsync"
) → RBXScriptSignalReturns the signal that fires when the injected result for an ID-keyed engine call changes on a mock: the backing attribute's changed signal. This lets a production observer treat a mid-test PlayerMock.writeLookup as the engine's own change event -- e.g. RxFriendUtils re-reading friendship when the "Player.IsFriendsWithAsync" domain changes, standing in for the CoreGui friended/unfriended events a mock can never receive. Mock-only, like PlayerMock.readLookup.
loadCharacterAsync
Emulates Player:LoadCharacterAsync() on a mock. The caller supplies the character model -- e.g.
Players:CreateHumanoidModelFromUserId/FromDescription (both work in cloud test runs) or a
hand-built rig -- or omits it to get a default R15 built from an empty HumanoidDescription
(may yield while the engine builds it).
The observable sequence encodes the engine's avatar loading event ordering (https://devforum.roblox.com/t/avatar-loading-event-ordering-improvements/269607), and PlayerMock.spec asserts each step -- correct both together if that understanding is ever corrected:
-
CharacterRemoving(old)fires whileCharacterstill points at the old model and it is still parented -- the event's "just before removal" contract (not part of the announcement) -
Characternils and the old character is destroyed (see RxCharacterUtils.observeLastCharacterBrio's assumption); nil-before-destroy lets observers tear down while the instance is still alive - the new rig is fully built before any signal fires -- the announcement's "appearance initialized / rig built and scaled" steps; the caller's rig (or the built default) stands in
Characteris set to the new model (the propertyChangedfires)- the new character is parented to the Workspace
-
CharacterAdded(new)fires -- after both the assignment and the Workspace parenting, per the announcement above (the pre-2019 "not in Workspace yet" gotcha is gone) CharacterAppearanceLoaded(new)fires -- afterCharacterAdded, with the rig finalized- only then does the call return, mirroring "LoadCharacter returns" ending the announced order
Per the same announcement, CharacterAdded fires only during avatar loading -- which is why a
plain PlayerMock.write(player, "Character", model) deliberately does not fire it.
Each call also replaces the mock's Backpack stand-in with a fresh empty one before any spawn
signal fires, like the engine does on respawn (minus the StarterPack copy) -- see
PlayerMock.getBackpack. The first call additionally inserts the StarterGear stand-in,
which later spawns keep -- see PlayerMock.getStarterGear.
loadMinimalCharacterAsync
PlayerMock.loadCharacterAsync with a minimal hand-built rig -- an anchored HumanoidRootPart
(the PrimaryPart) and a Humanoid -- instead of a full R15 built from a HumanoidDescription.
That is the smallest shape character-driven code paths (equip flows, humanoid observers) accept,
and building it never yields, so specs that only need a character spawn instantly:
local character = PlayerMock.loadMinimalCharacterAsync(playerMock)
removeCharacter
Emulates the character being removed with no replacement, i.e. player.Character = nil:
CharacterRemoving fires while Character still points at the model, Character is set to
nil, and the model is destroyed. No-op when no character is loaded.
Runs automatically when the mock itself is destroyed, mirroring the engine cleaning up the character when its player leaves or is kicked.
getBackpack
Returns the mock's current Backpack stand-in, or nil before the first spawn -- the engine only
inserts a player's Backpack when their character spawns. The stand-in is a genuine Backpack
instance parented to the mock (so it is the child named "Backpack", like a real Player's), which
means production code that observes the backpack's children -- e.g.
RxInstanceUtils.observeLastNamedChildBrio(player, "Backpack", "Backpack") -- works unchanged.
PlayerMock.loadCharacterAsync replaces it with a fresh empty one on every spawn, like the
engine does on respawn; the test parents Tools into it directly:
local character = PlayerMock.loadCharacterAsync(player, rig)
local backpack = assert(PlayerMock.getBackpack(player))
tool.Parent = backpack
getStarterGear
Returns the mock's current StarterGear stand-in, or nil before the first spawn -- the engine
only inserts a player's StarterGear alongside their first character spawn. Like the Backpack
stand-in it is a genuine StarterGear instance parented to the mock, so class-based lookups
(player:FindFirstChildOfClass("StarterGear")) work unchanged; unlike the Backpack,
PlayerMock.loadCharacterAsync never replaces it -- the engine keeps a player's StarterGear
across respawns. Consumers that dot-index player.StarterGear resolve it through the usual
explicit branch:
local starterGear = if PlayerMock.isMock(player)
then PlayerMock.getStarterGear(player)
else player.StarterGear
getPlayerGui
Returns the mock's PlayerGui stand-in, parented at construction -- mirroring the engine
inserting a player's PlayerGui at join (unlike the Backpack, which only appears at first
spawn). PlayerGui cannot be Instance.new'd, so like the mock itself the stand-in is really
a Folder (named "PlayerGui") typed as the native class; consumers resolve it through the
usual explicit branch instead of FindFirstChildOfClass:
local playerGui = if PlayerMock.isMock(player)
then PlayerMock.getPlayerGui(player)
else player:FindFirstChildOfClass("PlayerGui")
PlayerGuiUtils branches this way internally, so consumers of it work against a mock without changes. Tests parent ScreenGui/Frame surfaces into the stand-in directly.
getPlayerScripts
Returns the mock's PlayerScripts stand-in, parented at construction -- mirroring the engine
inserting a player's PlayerScripts at join, like the PlayerGui stand-in. PlayerScripts cannot
be Instance.new'd, so the stand-in is really a Folder (named "PlayerScripts") typed as the
native class. A Folder can never satisfy an IsA("PlayerScripts") class filter, so consumers
observing the child by class resolve it through the usual explicit branch:
local playerScriptsClassName = if PlayerMock.isMock(localPlayer) then "Folder" else "PlayerScripts"
RxInstanceUtils.observeLastNamedChildBrio(localPlayer, playerScriptsClassName, "PlayerScripts")
Tests parent script stand-ins (e.g. an RbxCharacterSounds LocalScript) into it directly.
kick
PlayerMock.kick() → ()
Emulates Player:Kick(message) on a mock. Kick is the special case among the stand-ins: a
method whose observable effect is the engine removing the player from the game, not a property
or event a test seeds -- so the mock really performs the removal sequence instead of merely
recording the call:
- the kick message is recorded (see PlayerMock.getKickMessage -- no client exists to show it to)
-
the character is removed (
CharacterRemovingfires whileCharacterstill points at it,Characternils, the model is destroyed), mirroring the engine cleaning up a kicked player's character -
the mock leaves the DataModel (
Parent = nil, not a destroy -- a held reference stays readable), so the nativeAncestryChangedsignal genuinely fires -- the same way a kicked player's instance leavesPlayers
Players.PlayerRemoving is a Players-service event only the engine fires, so consumers of that
event cannot observe a mock kick; observe AncestryChanged instead.
Production code branches explicitly, like every other mock seam:
if PlayerMock.isMock(player) then
PlayerMock.kick(player, reason)
else
player:Kick(reason)
end
getKickMessage
Returns the message a mock was kicked with (via PlayerMock.kick), or nil when it was never
kicked. A kick with no message reads back as "". Stays readable after the kick destroys the
mock, as long as the caller holds a reference.
getSignal
Reads a stand-in native Player event off a mock: the genuine native signal when the backing
Folder inherits it from Instance (AncestryChanged, Destroying, ...), otherwise a
BindableEvent-backed signal a test fires through PlayerMock.fireSignal. The name is
validated against the engine's reflected Player events, so a typo errors instead of
returning a signal that can never fire.
Like PlayerMock.read, this is mock-only and errors on anything else (including a real
Player), so call sites branch explicitly and the real-Player path stays plain member access:
local chatted = if PlayerMock.isMock(player) then PlayerMock.getSignal(player, "Chatted") else player.Chatted
fireSignal
PlayerMock.fireSignal(eventName: string,...: any--
Event arguments delivered to connected handlers.
) → ()
Fires the backing signal for a Player-class event on a mock, so code connected through
PlayerMock.getSignal observes the event as if the engine had fired it.
Only Player-class events can be fired; events the backing Folder inherits from Instance
resolve to genuine native signals, which only the engine fires.
bindInput
PlayerMock.bindInput(domain: string,--
a known input domain, e.g. "ContextActionService.BindAction"
actionName: string,...: any--
the engine call's remaining args, e.g. functionToBind, createTouchButton, ...inputTypes
) → ()
Emulates a context-restricted ContextActionService call on a mock. The domain is the canonical
Service.Method -- validated against the pre-authored set so a typo errors, like
PlayerMock.readLookup -- and the args after it mirror the engine call's own args, so a
production mock branch is the identical call aimed at the mock local player (there is no input
to receive):
local localPlayer = Players.LocalPlayer or PlayerMock.getMockedLocalPlayer()
if localPlayer ~= nil and PlayerMock.isMock(localPlayer) then
PlayerMock.bindInput(localPlayer, "ContextActionService.BindAction", "Drag", onDragAction, false, Enum.UserInputType.MouseButton2)
else
ContextActionService:BindAction("Drag", onDragAction, false, Enum.UserInputType.MouseButton2)
end
Unbinding goes through the same entry point -- the domain names the operation:
PlayerMock.bindInput(localPlayer, "ContextActionService.UnbindAction", "Drag")
A test dispatches a bound action through PlayerMock.fireInput as if the engine had routed input to it. What the emulation deliberately does not model: touch buttons, priority routing, input-type routing, and the engine's bind stack -- all bind domains share one action registry per mock (like the engine's), and rebinding a name simply replaces the callback.
isInputBound
Returns whether the given action is currently bound on a mock (via PlayerMock.bindInput). Keyed by the action name alone -- both bind domains share one action registry, like the engine.
fireInput
PlayerMock.fireInput(actionName: string,userInputState: Enum.UserInputState,inputObject: any?--
a real InputObject or a bindable-safe stand-in table
) → Enum.ContextActionResult?
Dispatches a bound action on a mock, invoking the bound callback with
(actionName, userInputState, inputObject) -- the argument order the engine uses -- and
returning its result. Test-side counterpart of PlayerMock.bindInput, like
PlayerMock.fireSignal is for PlayerMock.getSignal.
Errors when the action is not bound: with no engine sink logic modeled, firing an unbound action can only be a test mistake (typo'd name, or firing after the production unbind).
The invocation goes through the backing BindableFunction, so a stand-in inputObject must be
bindable-safe -- a real InputObject reference, or a plain metatable-free table of the fields
the callback reads (e.g. UserInputType, Position, Delta).
setMockedLocalPlayer
PlayerMock.setMockedLocalPlayer() → ()
Designates a mock as the local player for the client realm (or clears it with nil). Read through
PlayerMock.getMockedLocalPlayer by client code falling back from Players.LocalPlayer.
Call this directly before booting bags to pre-designate -- matching production, where
Players.LocalPlayer exists before any service runs -- and a booting PlayerMockServiceClient
adopts the designation as its local player and owns its cleanup. After boot, designate through
PlayerMockServiceClient.SetLocalPlayer instead, which records the designation per simulated
client.
The mock must be parented into the DataModel first: the designation is carried as a
CollectionService tag, and GetTagged only resolves parented instances -- an unparented
designation would silently read back as nil.
getMockedLocalPlayer
Returns the mock designated as the local player (via PlayerMockServiceClient), or nil. This is
only ever the mock -- there is deliberately no helper that resolves the real Players.LocalPlayer,
so call sites fall back explicitly and the real read stays visible to luau-lsp:
local localPlayer = Players.LocalPlayer or PlayerMock.getMockedLocalPlayer()