How mocks make it easy to ensure your application logic
It’s easy to test your application when your domain layer is decoupled from other:
For example, let’s imagine the following scenario:
In a Race Game, when the Car hit an obstacle, the player will go back a couple of yards, and the barrier will be removed
The Pseudo-code
We have a Game
class that receives the Player
and the ObstacleManager
:
1public class Game {
2
3 public Game(Player player, ObstacleManager obstacleManager) {
4 (...) /* Setup game, objects, etc */
5 }
6
7 public class updateGameState() {
8 const collision = player.checkCollision(obstacleManager);
9
10 if(collision) {
11 obstacleManager.Remove(collision.Obstacle);
12 player.Knockback();
13 }
14 }
15}
The Test - the slow way:
The direct (aka: “slow”) approach is to instantiate the objects directly
1describe("Game Tests", () => {
2 test("The player should hit obstacle in same position", () => {
3 const obstacle = new Obstacle(0,0); /*creating objects with the position*/
4 const obstacleManager = new ObstacleManager([ obstacle ]);
5 const player = new Player(0, 0);
6
7 const collision = player.checkCollision(obstacleManager);
8
9 expect(collision).toBe(obstacle);
10 });
11
12 test("The player should have a Knockback if hit an obstacle", () => {
13 const obstacle = new Obstacle(0,0); /*creating objects with the position*/
14 const obstacleManager = new ObstacleManager([ obstacle ]);
15 const player = new Player(0, 0);
16
17 const game = new Game(player, obstacleManager);
18 game.updateGameState();
19
20 expect(player.Knockbacked).toBe(true);
21 });
22});
This kind of approach is still valid (it’s better to have some tests than no test at all), but it has some issues:
- Your tests may become brittle over time - Every change to the entities (player, obstacles, managers) may require changes in all tests;
- The Entities may not be so easy to set up - The Player may rely on Canvas, EventListeners, or Network, etc. to be constructed;
- You are mixing player logic with obstacle manager logic. Both entities are coupled by these tests;
Knockback
can be an internal/private property that we’ll need to expose just to test (information leak);
Fourtnelly, Jest has powerful mechanisms that let you Mock even your import statements:
The Test - the fast (decoupled) way:
We’ll need additional steps to setup the mocks in Jest, but it’ll be payed over time:
1import { Player } from "../Entities/Player";
2import { ObstacleManager } from "../Entities/Obstacles/ObstacleManager";
3
4/* STEP 1 : Setup what jest will be mocking */
5jest.mock("../Entities/Player");
6jest.mock("../Entities/Obstacles/ObstacleManager");
7
8describe("Game Tests", () => {
9
10 beforeEach(() => {
11 /* STEP 2 : Clear all instances between tests */
12 Player.mockClear();
13 ObstacleManager.mockClear();
14 });
15
16 test("The player should have a Knockback if hit an obstacle", () => {
17 /* STEP 3 : Creating Spies */
18 const knockbackSpy = jest.fn();
19 Player.mockImplementation(() => {
20 return { checkCollision: (manager) => { return {}; },
21 Knockback: knockbackSpy };
22 });
23
24 // Jest will take care of injecting the dependencies where needed
25 const game = new Game();
26
27 game.updateGameState();
28
29 expect(knockbackSpy).toBeCalled();
30 });
31});
Lots of things happening here, but we can summarize as:
- Step 1: on line 5, it’s configuring Jest to replace the import of the Player and ObstacleManager with Mocks;
- Step 2: since your Mocks are somehow globals, We are ensuring that they are reset before each test;
- Step 3 - This is the tricky and not so obvious part (and why We named this post 😜): Our
Game
class is decoupled fromPlayer
andObstacleManager
, so you can spy on methods that the game is manipulating and test just the game logic itself;
The advantages of this approach:
- This test can focus in the game logic itself because is isolated from the entities. The Game object just need to know the interface that the objects will be using (here represented by its functions);
- Again, The entities itself don’t need to know about it other. The
Player
is not coupled with theObstacleManager
anymore, we are just using the “interface” method CheckCollision to ensure that our Game have the appropriate logic; - We don’t need to leak information from the
Player
, we can check, with the spy function, that our application is calling theKnockback
method;
Now it’s easier to write the test for the second statement from our requirement:
1 test("The Obstacle should be removed if hit by a Player", () => {
2 const obstacle = {};
3 const removeSpy = jest.fn();
4 Player.mockImplementation(() => {
5 return { checkCollision: (manager) => { return { Obstacle: obstacle }; },
6 Knockback: jest.fn() };
7 });
8
9 ObstacleManager.mockImplementation(() => {
10 return { remove: (obstacle) => removeSpy };
11 });
12
13 const game = new Game();
14 game.updateGameState();
15
16 /* the Method */
17 expect(removeSpy).toBeCalledWith(obstacle);
18 });
Source
Credits
- Revision by Henrique Jardim