You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

开发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()方法记录此次未命中的攻击

测试代码中的核心问题

  1. 依赖未隔离与Mock缺失

    • 直接使用全局的GameManager和EventManager实例,测试会修改全局状态,污染后续测试用例。
    • 断言expect(GameManager.cpu.getAttack).toBeCalledWith(myCoordinate)错误:getAttack是CPU的board对象方法,而非CPU玩家对象的方法,且未对该方法做Spy/Mock,Jest无法追踪调用情况。
  2. Spy使用不规范

    • 对EventManager.notifyAttack的Spy未在测试后清理,会影响后续测试。需用jest.clearAllMocks()或在afterEach中重置Spy。
    • 仅验证Spy被调用,未验证调用参数是否正确,应补充expect(spy).toHaveBeenCalledWith('Pablo', myCoordinate)。
  3. 断言逻辑错误

    • expect(GameManager.cpu.shipsLog.has(${myCoordinate.x}, ${myCoordinate.y}))未使用匹配器,应写成expect(...).toBe(false)(未命中时shipsLog无该坐标船)。此外原代码中船ID无空格,此处加空格会导致断言失败。
  4. 测试状态未隔离

    • 未在每个测试前重置GameManager及相关对象状态,比如玩家/CPU的board状态会保留,影响后续测试。需在beforeEach中重新初始化相关对象。

修正后的测试示例

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.01 23:00:27