Quality Assurance on PHP projects - PHPUnit part 2
I hope everyone enjoyed my first
article on unit testing with phpunit
where I started writing a few tests that would guide us building our
little game of tictactoe. Today I'm going start with turning these
tests into working code and adjusting our tests to have a clear separation of responsibility. Since we already know what the code should
produce, we only have to work out the details.
Our tests tell us we have four classes:
But we're not there yet, we still have 2 more things to verify: positioning of a symbol and verifying we have a 3 identical symbols in any of the three rows, columns or diagonal rows.
Let's look at our test verifying the positioning of a symbol:
Hey, now we can also use this to verify if we have 3 identical symbols in a horizontal, vertical or diagonal row! Let's work that out as well.
Next time, let's look at our Players as the need also some respect, right.
Our tests tell us we have four classes:
- Tictactoe: the main class that is responsible for the game and how it should be played
- Grid: is the class that's responsible for setting up the playing grid
- Players: a collection class containing both player objects
- Player: the class defining a single player
public function testGameGridIsSetAtStart() { $grid = $this->_ttt->getGrid(); $this->assertInstanceOf('Grid', $grid); $this->assertEquals(3, count($grid->getRows())); foreach ($grid->getRows() as $row) { $this->assertInternalType('array', $row); $this->assertEquals(3, count($row)); $this->assertNull($row[0]); $this->assertNull($row[1]); $this->assertNull($row[2]); } }What can we learn from this test?
- Grid is part of our main TicTacToe class
- has a method that fetches rows of type array
- and each row has columns as an array
- Grid class will instantiate a grid with 3 rows and 3 columns
- When calling "getRows()" we retrieve an array of 3 columns
- Each field in the grid will have a null value as a default
- A Player must have a way to set his "symbol" on a specific position on the grid
- Must have a way to verify if a given symbol exists in either a horizontal, a vertical or a diagonal row
<?php /** * TicTacToe * * A simple game that's played with two players, each taking a turn by marking * a field in a grid of 3 x 3 with either an X or an O (one symbol per player). * Winner is the one who has 3 identical symbols in a single horizontal, * vertical or diagonal row. * * @package Tictactoe * @license "Attribution-ShareAlike 3.0 Unported (CC BY-SA 3.0)" * @link http://creativecommons.org/licenses/by-sa/3.0/ */ /** * Grid * * This Grid class is responsible to set up and maintain the playing field * * @package Tictactoe * @category Tictactoe * */ class Grid { /** * Constant to define the number of rows * @var int */ const ROWS = 3; /** * Constant to define the number of colomns * @var int */ const COLS = 3; /** * Container for all rows and columns * * @var array */ protected $_rows = array (); /** * Constructor for this Grid class that will set up our grid with 3 rows * and 3 columns while setting the value of each field to NULL */ public function __construct() { for ($i = 0; $i < self::ROWS; $i++) { $columns = array (); for ($j = 0; $j < self::COLS; $j++) { $columns[$j] = null; } $this->addRow($columns); } } /** * Adds a row to the grid, requiring an array of 3 fields representing * the columns * * @param array $row * @return Grid */ public function addRow(array $row) { $this->_rows[] = $row; return $this; } /** * Retrieves all rows from this grid, including an array for the columns * for each row * * @return array */ public function getRows() { return $this->_rows; } /** * Sets the symbol for each field * * @param int $row The position of the field in the row * @param int $column The position of the field in the column * @param string $symbol */ public function setSymbol($row, $column, $symbol) { $this->_rows[$row][$column] = $symbol; return $this; } /** * Retrieves the current symbol from a given cordinate on the grid * * @param int $row The postion of the field in the row * @param int $column The position of the field in the column * @return string */ public function getSymbol($row, $column) { return $this->_rows[$row][$column]; } /** * Validation method to verify if a given symbol is found 3 times in a * single row. If we have 3 matches, it will return TRUE. In all other * cases it will return FALSE. * * @param string $symbol * @return boolean */ public function inRow($symbol) { foreach ($this->getRows() as $row) { $match = 0; foreach ($row as $column) { if ($symbol === $column) { $match++; } } if (self::ROWS === $match) { return true; } } return false; } /** * Validation method to verify if a given symbol is found 3 times in a * single column. If we have 3 matches, it will return TRUE. In all other * cases it will return FALSE. * * @param string $symbol * @return boolean */ public function inColumn($symbol) { for ($i = 0; $i < self::COLS; $i++) { $match = 0; for ($j = 0; $j < self::ROWS; $j++) { if ($symbol === $this->_rows[$j][$i]) { $match++; } } if (self::COLS === $match) { return true; } } return false; } /** * Validation method to verify if a given symbol is found 3 times in a * single diagonal row. If we have 3 matches, it will return TRUE. In all * other cases it will return FALSE. * * @param string $symbol * @return boolean */ public function inDiagonal($symbol) { $match1 = $match2 = 0; for ($i = 0; $i < self::ROWS; $i++) { if ($symbol === $this->_rows[$i][$i]) { $match1++; } if ($symbol === $this->_rows[$i][self::COLS - 1 - $i]) { $match2++; } } if (self::ROWS === $match1 || self::ROWS === $match2) { return true; } return false; } }Now we have a grid class, but we cannot test it properly as we use a Tictactoe class to validate everything. No problem, we can modify our tests as well to better serve our needs. Actually, we already have defined our test criteria. Let's list them again:
- Grid is part of our main TicTacToe class (is not really relevant for our Grid functionality)
- has a method that fetches rows of type array
- and each row has columns as an array
- Grid class will instantiate a grid with 3 rows and 3 columns
- When calling "getRows()" we retrieve an array of 3 rows with each 3 columns
- Each field in the grid will have a null value as a default
- A Player must have a way to set his "symbol" on a specific position on the grid
- Must have a way to verify if a given symbol exists in either a horizontal, a vertical or a diagonal row
<?php class GridTest extends PHPUnit_Framework_TestCase { const TEST_SYMBOL = 'X'; protected $_grid; protected function setUp() { $this->_grid = new Grid(); parent::setUp(); } protected function tearDown() { parent::tearDown(); $this->_grid = null; } public function testGameGridIsSetAtStart() { $this->assertEquals(Grid::ROWS, count($this->_grid->getRows())); foreach ($this->_grid->getRows() as $row) { $this->assertInternalType('array', $row); $this->assertEquals(Grid::COLS, count($row)); foreach ($row as $column) { $this->assertNull($column); } } } }So now we have exactly the same as before, except we created a separate TestCase specifically designed for Grid related tests. This allows us to follow up on issues people might report later on (things we haven't thought of or just don't know yet), as we can write now a specific test for our Grid.
But we're not there yet, we still have 2 more things to verify: positioning of a symbol and verifying we have a 3 identical symbols in any of the three rows, columns or diagonal rows.
Let's look at our test verifying the positioning of a symbol:
public function testGridCanPositionASymbol() { $this->_grid->setSymbol(0, 0, self::TEST_SYMBOL); $this->assertNotNull($this->_grid->getSymbol(0,0)); $this->assertEquals(self::TEST_SYMBOL, $this->_grid->getSymbol(0,0)); }But as you see, this is not really working for us. We can only ferify just one position on the grid and if we want to test more positions, our test method would grow exponentionally. Say hello to the "dataProvider"
public function cordinateProvider() { return array ( array (0,0), array (0,1), array (0,2), array (1,0), array (1,1), array (1,2), array (2,0), array (2,1), array (2,2), ); } /** * @dataProvider cordinateProvider */ public function testGridCanPositionASymbol($row, $column) { $this->_grid->setSymbol($row, $column, self::TEST_SYMBOL); $this->assertNotNull($this->_grid->getSymbol($row, $column)); $this->assertEquals(self::TEST_SYMBOL, $this->_grid->getSymbol($row, $column)); }The @dataProvider tag in your codeblock comment tells PHPUnit to use the method specified by this tag as a "provider" of data. Each row in this data provider method (as it returns an array of arrays) will be a provisioner for your test class, and for each row, your test will be executed from a clean state, so you don't have any corruption due to bad resetting of your objects.
Hey, now we can also use this to verify if we have 3 identical symbols in a horizontal, vertical or diagonal row! Let's work that out as well.
protected function _setDataOnGrid($data) { foreach ($data as $field) { list ($row, $column) = $field; $this->_grid->setSymbol($row, $column, self::TEST_SYMBOL); } } public function horizontalRowProvider() { return array ( array (array (array (0,0), array (0,1), array (0,2))), array (array (array (1,0), array (1,1), array (1,2))), array (array (array (2,0), array (2,1), array (2,2))), ); } /** * @dataProvider horizontalRowProvider */ public function testGridHasThreeSymbolsInARow($data) { $this->_setDataOnGrid($data); $this->assertTrue($this->_grid->inRow(self::TEST_SYMBOL)); } public function VerticalRowProvider() { return array ( array (array (array (0,0), array (1,0), array (2,0))), array (array (array (0,1), array (1,1), array (2,1))), array (array (array (0,2), array (1,2), array (2,2))), ); } /** * @dataProvider VerticalRowProvider */ public function testGridHasThreeSymbolsInAColumn($data) { $this->_setDataOnGrid($data); $this->assertTrue($this->_grid->inColumn(self::TEST_SYMBOL)); } public function DiagonalRowProvider() { return array ( array (array (array (0,0), array (1,1), array (2,2))), array (array (array (0,2), array (1,1), array (2,0))), ); } /** * @dataProvider DiagonalRowProvider */ public function testGridHasThreeSymbolsInADiagonal($data) { $this->_setDataOnGrid($data); $this->assertTrue($this->_grid->inDiagonal(self::TEST_SYMBOL)); }Oh, Wow! We now have a complete Grid test case that isolates all Grid related tasks and responsibilities.
<?php class GridTest extends PHPUnit_Framework_TestCase { const TEST_SYMBOL = 'X'; protected $_grid; protected function setUp() { $this->_grid = new Grid(); parent::setUp(); } protected function tearDown() { parent::tearDown(); $this->_grid = null; } public function testGameGridIsSetAtStart() { $this->assertEquals(Grid::ROWS, count($this->_grid->getRows())); foreach ($this->_grid->getRows() as $row) { $this->assertInternalType('array', $row); $this->assertEquals(Grid::COLS, count($row)); foreach ($row as $column) { $this->assertNull($column); } } } public function cordinateProvider() { return array ( array (0,0), array (0,1), array (0,2), array (1,0), array (1,1), array (1,2), array (2,0), array (2,1), array (2,2), ); } /** * @dataProvider cordinateProvider */ public function testGridCanPositionASymbol($row, $column) { $this->_grid->setSymbol($row, $column, self::TEST_SYMBOL); $this->assertNotNull($this->_grid->getSymbol($row, $column)); $this->assertEquals(self::TEST_SYMBOL, $this->_grid->getSymbol($row, $column)); } protected function _setDataOnGrid($data) { foreach ($data as $field) { list ($row, $column) = $field; $this->_grid->setSymbol($row, $column, self::TEST_SYMBOL); } } public function horizontalRowProvider() { return array ( array (array (array (0,0), array (0,1), array (0,2))), array (array (array (1,0), array (1,1), array (1,2))), array (array (array (2,0), array (2,1), array (2,2))), ); } /** * @dataProvider horizontalRowProvider */ public function testGridHasThreeSymbolsInARow($data) { $this->_setDataOnGrid($data); $this->assertTrue($this->_grid->inRow(self::TEST_SYMBOL)); } public function VerticalRowProvider() { return array ( array (array (array (0,0), array (1,0), array (2,0))), array (array (array (0,1), array (1,1), array (2,1))), array (array (array (0,2), array (1,2), array (2,2))), ); } /** * @dataProvider VerticalRowProvider */ public function testGridHasThreeSymbolsInAColumn($data) { $this->_setDataOnGrid($data); $this->assertTrue($this->_grid->inColumn(self::TEST_SYMBOL)); } public function DiagonalRowProvider() { return array ( array (array (array (0,0), array (1,1), array (2,2))), array (array (array (0,2), array (1,1), array (2,0))), ); } /** * @dataProvider DiagonalRowProvider */ public function testGridHasThreeSymbolsInADiagonal($data) { $this->_setDataOnGrid($data); $this->assertTrue($this->_grid->inDiagonal(self::TEST_SYMBOL)); } }Running this test along with our initial TictactoeTest should result in a warm, fuzzy, green feeling when you see this screen:
Next time, let's look at our Players as the need also some respect, right.
Hi, Thank you very much.
ReplyDeleteWhy do you write your tests first and then write the production code? I am starting and I write a test, then the production code, then another test and so on...
Thank you very much! Well done sir!
@Willian: I write most test up-front because I want to have a big-picture view (as I did in part 1), when I zoom in on the matter writing the code, I get to see the details so I can focus on that (part 2). But if you're focussing on details all the time, you sometimes forget what the big picture was, hence I still have a test for that.
ReplyDeleteBut your way of testing is as-good. To me, this works well. Maybe for others, no so much.
Now I understand. Please keep going!
ReplyDeleteThank you very much.
Nice job ;)
ReplyDeleteHey Michelangelo,
ReplyDeletefinally got round to reading the articles a bit. Kinda caught me by surprise to only see positive tests (perhaps on purpose as a scope issue), but yeah. Any reason why there are no negative test? And with that I mean that when a new grid is set up, and a row is filled you make sure to test that *only* a row is returned as existant, not a column or diagonal as well. It's logic that is tightly knit together and in which easily mistakes could be made, hence why I'd expect the negative tests as well.
Anyways, good read! Cheers!
@Daan,
ReplyDeleteYou're absolutely right, the negative or false results have been neglected here because of one single reason: the results are false by default. It's just a minor work to add a test to verify that in all other cases, the result is FALSE.