Many developers design their programs with an architecture-driven approach, first getting the application to work end-to-end, integrating most features, and only adding tests when things start to break.
However, true test-driven development (TDD) differs enormously from a process like the above. In this article, we’ll provide an overview of test-driven development in Python and show you how a TDD-informed approach will help both your code and the developers who maintain it.
What Is TDD?
Kent Beck introduced TDD back in 2003 with his book “Test-Driven Development by Example” (an excellent TLDR of which you can find here), setting out that TDD’s purpose is to alleviate fear in application development. Unlike architecture-driven design, TDD emphasizes three interwoven processes — testing, coding and refactoring. When those processes are combined they result in a robust program.
Order matters in TDD. Before writing the program itself, you’ll start by writing a test that’s designed to fail. You’ll then create a program that should pass the test and run the test. If the program fails, you would refactor it until your test gives the green light. But a passing test doesn’t necessarily mean the end of refactoring. In fact, “red, green, refactor” is a common phrase among test-driven developers: Red signals a failing test, green indicates a passing test and refactor refers to shaping up the code of a passing test until it’s clean.
So although TDD begins with writing tests to check your code’s functionality before the code itself is actually written, it doesn’t end with a passed test. The end goal is clean code that’s a step (or steps) above passable. With this in mind, remember that TDD isn’t a panacea that guarantees a perfect program, but a quality-driven tool that can help write good code.
What are the benefits of employing a test-driven approach? First, it ensures you plan your code with tests in mind. By outlining a process flow, TDD gives you clarity when designing a program and keeps you from over-engineering. Second, it gives you confidence when you begin to refactor code, as it allows for easy debugging. Third, it gifts you extensibility: When adding a new feature, you can easily check if your new code breaks old code.
Now that we understand the gears behind a test-driven approach, let’s look at what constitutes the “test” part of TDD.
Types of Tests
Four kinds of testing have their place in TDD: unit, integration, functional and acceptance. Unit tests ensure independent pieces of code (like functions) do their jobs. Meanwhile, integration tests check if an entire system, such as a transaction, is functioning as expected. On the other hand, regression testing’s job is to detect breaking changes in code after fixes or upgrades have been made; as such, it’s a special case of unit and integration testing. Lastly, acceptance tests focus on user experience by emulating real-world usage conditions. To gain a deeper understanding of testing types, check out this helpful overview.
For a program to be truly test-driven, both the unit and integration tests must work together to hold it to standard, while regression and acceptance tests aren’t always expected. Accordingly, Martin Fowler introduced the TestPyramid, a structure that helps developers create tests in a balanced way. As you can see, unit and integration tests should comprise the vast majority of testing, so let’s look at these tests in more detail.
While “testing a unit of code” might sound easy, writing unit tests is an art form that requires critical thought. For example, a good test designer considers not only if a function has failed, but how it went wrong and why that matters. Senior developer Steven Sanderson provides tips for writing commendable unit tests, such as designing each test to be independent of all other unit tests. On the other hand, integration tests require a thorough knowledge of how a whole process should perform, including the routes and methods that hold that system in place. With integration testing, we’re essentially checking if specific pieces of code (that pass their own respective unit tests) work together toward an end result.
Let’s now move on to actually implementing tests. From here on out, we’ll focus on writing unit tests to even better understand test-driven development in Python.
A plethora of test runners are available for Python developers, the three most popular being unittest, pytest and nose. Here, we’ll focus on unittest because it’s built into the Python standard library (and has been since version 2.1). You’ll likely encounter unittest in open-source projects and enterprise Python apps.
Before jumping into a coding example using unittest, let’s look at some of the module’s requirements and logistics to help you employ it correctly. First, you can import the module with the code import unittest.
With unittest, you must write your tests as methods that are part of a class. So after importing the module, you’ll want to create a case-specific class that inherits from the generic TestCase class. For example, if you’re testing a function that should return a minimum, you’ll want your class to be called something like TestMinimum.
Within your class, create functions that will test your program. Extending our example, if you’re testing a min() function (here we’re using Python’s inbuilt method), you can write something like this:
def test_minimum(): assert min([1, 2, 3]) == 1, "Should be 1"
You’ll then turn your test functions into methods of your test class by including self as the first argument. Also, change the assert to self.assertEqual(), since unittest requires that you use special assertion methods in the unittest.TestCase class. You might eventually write your test as a class method to begin with, but using a standard Python function definition as you learn unittest might make the transition easier. The method, encased in its class, will look like this:
def test_minimum(self): self.assertEqual(min([1, 2, 3]), 1, "Should be 1")
For the full run-down on unittest, see the Python docs. We’re now ready to put it all together and look at a full example of how testing a program works.
TDD Flow Example
Let’s start by creating two files: program.py and test.py. We’ll start by writing a unit test in test.py that verifies the outcome of a subtraction function to be written in our program file:
class TestDifference(unittest.TestCase): def test_difference(self): # this function doesn't exist yet from program import difference # store the expected result in a variable result = difference(7, 2) # check if result equals expected result self.assertEqual(2, result, "Should be 2")
As you can see, this definition of test_difference() is a little different from our earlier definition of test_minimum(), which was a one-liner that didn’t have a result variable. They’re both equally valid and illustrate that you have some flexibility when writing your tests.
Creating the class and testing function alone won’t verify our test results. We need to run the test suite by changing the command-line entry point. We can do so by adding the following code, which calls unittest.main() to the end of the file:
if __name__ == '__main__': unittest.main()
Now, we need to use a shell command to run our file. To run a single test case, you can use this command from your root directory, making sure to provide the import path to your test case:
python -m unittest <test_directory>
Obviously, this test will fail. We haven’t included anything in program.py, so our test is testing an undefined function. Note that had we already created a function that wasn’t returning anything useful, our test would still fail. Let’s now write a function definition to do what the test expects:
def difference(x, y): return x - y
We reran the test, and this time it passed! If we had a more complex function, we might take the opportunity to see if we could clean up our now-passing program code.
We recommend trying something simple like this on your own to get a feel for writing tests. To quickly try things out in a local development environment, you might need variations of the shell commands to run your tests.
Now that you’re clear on test-driven development in Python, you’re on your way to writing quality code with a clear design. But to master both test-writing and programming itself, you’ll need many more skills in your pocket. If you can see yourself testing (and writing) programs in the future, check out our Introduction to Programming Nanodegree to continue your learning journey.