Adding Tests to Existing Code

You have started to develop software with Test Driven Development, but now need to change code with no tests. What is the best approach?

Adding Tests to Existing Code
Photo by Kelly Sikkema / Unsplash

Software engineers rarely get to work on completely new projects; there is almost always prior code to integrate with. What do you do if there are no unit or other tests on an existing piece of code, one that you need to modify? A good practice, one taken from Test Driven Development (TDD), is to write tests for the code you plan to change before making any fixes.

That can sometimes be tricky to do. Even when you only need to make a small, one or two line change, how the function or class is written can make it tricky to make that modification. What if the existing code has a bug? This will often be the reason for changing some old code – it is not functioning as expected.

A general rule of thumb is to add tests to capture the current way the code functions first. Then add a test case for the bug or thing you want to change (this test should fail). Finally fix the code and make sure that all the tests now pass. Don’t be afraid to add unit tests to someone else’s code. If they have not heard about TDD, then point them to this blog!

Be careful not to try to add too much test code at once. Focus on testing the classes or functions you are going to need to change, anything more could eat up a whole lot of time.

An Easy Example

Continuing on from my post How to Start Test Driven Development, where we wrote code to add two Roman Numerals together, we now want to change the existing functions which we relied upon in that example. One converted a String representing a Roman Numeral to an Int and the other did the reverse. We suspect there is a bug in one of these functions but there are no tests in the code base. What can we do?

Photo by Sandeep: https://www.pexels.com/photo/wristwatch-with-strap-on-white-surface-6018369/

Let’s add some tests. This time we know the structure of the function under test as it already exists. The first is an extension function on String that returns an Int. Its signature is fun String.fromRomanNumeral(): Int. We can write a test for this easily with the following:

@Test
fun `should convert a Roman Numeral to an integer`() {
	val input = "IV"
	val expected = 4
	val result = input.fromRomanNumeral()
	assertEquals(expected, result)
}

We can run the test and check that it passes. If it does, then it is not the scenario that generates the bug we noticed before. We could try to add various scenarios as other tests, converting Roman Numerals with various letters or different orders of letters to try to find the issue.

It can prove difficult to find the issue in another person’s code; try focusing on edge conditions or use the issue that caused you to investigate the bug in the first place. If there is a definite example you know about that fails, write the test for that. In our example, before expending too much time on a single test, perhaps writing the test will help find the bug.

More Tests

The process then repeats itself for the next dependency our code has. For example, we next need a test for the function to convert an integer to a Roman Numeral.

@Test
fun `should convert an Int to an Roman Numeral`() {
	val input = 4
	val expected = "IV"
	val result = input.toRomanNumeral()
	assertEquals(expected, result)
}

This looks similar to the previous example. In fact, unit tests should mostly follow a similar pattern. A few lines to setup inputs and expectations. Next there is an execution step – I prefer to keep this to a single statment, if possible. Finally there should be a minimum of one assertion that checks something pertinent about the result.

Too many assertions could indicate a code smell; perhaps the function is testing too many things. It might be a good idea to break up the function under test and the unit test into smaller chunks.

A large amount of code in the setup can also indicate that the class is too complicated to test easily – it may be a good candidate for refactoring once you have written enough tests to give a good a baseline of test coverage. A more advanced topic but not covered in this post is how to make code more testable. It is generally easier by default if you start with TDD, as your code tends to default to testable code.

Finding Your Bug

If there is a bug in the code, writing a test to show the failure is important. When the actual code gets fixed, the test should start to pass. Sometimes it can be difficult to pin down the exact input required to show the issue. In general, tests should only check one thing, but in cases like these it might be useful to make a test check a large range of possible inputs.

@Test
fun `should convert integers from 1 to 1000 to Roman Numerals and back`() {
    for (i in 1..1000) {
        val asRomanNumeral = i.toRomanNumeral()
        val asInteger = asRomanNumeral.fromRomanNumeral()
        assertEquals(i, asInteger, "Incorrect conversion of $i either to Roman Numeral $asRomanNumeral or back to $asInteger")
    }
}

Here the code makes sure that when an Int is converted to a Roman Numeral and then back again, that the number matches, and does this from one to one thousand. It is not guaranteed to work – the bug could be the same in both translation to and from an integer.

I prefer not to use this style of testing as it will fail on the first encountered issue. There could be more issues for higher numbers but running the test won’t show those until the first issue has been addressed.

The code from this post is available in this GitHub Gist.

Summary

Test Driven Development can be used when making changes to existing code. The first step is to try to write some tests for the code that needs to be changed. This can be easy, like in this example, or really difficult. I believe you should make the attempt if you can, but contact the code author for assistance if you can’t even figure out what the code should do in the first place.

Once testing for the existing code is in place, add more cases or change the existing tests to get a failing test result for the functionality you need to add or change. Then complete the implementation. In the next post I will look at refactoring this code supported by the unit tests we have written.

Adding tests will leave the source code in a better place than when you started. The tests describe the functionality and make it easier to change in the future.