- Learn strategies for testing legacy code
- Learn how to refactor legacy code in a TDD way
In Exercise 2B, we touched on how test coverage helps you see regression issues. In this section, we will modify legacy code by introducing incremental tests.
Note that for this exercise, the legacy methods will NOT be counted toward test coverage, but all new code you write will. This will closely resemble how you might add tests in a production code base that lacks tests.
The legacy code in question places mines onto an imaginary board. We have two issues with this code: add a new feature for dynamic board size and to fix a bug with mine placement, but either change would require a major rewrite. We will tackle this rewrite by breaking the method into testable components.
In this first exercise, we want to introduce a small structural change that lets us write our high-level tests.
Our first objective is to move the legacy function inside an object (class). This first set of changes is the most risky in that they are made with little to no test coverage. Subsequent changes become less risky as tests are added.
Examine minesweeper.ts.
Our first change is to separate the printing logic from the calculation logic without impacting dependencies. We will
move the entire contents of printMineSweeperBoard
to another method to accomplish this.
- Create a
MineSweeper
class inside theLegacyCode
module. - Create a new method called
toString
and move the current contents ofprintMineSweeperBoard
into it - Change the signature of
toString
to return a string that would otherwise be printed to console. - Change
printMineSweeperBoard
so that it prints the return value fromtoString
as shown here:
console.log(new MineSweeper().toString(guesses, mineCount));
The above steps move key pieces of logic out of the legacy function without disrupting legacy code.
Now we need to add as many tests as we can to the legacy logic while recognizing some things are difficult/impossible to test.
There is a bug with how many mines are placed; we will fix it later so don't worry about finding it if you don't encounter it. Had this been a real assignment, this may be when we would have caught the issue. For now, ignore all failing tests that fail inconsistently. When writing tests against mine placement, use a low number of mines being placed. We will fix this in exercise 3.
Let's add more tests so that refactoring is safer. Add tests to toString
that:
- Checks that the string represents a 10x10 grid (
(10 chars + line break) * 10 rows
) - Ensures at least
n
mines are placed (mines are either?
or*
) wheren
is the argument provided in the method. You may want to use a small value forn
for this test given the bug mentioned previously. DO NOT FIX THE BUG. - Ensures a string containing only underscores, asterisks, line breaks, and question marks is returned
Now that you have test coverage, we are nearly ready to refactor the core logic. Before we can fix anything, we
need to isolate the code that is causing the problem. In order to do this, we need to break the toString
into even smaller pieces. We will do two structural changes that will help with subsequent changes:
- Create a new interface called
Board
with two properties:x: number
,y: number
- Refactor the class to expect a
size: Board
,guesses
, andmineCount
as constructor arguments. This will break previous tests. Fix those tests. You will also need to updateprintMineSweeperBoard()
to provide the legacy default values totoString
(i.e., the value ofx
andy
need to be set to 10.
If you haven't encounter the bug yet -- great. Let's encounter it now. Try updating your previous test that tests the mine placement to attempt to place 99 mines in a 10x10 grid (all squares except 1 will be filled with a mine). The test should fail nearly every time.
Now we need to update the actual mine placement logic. We will simulate a situation where core logic is being changed:
- Inside the inner
for
loop, there is anif
block that contains logic for determining if a mine should be placed. Extract the if condition into a method with the signature:shouldPlaceMine: () => bool
. - Add a
getMineLocations: () => Array<MineCoordinate>
method (that will be used in the next step) that predetermines which positions mines should be placed in. The method returns an array of positions that mines should be placed at and if it runs again, returns the same results. - Fix the logic for
shouldPlaceMine
: right now it gives early squares a higher chance of getting a mine (this is the bug) and possibly does not place enough mines. Use the results fromgetMineLocations
to make sure all mines always get placed. DO NOT SPEND TOO MUCH TIME TRYING TO GET PERFECT DISTRIBUTION -- the important part is that all required mines are always placed.
The above illustrates an example of how one might start to refactor a block of procedural code. There's much more refactoring that could be done, which is left as optional exercises:
- Lower the number of mines being placed and update your tests for
printMineSweeperBoard
: stubgetMineLocations()
so that it returns a predetermined set of coordinates that you can write your tests against. The randomly broken tests should now always pass. Make sure to check ifgetMineLocations
is getting called (via spies). - Add a method for printing a mine: change the line with the ternary operator to call a method instead:
placeMine: (coordinate: MineCoordinate) => string
which will return either*
or?
based on if the location has been guessed during the constructor. - Extract the logic to check if a guess overlaps with a mine placement (the line that mentions
guesses[k].x == j
) - Add a border to the entire board
- Extract a method to build a row (instead of an additional inner
for
loop)