Skip to content

Commit 636ce8f

Browse files
committed
feat: Add testing utility for mocking the randomness API
1 parent 99639c4 commit 636ce8f

5 files changed

Lines changed: 139 additions & 2 deletions

File tree

docs/documentation/testing.md

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ it('should declare player 1 as the winner', () => {
6767
client.moves.clickCell(5);
6868

6969
// get the latest game state
70-
const { G, ctx } = client.store.getState();
70+
const { G, ctx } = client.getState();
7171

7272
// the board should look like this now
7373
expect(G.cells).toEqual(['0', '0', null, '1', '1', '1', null, null, '0']);
@@ -76,9 +76,72 @@ it('should declare player 1 as the winner', () => {
7676
});
7777
```
7878

79-
!> Note that we imported the vanilla JavaScript client, not the
79+
?> Note that we imported the vanilla JavaScript client, not the
8080
one from `boardgame.io/react`.
8181

82+
### Testing Randomness
83+
84+
If you are testing a move that uses the [Random API](/random), by definition
85+
you can’t always expect the same result, making it harder to test. In this
86+
case, you can use one of the following strategies.
87+
88+
#### Fixed PRNG seed
89+
90+
You can set `seed` in your game object. This will be used to initialise the
91+
Random API’s internal state and you’ll see a predictable sequence of results
92+
from calls to random API methods:
93+
94+
```js
95+
import { Client } from 'boardgame.io/client';
96+
97+
const Game = {
98+
moves: {
99+
rollDice: (G, ctx) => {
100+
G.roll = ctx.random.D6();
101+
},
102+
},
103+
};
104+
105+
it('updates G.roll with a random number', () => {
106+
const client = Client({
107+
// Set seed so PRNG always starts in same state
108+
game: { ...Game, seed: 'fixed-seed' },
109+
});
110+
client.moves.rollDice();
111+
const { G } = client.getState();
112+
expect(G.roll).toMatchInlineSnapshot(`4`);
113+
});
114+
```
115+
116+
#### Override Random API <small>`since v0.49.9`</small>
117+
118+
If you need to test specific random outcomes, you can override the Random
119+
API entirely to allow complete control of the results of API methods.
120+
121+
```js
122+
import { Client } from 'boardgame.io/client';
123+
import { MockRandom } from 'boardgame.io/testing';
124+
125+
// Create a mock of the random plugin, where the D6 method always returns 6.
126+
// Any methods you don’t provide an implementation for will behave as usual.
127+
const randomPlugin = MockRandom({
128+
D6: () => 6,
129+
});
130+
131+
it ('rolls a six', () => {
132+
const client = Client({
133+
game: {
134+
...Game,
135+
// Add the random plugin mock to the game’s plugins.
136+
plugins: [...(Game.plugins || []), randomPlugin]
137+
},
138+
});
139+
client.moves.rollDice();
140+
const { G } = client.getState();
141+
expect(G.roll).toMatchInlineSnapshot(`6`);
142+
});
143+
```
144+
82145
### Multiplayer Tests
83146

84147
Use the local multiplayer mode to simulate multiplayer interactions

packages/testing.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { MockRandom } from '../src/testing/mock-random';

src/testing/mock-random.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Client } from '../client/client';
2+
import type { Game } from '../types';
3+
import { MockRandom } from './mock-random';
4+
5+
test('it creates a plugin object', () => {
6+
const plugin = MockRandom();
7+
expect(plugin).toEqual({
8+
name: 'random',
9+
noClient: expect.any(Function),
10+
api: expect.any(Function),
11+
setup: expect.any(Function),
12+
playerView: expect.any(Function),
13+
});
14+
});
15+
16+
test('it can override random API methods', () => {
17+
const game: Game<{ roll: number }> = {
18+
moves: {
19+
roll: (G, ctx) => {
20+
G.roll = ctx.random.D6();
21+
},
22+
},
23+
plugins: [MockRandom({ D6: () => 1 })],
24+
};
25+
26+
const client = Client({ game });
27+
client.moves.roll();
28+
expect(client.getState().G.roll).toBe(1);
29+
});
30+
31+
test('it can use non-overridden API methods', () => {
32+
const game: Game<{ roll: number }> = {
33+
moves: {
34+
roll: (G, ctx) => {
35+
G.roll = ctx.random.D6();
36+
},
37+
},
38+
plugins: [MockRandom({ D10: () => 1 })],
39+
seed: 0,
40+
};
41+
42+
const client = Client({ game });
43+
client.moves.roll();
44+
expect(client.getState().G.roll).toMatchInlineSnapshot(`4`);
45+
});

src/testing/mock-random.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { RandomAPI } from '../plugins/random/random';
2+
import RandomPlugin from '../plugins/plugin-random';
3+
4+
/**
5+
* Test helper that creates a plugin to override the built-in random API.
6+
*
7+
* @param overrides - A map of method names to mock functions.
8+
*
9+
* @example
10+
* const game = {
11+
* plugins: [
12+
* MockRandom({ D6: () => 1 }),
13+
* ],
14+
* };
15+
*/
16+
export const MockRandom = (
17+
overrides: Partial<Record<keyof RandomAPI, (...args: any[]) => any>> = {}
18+
): Omit<typeof RandomPlugin, 'flush'> => {
19+
// Don’t include the original flush method, otherwise when the
20+
// built-in random plugin flushes, it won’t have access to the
21+
// state it needs.
22+
const { flush, ...rest } = RandomPlugin;
23+
return {
24+
...rest,
25+
api: (context) => ({ ...RandomPlugin.api(context), ...overrides }),
26+
};
27+
};

subpackages.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ module.exports = [
1717
'master',
1818
'multiplayer',
1919
'internal',
20+
'testing',
2021
];

0 commit comments

Comments
 (0)