Getting Started With Unit Tests In Python — Part 2: unittest module
Twitter | LinkedIn | YouTube | Instagram
This is part of my learning journey as I dive deep into Python development. If you missed the first part, where I explain the fundamentals of unit testing, make sure you check it out by clicking here.
Summary
- Introduction
- Getting Started & Conventions
- Skipping Tests
- Fixtures
- Assertion Methods
Introduction
The unittest
is a built-in module in Python’s standard library that provides tools for creating and running test cases. It's a powerful tool, and it's good to start testing in Python.
The unittest
was designed to make our lives easier when it comes to testing code. Since it comes with the following:
- TestCase class: This provides a way to define and run test methods.
- Assertion methods: This allows you to check the test results and to determine if the code is working as expected.
- TestLoader and TestRunner classes: This provides a way to discover and run tests in a flexible and extensible way.
- TestSuite class: This allows you to organize and run multiple test cases together.
Thus, you can spend more time thinking about designing your test cases instead of designing your testing modules.
Getting started
import unittest
class SeatFinderTest(unittest.TestCase):
def test_prefer_near_the_front(self):
finder = SeatFinder(available_seats={"A1", "B4", "H2"})
seats = finder.find_seats_in_front(1)
self.assertEqual(["A1"], seats)
if __name__ == '__main__':
unittest.main()
This code is an example of a unit test written using the unittest
module in Python.
It starts by importing the unittest test from Python's standard library.
Then it defines a test class, SeatFinderTest,
which inherits from unittest.TestCase
.
Then we define our first test case, test_prefer_near_the_front
. And in this test case we:
- Create an instance of
SeatFinder
, our unit being tested, with a set of available seats. - We call the
find_seats_in_front
method with the argument1
, which is the number of seats expected to be returned. Then, we expect this function to return a set of seats that we will assign to theseats
variable. - Finally, we assert that the seats returned in the previous function are the same as we expect ("A1").
At last, if __name__ == '__main__':
checks if the code is running as the main program. If it is, then it calls the unittest.main()
method, which discovers and runs the test cases in the file.
Conventions
Before we run our tests, let's go through a few conventions:
Class Name
By convention, we name our test classes as the name of the unit we are testing, followed by the Test
prefix. In our case, we are testing the SeatFinder
unit. Therefore, we name our test class as SeatFinderTest
.
Function Name
Our function is our test case. We name it as test
, followed by a description of what we're testing. In our example: test_prefer_near_the_front
.
Running our tests
If you're using Intellij or any other IDE, you can push a play button to run these tests. Otherwise, you can run them through the command line interface by executing the following command:
python -m unittest SeatFinderTest.py
Running with IntelliJ
IntelliJ will run the same command we would run in the command line interface, with the difference that the test results will be presented in a more organized and understandable way.
You can see all tests are organized in lists. You have a list of Test Results and a list of Test Cases within each element.
You can see how many tests failed and how many passed, and click on them to check the results.
By clicking on our test case that failed, it will show us the TraceBack and the reason why it failed.
In our case, we can see that the error happened on the SeatFinderTest
file, on line 7, in test_prefer_near_the_front
and that the exception thrown was: NameError: name 'SeatFinder' is not defined.
This test failed because the class SeatFinder doesn't exist. We still need to create it. Let's implement it as follows in a new SeatFinder.py
file:
class SeatFinder:
def __init__(self, available_seats):
self.available_seats = available_seats
def find_seats_in_front(self, number_of_seats):
sorted_seats = sorted(self.available_seats)
return sorted_seats[:number_of_seats]
In this implementation, the SeatFinder
class takes a set of available_seats
when it is created. The find_seats_in_front
function sorts the available_seats
and returns the first number_of_seats
elements, representing the seats closest to the front of the plane.
Don't forget to import it into our test class:
from plane.SeatFinder import SeatFinder
And let's run it again:
Cool! Our test case passed now.
Skipping Tests
There are many reasons why you would want to skip a test, and in case you want to skip a test, you can use the @unittest.skip
decorator. By using this decorator, the test runner will not run the test and mark it as skipped.
Additionally, you can also send an optional string argument that can be used to provide a reason for skipping the test. Here's an example:
@unittest.skip("Test is temporarily broken")
def test_example(self):
self.assertTrue(False)
Running the tests again will show that the skipped test was ignored, and the provided message will be displayed to give context.
Besides that, we can also skip tests based on conditions. For example, we can check the platform or the presence of a specific module before skipping a test:
@unittest.skipIf(sys.platform == "darwin", "Test is not supported on MacOS")
def test_example_with_conditional(self):
self.assertTrue(False)
Running the tests again will show that the test was skipped again because this test is not supported on MacOS machines.
SubTest
There are cases where we need to test multiple scenarios, conditions, or input values that are closely related to each other. For example, in the context of the SeatFinder class, we can use subtests to test different sections of the plane, such as the front, middle, and rear sections.
To help us keep our code more organized, readable, and maintainable, the unittest
module provides a feature called SubTest
.
Subtests provide a way to break down test cases into smaller, more manageable parts. They are particularly useful when a single test case can result in multiple failures, as they allow you to isolate the failures and provide more detailed information about each one.
When writing a test case, wrap any part of the test that can fail independently in a with statement and call the subTest method, passing in any relevant arguments.
Let's see an example:
def test_find_seats_within_section(self):
finder = SeatFinder(available_seats={"A1", "B1", "H2"})
with self.subTest("Available seats within section A"):
seats = finder.find_seats_within_section("A")
self.assertEqual({"A1"}, seats)
with self.subTest("Available seats within section H"):
seats = finder.find_seats_within_section("H")
self.assertEqual({"H2"}, seats)
with self.subTest("No available seats within section C"):
seats = finder.find_seats_within_section("C")
self.assertEqual(set(), seats)
In this test case, we are testing the find_seats_within_section
method of the SeatFinder
class.
Each sub-test tests the method find_seats_within_section
for a specific section of the plane and asserts that the returned set of available seats is what was expected.
In the first sub-test, the method is called with the argument “A” and the returned set of available seats is compared to the expected set {"A1"}
.
In the second sub-test, the method is called with the argument “H” and the returned set of available seats is compared to the expected set {"H2"}
.
In the third sub-test, the method is called with the argument “C” and the returned set of available seats is compared to the empty set.
Let's implement the method in our SeatFinder
class as in:
def find_seats_within_section(self, section):
return {seat for seat in self.available_seats if seat.startswith(section)}
And by running the tests again, we can see that our test case comprises a list of sub-tests. These sub-tests can be accessed separately, and each one can have a different status.
Fixtures
It's a good time to introduce the idea of fixtures. Fixtures can be used to set up a test environment, test data, or perform actions that must be done before or after a test is run. They provide a known and controlled environment for tests, making them repeatable and reliable.
In the unittest
module we can have fixtures that act:
- Before or after each test case in a test class
- Before all tests cases in a test class
- Before all test cases in a module
Before or after each test case in a test class
This is the most common one. It is a method that is called before each test method, and it is used to prepare the environment for each individual test. We can define it by using the setUp
or the tearDown
methods in our class.
In the examples presented before, we always started by initializing a SeatFinder
object with the same available seats.
finder = SeatFinder(available_seats={"A1", "B1", "H2"})
Instead of initializing it multiple times, we can instead declare it in the setUp
method, which will initialize it before each test method.
def setUp(self):
self.finder = SeatFinder(available_seats={"A1", "B1", "H2"})
def test_prefer_near_the_front(self):
seats = self.finder.find_seats_in_front(1)
self.assertEqual(["A1"], seats)
def test_find_seats_within_section(self):
with self.subTest("Available seats within section A"):
seats = self.finder.find_seats_within_section("A")
self.assertEqual({"A1"}, seats)
with self.subTest("Available seats within section H"):
seats = self.finder.find_seats_within_section("H")
self.assertEqual({"H2"}, seats)
with self.subTest("No available seats within section C"):
seats = self.finder.find_seats_within_section("C")
self.assertEqual(set(), seats)
It's important to notice that the setUp method will be run before each test case, resetting the state of the object. If one test case mutates the finder variable, it will not affect the other test cases.
Before all test cases in a test class
If we want to share the state of the object among all the test cases, it's better to use the setUpClass
method. With this method, if one of the test cases modifies the object that was initialized within the setUpClass
method, other test cases will also be affected by these changes. This is because, differently from the setUp
method which is called before each test case, the setUpClass
method will be called only once before all the test cases within the class.
def setUpClass(self):
self.finder = SeatFinder(available_seats={"A1", "B1", "H2"})
def test_reserve_seat(self):
is_reserved = self.finder.reserve_seats({"A1", "B1"})
self.assertTrue(is_reserved)
def test_find_available_seats(self):
available_seats = self.finder.find_available_seats()
self.assertEqual({"A1", "B1", "H2"}, available_seats)
In the example above the test case test_find_available_seats
would fail. This is because the test case test_reserve_seat
would have reserved two of the three seats available. Since the object state hasn't been reset, the only available seat would be "H1" when the following test ran.
Before all tests in the module
Besides the two methods mentioned previously, we also have the setUpModule
and tearDownModule
methods.
These setUpModule
is called once before any tests in the module are run, and the tearDownModule
is called once after all tests in the module have been run.
Note that the module level setup and teardown methods are called only once for the entire test module, not for each test class or test case.
These can be helpful when all tests need a database connection, for example. We can have the connection with the database started before all tests run, and the connection closed after all tests have finished.
Tear down methods
Another important thing to mention is that the tear-down methods will be called regardless of the final state of the tests. The failure of a test will not prevent the tear-down methods from running. This is helpful when you need to close connections with the database or reset the state of resources, even when tests fail.
Assertion Methods
The TestCase
class provides several assert methods to check for and report failures. These are the most commonly used methods, but you can see more of them in the official documentation by clicking here.
Conclusion
Unittest is a powerful tool for testing in Python. By following the conventions, developers can easily create and run unit tests in a consistent manner.
The ability to skip tests and use fixtures provides a flexible way to handle different testing scenarios.
Finally, the various assertion methods allow for easy validation of test results and make it simple to write effective and comprehensive tests.
Whether you’re just getting started with unit testing or looking to expand your testing skills, unittest is a must-know tool for any Python developer.
What's next?
In the following parts, we will dive deeper pytest
, an open-source module that is more powerful and easier to use, and doctest, a module that allows you to embed test cases directly in the documentation of your code.
Contribute
Writing takes time and effort. I love writing and sharing knowledge, but I also have bills to pay. If you like my work, please, consider donating through Buy Me a Coffee: https://www.buymeacoffee.com/RaphaelDeLio
Or by sending me BitCoin: 1HjG7pmghg3Z8RATH4aiUWr156BGafJ6Zw
Follow Me on Social Media
Stay connected and dive deeper into the world of Python with me! Follow my journey across all major social platforms for exclusive content, tips, and discussions.