Tuesday, April 30, 2019

TDD in JavaScript (Part 3)

This blog post was originally published on the ThoroughTest website, back when that was a thing. As a co-founder and primary content contributor for ThoroughTest, I absolutely own the rights to this post and the source code to which it refers. I intend to reproduce each blog post here on my personal blog since the company is no longer in business.

In our twoprevious posts on this topic we setup Jasmine in preparation for simple browser-based tic-tac-toe game, established our requirements for the game, and wrote our first test and first bit of functionality for the game. In this post we're going to work quickly through the iterative process of test-driven development.

Before we write a bunch of tests we're going to introduce the beforeEach and afterEach functions of Jasmine. As their names describe beforeEach and afterEach are executed before and after each spec (test) in a describe block, respectively. We're going to migrate some pieces from our first test into these functions so we can reuse them.

Changing our tests to be more concise, reusable, or more efficient is part of the very important refactoring step in test-driven development.
describe('selectBox', () => {
  beforeEach(() => {
    const elementToCreate = document.createElement('button');
    elementToCreate.id = 'topLeft';
    elementToCreate.innerHTML = ' ';
    elementToCreate.onclick = selectBox(elementToCreate);
    document.body.appendChild(elementToCreate);
  });

  it('should mark the box with the current players marker', () => {
    // arrange
    const element = document.getElementById('topLeft');

    // act
    element.click();

    // assert
    expect(element.innerHTML).toBe('X');
  });

  afterEach(() => {
    document.body.removeChild(document.getElementById('topLeft'));
  });
});
Now that we've refactored our unit test we can quickly write some more. We recommend writing the unit test blocks before writing all of the tests. Doing so helps us identify potential discrepancies in the requirements and gives us a better understanding of what we're being asked to code.

describe('selectBox', () => {
  beforeEach(() => {
    const elementToCreate = document.createElement('button');
    elementToCreate.id = 'topLeft';
    elementToCreate.innerHTML = ' ';
    elementToCreate.onclick = selectBox(elementToCreate);
    document.body.appendChild(elementToCreate);
  });

  it('should mark the box with the current players marker', () => {
    // arrange
    const element = document.getElementById('topLeft');

    // act
    element.click();

    // assert
    expect(element.innerHTML).toBe('X');
  });
  
  it('should become Os turn when X marks a box', () => {});
 
  it('should mark the box with O when the current player is X', () => {});
  
  it('should become Xs turn when O marks a box', () => {});

  it('should end the game when O marks three boxes in a row', () => {});

  it('should end the game when X marks three boxes in a column', () => {});

  it('should end the game when O marks three boxes diagonally', () => {});

  it('should end the game when all boxes are marked and neither player has won',
    () => {});

  afterEach(() => {
    document.body.removeChild(document.getElementById('topLeft'));
  });
});
While we were writing our tests we noticed that our requirements never specified how to start a new game, whether to keep track of how many wins each player had or how many games ended in a draw. We're going to move forward without these features, but it's good that we identified them now. If the product owner really wanted them, we would have been able to renegotiate the work we're doing to make sure we included the most important features in the first release.

Now that we have our tests started we can start writing them. For the sake of brevity, we've written them all and included them here.

describe('selectBox', () => {
  beforeEach(() => {
    const elementToCreate = document.createElement('button');
    elementToCreate.id = 'topLeft';
    elementToCreate.innerHTML = ' ';
    elementToCreate.onclick = selectBox(elementToCreate);
    document.body.appendChild(elementToCreate);
  });

  it('should mark the box with X when the current player is O', () => {
    // arrange
    const element = document.getElementById('topLeft');

    // act
    element.click(element);

    // assert
    expect(element.innerHTML).toBe('X');
  });

  it('should become Os turn when X marks a box', () => {
    // arrange
    player = 'X';
    const element = document.getElementById('topLeft');

    // act
    element.click(element);

    // assert
    expect(player).toBe('O');
  });

  it('should mark the box with O when the current player is X', () => {
    // arrange
    player = 'O';
    const element = document.getElementById('topLeft');

    // act
    element.click(element);

    // assert
    expect(element.innerHTML).toBe('O');
  });

  it('should become Xs turn when O marks a box', () => {
    // arrange
    player = 'O';
    const element = document.getElementById('topLeft');

    // act
    element.click(element);

    // assert
    expect(player).toBe('X');
  });

  it('should end the game when O marks three boxes in a row', () => {
    // arrange
    player = 'O';
    moves = ['', 'O', 'O', 'X', 'X', '', 'X', '', ''];
    
    // act
    element.click(element);

    // assert
    expect(document.getElementById('gameStatus').innerHTML).toBe('<h1>O Wins!</h1>');
  });

  it('should end the game when X marks three boxes in a column', () => {
    // arrange
    player = 'X';
    moves = ['', 'O', 'O', 'X', 'X', '', 'X', '', ''];

    // act
    element.click(element);

    // assert
    expect(document.getElementById('gameStatus').innerHTML).toBe('<h1>X Wins!</h1>');
  });

  it('should end the game when O marks three boxes diagonally', () => {
    // arrange
    player = 'O';
    moves = ['', 'X', 'X', 'X', 'O', '', '', '', 'O'];

    // act
    element.click(element);

    // assert
    expect(document.getElementById('gameStatus').innerHTML).toBe('<h1>O Wins!</h1>');
  });

  it('should end the game when all boxes are marked and neither player has won',
    () => {
      // arrange
      player = 'X';
      moves = ['', 'X', 'O', 'O', 'O', 'X', 'X', 'O', 'X'];

      // act
      element.click(element);

      // assert
      expect(  
        document.getElementById('gameStatus')
        .innerHTML).toBe('<h1>Cat\'s Game!</h1>');
  });
  
  afterEach(() => {
    document.body.removeChild(document.getElementById('topLeft'));
  });
});
All of our tests fail except the first one, which is perfectly normal. Now we write just a little bit of code, using our tests as our guides.

let moves = ['', '', '', '', '', '', '', '', ''];
let player = 'X';

function selectBox() {
  this.innerHTML = player;

  if (this.id === 'topLeft') {
    moves[0] = player;
  } else if (this.id === 'topMiddle') {
    moves[1] = player;
  } else if (this.id === 'topRight') {
    moves[2] = player;
  } else if (this.id === 'centerLeft') {
    moves[3] = player;
  } else if (this.id === 'centerMiddle') {
    moves[4] = player;
  } else if (this.id === 'centerRight') {
    moves[5] = player;
  } else if (this.id === 'bottomLeft') {
    moves[6] = player;
  } else if (this.id === 'bottomMiddle') {
    moves[7] = player;
  } else if (this.id === 'bottomRight') {
    moves[8] = player;
  }

  if (moves[0] === moves[1] && moves[1] === moves[2]) {
    document.getElementById('gameStatus').innerHTML = '<h1>' + player + ' Wins!</h1>';
  } else if (moves[0] === moves[3] && moves[3] === moves[6]) {
    document.getElementById('gameStatus').innerHTML = '<h1>' + player + ' Wins!</h1>';
  } else if (moves[0] === moves[4] && moves[4] === moves[8]) {
    document.getElementById('gameStatus').innerHTML = '<h1>' + player + ' Wins!</h1>';
  } else {
    document.getElementById('gameStatus').innerHTML = '<h1>Cat\'s Game!</h1>';
  }

  player = player === 'X' ? 'O' : 'X';
}
This function causes all of our tests to pass, but doesn't mean the game will work. We're only checking whether the top row is a win, or the left column, or diagonally from top left to bottom right. If someone marks all three boxes in the middle row they won't win. We'll need to go back and write more tests, then update our code for the tests to pass.

We've added the additional tests we needed and we also refactored some of our test code to include some helper methods to create and remove all of the buttons from the form for us for each test.

describe('selectBox', () => {
  function createButton(id) {
    const elementToCreate = document.createElement('button');
    elementToCreate.id = id;
    elementToCreate.innerHTML = ' '
    elementToCreate.onclick = selectBox;
    document.body.appendChild(elementToCreate);
  }

  beforeEach(() => {
    createButton('topLeft');
    createButton('topMiddle');
    createButton('topRight');
    createButton('centerLeft');
    createButton('centerMiddle');
    createButton('centerRight');
    createButton('bottomLeft');
    createButton('bottomMiddle');
    createButton('bottomRight');

    const gameStatusElement = document.createElement('div');
    gameStatusElement.id = 'gameStatus';
    document.body.appendChild(gameStatusElement);
  });

  it('should mark the box with X when the current player is O', () => {
    // arrange
    const element = document.getElementById('topLeft');

    // act
    element.click();

    // assert
    expect(element.innerHTML).toBe('X');
  });

  it('should become Os turn when X marks a box', () => {
    // arrange
    player = 'X';
    const element = document.getElementById('topLeft');

    // act
    element.click();

    // assert
    expect(player).toBe('O');
  });

  it('should mark the box with O when the current player is X', () => {
    // arrange
    player = 'O';
    const element = document.getElementById('topLeft');

    // act
    element.click();

    // assert
    expect(element.innerHTML).toBe('O');
  });

  it('should become Xs turn when O marks a box', () => {
    // arrange
    player = 'O';
    const element = document.getElementById('topLeft');

    // act
    element.click();

    // assert
    expect(player).toBe('X');
  });

  it('should end the game when O marks three boxes in the top row', () => {
    // arrange
    player = 'O';
    moves = ['', 'O', 'O', 'X', 'X', '', 'X', '', ''];
    const element = document.getElementById('topLeft');
    
    // act
    element.click();

    // assert
    expect(document.getElementById('gameStatus').innerHTML).toBe('<h1>O Wins!</h1>');
  });

  it('should end the game when X marks three boxes in the middle row', () => {
    // arrange
    player = 'X';
    moves = ['', 'O', 'O', 'X', 'X', '', 'X', 'O', ''];
    const element = document.getElementById('centerRight');
    
    // act
    element.click();

    // assert
    expect(document.getElementById('gameStatus').innerHTML).toBe('<h1>X Wins!</h1>');
  });

  it('should end the game when O marks three boxes in the bottom row', () => {
    // arrange
    player = 'O';
    moves = ['', '', 'X', 'X', 'X', '', 'O', '', 'O'];
    const element = document.getElementById('bottomMiddle');
    
    // act
    element.click();

    // assert
    expect(document.getElementById('gameStatus').innerHTML).toBe('<h1>O Wins!</h1>');
  });

  it('should end the game when X marks three boxes in the left column', () => {
    // arrange
    player = 'X';
    moves = ['', 'O', 'O', 'X', 'X', '', 'X', '', ''];
    const element = document.getElementById('topLeft');

    // act
    element.click();

    // assert
    expect(document.getElementById('gameStatus').innerHTML).toBe('<h1>X Wins!</h1>');
  });

  it('should end the game when O marks three boxes in the middle column', () => {
    // arrange
    player = 'O';
    moves = ['X', 'O', '', 'X', '', 'X', '', 'O', ''];
    const element = document.getElementById('centerMiddle');

    // act
    element.click();

    // assert
    expect(document.getElementById('gameStatus').innerHTML).toBe('<h1>O Wins!</h1>');
  });

  it('should end the game when X marks three boxes in the right column', () => {
    // arrange
    player = 'X';
    moves = ['O', 'X', 'X', 'O', '', 'X', '', 'O', ''];
    const element = document.getElementById('bottomRight');

    // act
    element.click();

    // assert
    expect(document.getElementById('gameStatus').innerHTML).toBe('<h1>X Wins!</h1>');
  });

  it('should end the game when O marks three boxes diagonally from top left to bottom right', () => {
    // arrange
    player = 'O';
    moves = ['', 'X', 'X', 'X', 'O', '', '', '', 'O'];
    const element = document.getElementById('topLeft');

    // act
    element.click();

    // assert
    expect(document.getElementById('gameStatus').innerHTML).toBe('<h1>O Wins!</h1>');
  });

  it('should end the game when X marks three boxes diagonally from top right to bottom left', () => {
    // arrange
    player = 'X';
    moves = ['O', 'O', 'X', '', 'X', '', '', 'X', 'O'];
    const element = document.getElementById('bottomLeft');

    // act
    element.click();

    // assert
    expect(document.getElementById('gameStatus').innerHTML).toBe('<h1>X Wins!</h1>');
  });

  it('should not end the game when not all boxes are marked and neither player has won', () => {
    // arrange
    player = 'O';
    moves = ['', 'X', 'O', '', 'O', 'X', 'X', 'O', 'X'];
    const element = document.getElementById('topLeft');

    // act
    element.click();

    // assert
    expect(document.getElementById('gameStatus').innerHTML).toBe('');
  });

  it('should end the game when all boxes are marked and neither player has won', () => {
    // arrange
    player = 'X';
    moves = ['', 'X', 'O', 'O', 'O', 'X', 'X', 'O', 'X'];
    const element = document.getElementById('topLeft');

    // act
    element.click();

    // assert
    expect(document.getElementById('gameStatus').innerHTML).toBe('<h1>Cat\'s Game!</h1>');
  });
  
  afterEach(() => {
    document.body.removeChild(document.getElementById('topLeft'));
    document.body.removeChild(document.getElementById('topMiddle'));
    document.body.removeChild(document.getElementById('topRight'));
    document.body.removeChild(document.getElementById('centerLeft'));
    document.body.removeChild(document.getElementById('centerMiddle'));
    document.body.removeChild(document.getElementById('centerRight'));
    document.body.removeChild(document.getElementById('bottomLeft'));
    document.body.removeChild(document.getElementById('bottomMiddle'));
    document.body.removeChild(document.getElementById('bottomRight'));
    document.body.removeChild(document.getElementById('gameStatus'));
  });
});
With these updated tests we'll need to update our code, so we've done that as well.

let moves = ['', '', '', '', '', '', '', '', ''];
let player = 'X';


function selectBox() {
  this.innerHTML = player;

  if (this.id === 'topLeft') {
    moves[0] = player;
  } else if (this.id === 'topMiddle') {
    moves[1] = player;
  } else if (this.id === 'topRight') {
    moves[2] = player;
  } else if (this.id === 'centerLeft') {
    moves[3] = player;
  } else if (this.id === 'centerMiddle') {
    moves[4] = player;
  } else if (this.id === 'centerRight') {
    moves[5] = player;
  } else if (this.id === 'bottomLeft') {
    moves[6] = player;
  } else if (this.id === 'bottomMiddle') {
    moves[7] = player;
  } else if (this.id === 'bottomRight') {
    moves[8] = player;
  }

  if (moves[0] === player && moves[1] === player && moves[2] === player) {
    // top row
    document.getElementById('gameStatus').innerHTML = '<h1>' + player + ' Wins!</h1>';
  } else if (moves[3] === player && moves[4] === player && moves[5] === player) {
    // middle row
    document.getElementById('gameStatus').innerHTML = '<h1>' + player + ' Wins!</h1>';
  } else if (moves[6] === player && moves[7] === player && moves[8] === player) {
    // bottom row
    document.getElementById('gameStatus').innerHTML = '<h1>' + player + ' Wins!</h1>';
  } else if (moves[0] === player && moves[3] === player && moves[6] === player) {
    // left column
    document.getElementById('gameStatus').innerHTML = '<h1>' + player + ' Wins!</h1>';
  } else if (moves[1] === player && moves[4] === player && moves[7] === player) {
    // middle column
    document.getElementById('gameStatus').innerHTML = '<h1>' + player + ' Wins!</h1>';
  } else if (moves[2] === player && moves[5] === player && moves[8] === player) {
    // right column
    document.getElementById('gameStatus').innerHTML = '<h1>' + player + ' Wins!</h1>';
  } else if (moves[0] === player && moves[4] === player && moves[8] === player) {
    // top left to bottom right
    document.getElementById('gameStatus').innerHTML = '<h1>' + player + ' Wins!</h1>';
  } else if (moves[2] === player && moves[4] === player && moves[6] === player) {
    // top right to bottom left
    document.getElementById('gameStatus').innerHTML = '<h1>' + player + ' Wins!</h1>';
  } else if (!!moves[0] && !!moves[1] && !!moves[2] &&
    !!moves[3] && !!moves[4] && !!moves[5] &&
    !!moves[6] && !!moves[7] && !!moves[8]) {
      document.getElementById('gameStatus').innerHTML = '<h1>Cat\'s Game!</h1>';
  }

  player = player === 'X' ? 'O' : 'X';
}
And now our game of Tic Tac Toe should work exactly as we planned. The only part left is creating the actual markup for the game board. Since that's not really TDD we're going to show you what we used, but not go into any great detail about it. Here's our HTML file.

<html>
 <head>
  <script src="./src/tic-tac-toe.js"></script>
  <style>
   #board {
    height: 50%;
    width: 100%;
    position: relative;
   }

   button {
    font-size: 3em;
    width: 75px;
    height: 75px;
    position: relative;
    float: left;
   }

   #centerLeft, #bottomLeft, #gameStatus {
    clear: left;
   }

   #gameStatus {
    height: 10%;
    text-align: center;
   }
  </style>
 </head>
 <body>
  <div id="board"></div>
  <div id="gameStatus"></div>
  <script>createForm()</script>
 </body>
</html>
We're creating the controls for the form dynamically so we'll show you that part, too (the createForm() function called at the bottom of the markup you see up there).
It's important to note that we skipped doing TDD on the createForm and createButton functions for this guide, but if we were really developing a game like this we absolutely would have.


let buttons = ['topLeft', 'topMiddle', 'topRight', 'centerLeft', 'centerMiddle', 'centerRight', 'bottomLeft', 'bottomMiddle', 'bottomRight'];

function createButton(id) {
  const button = document.createElement('button');
  button.innerHTML = ' ';
  button.onclick = selectBox;
  button.id = id;
  return button;
}

function createForm() {
  const board = document.getElementById('board');
  for (let i = 0; i < buttons.length; i++) {
    const button = createButton(buttons[i]);
    board.appendChild(button);
  }
}
These two functions are included in our tic-tac-toe.js file for easier inclusion in the page.

That's the end of the guide on using test-driven development for your JavaScript. Happy coding!


No comments:

Post a Comment