It’s important to understand not only what unit testing is, but how to implement unit tests correctly. Many developers live their lives going through the motions, scraping by — but not you. No, you’re a cut above the rest; on a quest to write unit tests that are the envy of your colleagues. Sound like you? Keep reading. Not resonating? keep reading anyway — I’m sick of cleaning up after you. 🙂
Unit testing is the process of breaking up a program into components to test individually. This can save many hours of manual QA before release. With proper unit tests, you can be sure the program is still working as expected, even after sweeping code changes.
At the start, unit tests can feel like a waste of time. It’s true, doing them properly will require you to write more code, but I promise the time you’ll save in the long run far outweighs the time spent writing tests.
Defining Scope
A common dilemma when first learning: properly defining scope. It’s easy to make the tests either too broad or too specific. The guideline I found to be the most useful? Think of the test as documentation. When writing a function, it’s common to describe its inputs and expected outputs. Unit tests verify this input/output relationship. They should not care how the insides of the method work, so long as it yields the expected result.
In general, a unit test should:
- Test only a single method
- Provide specific arguments to the method
- Verify the result
Let’s dive deeper into each of these points.
Testing a single method
This is exactly what it sounds like. A test should target a single method. Take a look at the following pseudo code.
def test_add():
assertEqual(8, add(5,3))
The snippet above presents a simple test for a function called add
. If we want to make the test more robust, append lines to the existing test function, do not needlessly create another test unless there is a clear difference. For instance, specific edge cases are commonly factored into their own testing method. When incorporating bug fixes, it’s also common to add a specialized test case that references the issue to safeguard against repeating past mistakes.
This is good:
def test_add():
assertEqual(8, add(5, 3))
assertEqual(-1, add(1, -2))
def test_mult():
assertEquals(10, mult(5, 2))
In contrast, this is generally frowned upon:
def test_add_simple():
assertEqual(8, add(5, 3))
def test_add_negative():
assertEqual(-1, add(1, -2))
More than one test for a single method. As mentioned above, an exception could be made if the negative instance is addressing a specific issue in a bug tracker. In that case, a comment should be added with a link to the issue or bug id.
The below snippet is also bad… more than one method tested in a single unit test.
def test_math_functions():
assertEquals(8, add(5, 3))
assertEquals(-1, add(1, -2))
assertEquals(10, mult(5, 2))
Provide specific arguments the method
Do not generate a unique argument every time the test is run. Hard coding is not only okay, but often encouraged (in this context, don’t get carried away)! It’s imperative that unit tests are deterministic. If method arguments are generated on the fly, one developer may get an error while another passes every test. Most important takeaway: testing arguments should be constant across every instance of the program. Let’s look at an example.
Suppose you have a faster way to implement len
called my_len()
that you want to test.
This is good:
def test_my_len():
assertEqual(3, my_len("abc"))
assertEqual(0, my_len([]))
assertEqual(3, my_len([1,2,3]))
This is bad:
def test_my_len():
l = generate_random_list()
assertEqual(len(l), my_len(l))
Different code paths could be tested depending on the list that’s generated.
Another cannon, always use the least amount of assert statements possible for full coverage. If my_len
treats “abc” and “abcd” exactly the same way internally, there is no reason to write two assert statements, just pick one. On the other hand, if the method has a specific if statement to check for a null argument, then absolutely include that.
This is good:
def test_my_len():
assertEqual(3, my_len("abc"))
# Check for defined edge case
assertEqual(-1, my_len(None))
This is bad:
def test_my_len():
assertEqual(3, my_len("abc"))
assertEqual(4, my_len("abcd"))
assertEqual(5, my_len("abcde"))
There’s no reason to believe “abc” would pass and “abcd” would fail. The same code path is being tested multiple times. This is wasteful.
Verify the result
Not much to say here. Like above, the result should also be deterministic. One stylistic note is worth mentioning. Most unit testing frameworks expect assertEquals(...)
to have the expected result as the first argument, and the test result as the second.
assertEqual(expected, actual)
Of course, the tests will work if this is backwards, but your coworkers may scoff.
Testing for exceptions
Testing for exceptions is equally important. If the method is expected to throw an exception with certain arguments, test it! The implementation varies by language. Typically it looks something like this:
assertRaises(IllegalArgumentException, is_numeric(null))
The test case will only pass if an exception is thrown. If a different exception occurs or it returns normally, then it will fail — alerting you to the problem.
Test Driven Development
I firmly believe all projects should use unit tests. This, however, is not what test driven development is.
Test driven development refers to the practice of writing unit tests before the method implementations. It may seem backwards at first, but there are a few key advantages.
- Start thinking about the edge cases early
- Forces the spec to be strictly defined in advance
- Impossible to “forget” to write those unit tests 🙂
Side note: this is becoming more and more common in education. Students get instant feedback on how they’re progressing on an assignment and practice with industry methodology. Unit tests can easily be included in Jupyter Notebooks for the ultimate teaching tool.
Concrete Examples by Language
These are brief examples meant to serve as a reference.
For demonstration purposes, each illustration assumes you want to test a class called Palindrome
. The class has a function called is_palindrome()
that takes a single string argument and returns a boolean.
Unit Testing in Python
Python has dozens of unit testing libraries. We’re going to stick to the aptly named unittest
because it’s built into Python. If you want an even simpler alternative, pytest may be worth a look.
The Palindrome
class in palindrome.py
.
class Palindrome():
@staticmethod
def is_palindrome(s):
return s == s[::-1]
To create the unit test, start with a new file test.py
.
import unittest
from palindrome import Palindrome as p
class TestPalindrome(unittest.TestCase):
def test_is_palindrome(self):
self.assertTrue(p.is_palindrome("racecar"))
self.assertFalse(p.is_palindrome("thisisfalse"))
if __name__ == "__main__":
unittest.main()
Simply run the test file to see the results.
$ python test.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
Unit Testing in Java
JUnit is the defacto standard in Java. First, you will need to add the JUnit dependency to the project. Here’s the quick version for Eclipse:
- Right click the Java project, open Project Properties
- Build Path > Configure Build Path
- Click the Libraries tab
- Click Add Library… on the right side
- Select JUnit from the list and hit next
- Ensure the newest version is selected in the drop down
- Hit Finish followed by OK
IntelliJ IDEs offer to include JUnit automatically when you start writing the test. Simply accept.
With the JUnit dependency added, create a new class called PalindromeTest
. Note the special @Test
annotation.
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
// Import the class to be tested
import com.technohedge.example.Palindrome.isPalindrome;
public class PalindromeTest {
@Test
public void isPalindromeTest() {
assertEquals(true, isPalindrome("racecar");
assertEquals(false, isPalindrome("abc");
}
}