开发Web战舰游戏时,用Jest测试中介者模式遇问题求助
战舰游戏中介者模式测试问题分析
我正在为《Odin Project》的作业开发一款基于Web的战舰游戏,采用中介者模式处理玩家与CPU的开火逻辑。作业要求用Jest完成全面测试而非console.log,目前已测试部分功能,但对中介者模式的测试感到困惑——尽管查了大量指南,还是没法正确实现函数/模块Mock(对Mock的理解也有困难)。现在EventManager里的notifyAttack函数仅靠console.log完成了测试,希望能指出测试代码里的问题。
EventManager代码
export {EventManager} const EventManager = { gameManager: GameManager, notifyAttack(who, coordinate){ if(!who) throw new Error(`Unknown player`); else who === `CPU` ? GameManager.player.board.getAttack(coordinate) : GameManager.cpu.board.getAttack(coordinate); GameManager.turn = who; } }
GameManager代码
import {Player} from "./player"; export {GameManager} const GameManager = { turn: undefined, player: undefined, cpu: Player(), }
Player代码
import {coordinate, GameBoard} from './gameboard'; import { EventManager } from './eventmanager'; export {Player} const playerActions = { eventManager: EventManager, fire(coordinate){ this.eventManager.notifyAttack(this.name, coordinate); } } function Player(name){ const player = Object.create(playerActions); player.board = GameBoard(); name === undefined ? player.name = `CPU`: player.name = name; return player; }
GameBoard代码
import { Ship } from "./ship" export {GameBoard, coordinate, shipOrientation, tile} function coordinate(x,y){ const boardSize = 10; if(x > boardSize || x < 1) throw new Error(`X coordinate is out of boundaries`); if(y > boardSize || y < 1) throw new Error(`Y coordinate is out of boundaries`); return{x:x, y:y} } function tile(coordinate, id){ return{coordinate: coordinate, id: id} } const shipOrientation = { HORIZONTAL: Symbol(`horizontal`), VERTICAL: Symbol(`vertical`), } const gameboardActions = { placeShips(shipType, orientation, inputCoordinate){ const ship = Ship(shipType); ship.ID = `${inputCoordinate.x},${inputCoordinate.y}`; this.tiles.forEach(tile=>{ if(tile.coordinate.x === inputCoordinate.x && tile.coordinate.y === inputCoordinate.y) throw new Error(`There's already an object on that input coordinate`); }) if(orientation === shipOrientation.HORIZONTAL){ if(inputCoordinate.x + ship.length > this.size) throw new Error(`Part of ship is out of board X boundary`); for(let i = 0; i<ship.length; ++i) this.tiles.push(tile(coordinate(inputCoordinate.x+i, inputCoordinate.y), `${ship.ID}`)); }else if(orientation === shipOrientation.VERTICAL){ if(inputCoordinate.y + ship.length > this.size) throw new Error(`Part of ship is out of board Y boundary`); for(let i = 0; i<ship.length; ++i) this.tiles.push(tile(coordinate(inputCoordinate.x, inputCoordinate.y+i), `${ship.ID}`)); }else throw new Error(`Undefined ship orientation`); this.shipsLog.set(`${ship.ID}`,ship); }, getAttack(inputCoordinate){ let isShip, ID; this.tiles.forEach(tile=>{ if(tile.coordinate.y===inputCoordinate.y&&tile.coordinate.x===inputCoordinate.x&&tile.id){ ID = tile.id; return isShip = true; } }) if(isShip){ this.shipsLog.get(ID).hit() if(this.shipsLog.get(ID).isSunk){ this.removeShip(ID); this.checkSunkFleet(); } }else this.tiles.push(tile(inputCoordinate, undefined)); }, removeShip(ID){ this.shipsLog.delete(ID); for(let i = 0; i<this.tiles.length; ++i) if(this.tiles[i].id===ID) this.tiles.splice(i,1); }, checkSunkFleet(){ this.shipsLog.size === 0 ? this.sunkFleet=true:this.sunkFleet=false; } } function GameBoard (){ const gameboard = Object.create(gameboardActions); gameboard.shipsLog = new Map(); gameboard.tiles= []; gameboard.size= 10; gameboard.sunkFleet = false; return gameboard; }
Jest测试代码
import {GameBoard, coordinate} from "./gameboard"; import {GameManager} from './gamemanager'; import {Player} from "./player"; import {EventManager} from "./eventmanager"; GameManager.player = Player(`Pablo`); describe(`Player set up`, ()=>{ test(`Player's name is Pablo`,()=>{ expect(GameManager.player.name).toMatch(/^[A-Z]+$/i); }); test(`Player has a board to play with`, ()=>{ expect(GameManager.player.board).toMatchObject(GameBoard()); }); }) describe(`Player's actions`,()=>{ test(`Pablo fires a missile, he misses ship target though`, ()=>{ const myCoordinate = coordinate(5,5); const spy = jest.spyOn(EventManager, 'notifyAttack') GameManager.player.fire(myCoordinate); expect(spy).toBeCalled(); expect(GameManager.cpu.getAttack).toBeCalledWith(myCoordinate); expect(GameManager.cpu.shipsLog.has(`${myCoordinate.x}, ${myCoordinate.y}`)); }) })
开火流程说明
- GameManager中已初始化的玩家Pablo调用Player对象的
fire()方法开火 fire()向EventManager报告开火者及坐标- EventManager调用CPU的GameBoard的
getAttack()方法记录此次未命中的攻击
测试代码中的核心问题
依赖未隔离与Mock缺失
- 直接使用全局的GameManager和EventManager实例,测试会修改全局状态,污染后续测试用例。
- 断言
expect(GameManager.cpu.getAttack).toBeCalledWith(myCoordinate)错误:getAttack是CPU的board对象方法,而非CPU玩家对象的方法,且未对该方法做Spy/Mock,Jest无法追踪调用情况。
Spy使用不规范
- 对
EventManager.notifyAttack的Spy未在测试后清理,会影响后续测试。需用jest.clearAllMocks()或在afterEach中重置Spy。 - 仅验证Spy被调用,未验证调用参数是否正确,应补充
expect(spy).toHaveBeenCalledWith('Pablo', myCoordinate)。
- 对
断言逻辑错误
expect(GameManager.cpu.shipsLog.has(${myCoordinate.x}, ${myCoordinate.y}))未使用匹配器,应写成expect(...).toBe(false)(未命中时shipsLog无该坐标船)。此外原代码中船ID无空格,此处加空格会导致断言失败。
测试状态未隔离
- 未在每个测试前重置GameManager及相关对象状态,比如玩家/CPU的board状态会保留,影响后续测试。需在
beforeEach中重新初始化相关对象。
- 未在每个测试前重置GameManager及相关对象状态,比如玩家/CPU的board状态会保留,影响后续测试。需在
修正后的测试示例
import {GameBoard, coordinate} from "./gameboard"; import {GameManager} from './gamemanager'; import {Player} from "./player"; import {EventManager} from "./eventmanager"; // 每个测试前重置状态,避免污染 beforeEach(() => { GameManager.player = Player(`Pablo`); GameManager.cpu = Player(); jest.clearAllMocks(); }); describe(`Player set up`, ()=>{ test(`Player's name is Pablo`,()=>{ expect(GameManager.player.name).toBe('Pablo'); }); test(`Player has a board to play with`, ()=>{ expect(GameManager.player.board).toBeInstanceOf(Object); expect(GameManager.player.board.tiles).toEqual([]); }); }) describe(`Player's actions`,()=>{ test(`Pablo fires a missile, misses the target`, ()=>{ const myCoordinate = coordinate(5,5); // 对关键方法做Spy const notifySpy = jest.spyOn(EventManager, 'notifyAttack'); const getAttackSpy = jest.spyOn(GameManager.cpu.board, 'getAttack'); GameManager.player.fire(myCoordinate); // 验证调用逻辑 expect(notifySpy).toHaveBeenCalledWith('Pablo', myCoordinate); expect(getAttackSpy).toHaveBeenCalledWith(myCoordinate); expect(GameManager.cpu.shipsLog.has(`${myCoordinate.x},${myCoordinate.y}`)).toBe(false); expect(GameManager.turn).toBe('Pablo'); }) })
内容的提问来源于stack exchange,提问作者wavesinaroom
相关产品推荐
相关产品推荐

