Getting Started With Unit Tests in Python — Part 1: Fundamentals

Raphael De Lio
10 min readJan 27, 2023

Twitter | LinkedIn | YouTube | Instagram
This is part of my learning journey as I dive deep into Python development.

In this article, we will dive into the world of unit testing. We will cover the basics of what unit tests are, the appropriate situations in which to use them, and when it may be more beneficial to forego them.

Furthermore, we will familiarize ourselves with the common terminology associated with unit testing and explore a practical approach to designing effective tests.

Whether you are a seasoned software developer or new to the field, this guide will provide valuable insight into the realm of unit testing and its role in ensuring the reliability and robustness of your code.

What are unit tests?

In simple words, unit tests are like little checks you run on your code to ensure it’s working correctly. You will write these tests and then run them automatically to ensure the code is doing what it should.

The goal is to catch any bugs or problems early before they cause more significant issues if they go undetected. Making sure each part of your code is working as it should.

A unit is a small code that performs a specific task or function. It can be a single line of code, a function, a method, or a class. It’s a way of breaking down a larger program into smaller, manageable parts.

It’s also important to note that unit tests should be written in such a way that they are isolated so that one test doesn’t affect the other. They should also be repeatable, providing the same result every time they run, and be fast, meaning that they don’t take too long to run. Ideally, you don’t want your unit tests to use the file system, the database, the network, or external resources.

Why unit testing?

There are several reasons why you might want to implement unit tests in your code:

Ensuring code quality

Unit tests help to ensure that your code is working correctly and is free of bugs. By writing tests covering different scenarios and edge cases, you can be confident that your code will continue to work correctly even as you make changes to it.

Facilitating refactoring

Unit tests make it easier to refactor your code. If you have a suite of tests covering your code, you can confidently make changes to the code, knowing that if the tests still pass, the code is still working correctly.

Improving code design

Writing unit tests can help you identify poorly designed or hard-to-test areas of your code. By focusing on writing tests for your code, you may be forced to think about how to design your code in a more modular and testable way.

Making collaboration easier

Unit tests can be a valuable tool when working on a team. By having a set of tests covering the code, other team members can understand how the code is supposed to work and confidently make changes.

Documenting code

Unit tests can serve as a form of documentation for your code. By reading the tests, others can understand how the code is supposed to be used and what it is supposed to do.

When NOT writing unit tests?

There are a few situations where unit tests may not be the best choice:

Trivial code

If the code you’re working on is straightforward and unlikely to change, it may not be worth the effort to write unit tests for it.

Third-party code

If you’re using third-party code, like libraries and frameworks, that you don’t have the ability to modify, it may not make sense to write unit tests for it.

Limited resources

If you have limited resources and need to prioritize your testing efforts, you may focus on other testing types, such as integration or acceptance testing.

The code is going to be replaced or deprecated soon

If the code you are working on will be replaced or quickly deprecated, it may not be worth the effort to write unit tests for it.

It’s also important to remember that unit tests, while useful, are not the only way to ensure software quality. Other testing methods, such as integration testing, functional testing, and manual testing, can ensure that your code works correctly.

Common vocabulary

Test Case

A test case is a set of instructions you write to test a specific part of your code. It’s like a little check you run to ensure your code is working correctly.

Test cases should be independent. You should be able to run them in any order and always get the same results.

Test cases should also only test one specific piece of code. This is because if one test case fails, you know exactly which part of the code is not working correctly.

Additionally, independent test cases also provide more flexibility. You can run them individually or as a group and run only the failed tests again, not the whole test suite, which is easier to debug.

Test Suite

A test suite is a group of tests put together to be run simultaneously. It’s like a folder that contains all the little checks you wrote to ensure your code is working correctly. This way, you can run all the tests simultaneously instead of running them one by one, it makes it easy to check if everything is working fine, and it also makes it easy to keep track of your tests.

Test Runner

A test runner is a tool that helps you run your tests. It’s like a helper program that takes all the little checks you wrote (test cases) and runs them automatically for you. It also gives you the results of the tests, which ones passed and which ones failed, and it can also help you find where the problems are so you can fix them. Test runners make it easy to check your code quickly and regularly.

In this story, the test runner we'll use is unittest, a built-in module in Python’s standard library that provides tools for creating and running test cases.

Closer look

Let’s take a look at a few unit tests in which the goal is to test the seat finder for passengers in an airplane:

# Note: A is the front row, so A2 is the 2ndseat on the front row
# Consider the airplane has seats sorted by 2-2
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)

def test_prefer_aisle_seats(self):
finder = SeatFinder(available_seats={"A1", "A2", "A3", "A4"})
seats = finder.find_aisle_seats(2)
self.assertEqual({"A2", "A3"}, seats)

def test_not_enough_seats(self):
finder = SeatFinder(available_seats={"A1", "A2"})
seats = finder.find_seats(3)
self.assertEqual({}, seats)

These are good examples of unit tests because they:

Test specific functionality

Each test case tests a specific aspect of the SeatFinder class’s functionality, such as preferring seats near the front, preferring aisle seats, and handling the case when there are not enough available seats.

Use clear and descriptive test method names

The test method names, test_prefer_near_the_front(), test_prefer_aisle_seats(), and test_not_enough_seats(), clearly describe what is being tested in each case.

Have a single assertion

Each test case has a single assertion that checks the expected output of the tested method against the actual output. This makes it easy to understand what is being tested and the expected result.

Are independent

Each test case is entirely independent of the other test cases and can be run independently. This means that changes to one test case do not affect the others and that the other test cases are not affected if one test case fails.

Test the edge cases

The last test test_not_enough_seats() is a good example of testing the edge cases, which is an important part of testing and helps ensure the class works correctly even in unexpected situations.

Besides that, they all follow the AAA pattern, which is a good choice for designing our unit tests, as we will see next.

Designing our unit tests

Take a look at the following code. Is it a good design choice for our tests?

def test_seat_finder():
finder = SeatFinder(available_seats={"A1", "B4", "H2"})

expected_seats = {"A1"}
seats = finder.find_seats_in_front(1)
self.assertEqual(expected_seats, seats)

is_assigned = finder.assign_seats(seats)
self.assertTrue(is_assigned)

expected_seats = {"B4", "H2"}
seats = finder.find_seats(2)
self.assertEqual({"B4", "H2"}, seats)

is_assigned = finder.assign_seats(seats)
self.assertTrue(is_assigned)

seats = finder.find_seats(5)
self.assertEqual({}, seats)

Let's start by evaluating the name of the test: test_seat_finder

Name

test_seat_finder is not a good name choice for our test. We cannot tell what it is actually testing. Test names should be clear and descriptive.

Specific Functionality

Let’s say there’s a bug in the assign_seats method, and it's assigning all seats instead of only the ones sent as a parameter in the method call. All the seats on the plane would not be available anymore.

You then test the find_seats function, expecting it to return the two seats that were supposed to be available, but instead, it returns nothing, and the test fails.

Since the test failed to assert the result of find_seats, you would now be inclined to think that the bug is actually in this unit. Your test design is leading you to believe it.

However, the actual bug is in the assign_seats function that assigns all the seats available in the plane instead of only those that are supposed to be.

Determining which part of the code is broken if one of the assertions fails is complex. You will need to check which assertion failed and ensure that it failed due to a bug in its own implementation and not a bug in another unit of the code.

Each test case should test a specific aspect of the SeatFinder class’ functionality. However, this test is also not testing specific functionality. It’s actually testing multiple functionalities of the SeatFinder feature. It may be hard to tell, but it’s testing the functionalities to:

  • find seats in front
  • assign seats
  • find available seats
  • return nothing when there are not enough seats available

What to do instead? (The AAA pattern)

A well-designed unit test follows the “Arrange-Act-Assert” (AAA) pattern. This pattern is used to structure the test method and make it easy to understand what is being tested and the expected outcome. In the previous example, we had multiple arranges, acts, and assertions in the same unit test, as you can see:

def test_seat_finder():
# Arrange
finder = SeatFinder(available_seats={"A1", "B4", "H2"})
expected_seats = {"A1"}

# Act
seats = finder.find_seats_in_front(1)

# Assert
self.assertEqual(expected_seats, seats)

# Act
is_assigned = finder.assign_seats(seats)

# Assert
self.assertTrue(is_assigned)

# Arrange
expected_seats = {"B4", "H2"}

# Act
seats = finder.find_seats(2)

# Assert
self.assertEqual({"B4", "H2"}, seats)

# Act
is_assigned = finder.assign_seats(seats)

# Assert
self.assertTrue(is_assigned)

# Act
seats = finder.find_seats(5)

# Assert
self.assertEqual({}, seats)

Let's break this test into four different test cases and go through each concept of "AAA".

class TestSeatFinder(unittest.TestCase):
def test_find_seats_in_front(self):
# Arrange
finder = SeatFinder(available_seats={"A1", "B4", "H2"})
expected_seats = {"A1"}

# Act
seats = finder.find_seats_in_front(1)

# Assert
self.assertEqual(expected_seats, seats)

def test_assign_seats(self):
# Arrange
finder = SeatFinder(available_seats={"A1", "B4", "H2"})
seats = {"A1"}

# Act
is_assigned = finder.assign_seats(seats)

# Assert
self.assertTrue(is_assigned)

def test_find_seats(self):
# Arrange
finder = SeatFinder(available_seats={"A1", "B4", "H2"})
expected_seats = {"B4", "H2"}

# Act
seats = finder.find_seats(2)

# Assert
self.assertEqual(expected_seats, seats)

def test_find_seats_not_enough(self):
# Arrange
finder = SeatFinder(available_seats={"A1", "B4", "H2"})

# Act
seats = finder.find_seats(5)

# Assert
self.assertEqual({}, seats)

Arrange

This is where the test sets up the system’s initial state. This typically involves creating objects, setting their properties, and preparing any inputs required for the method being tested. In the above example, the Arrange part creates an instance of the SeatFinder class with a set of available seats.

finder = SeatFinder(available_seats={"A1", "B4", "H2"})

Act

This is where the test executes the method or code being tested. This is the actual execution of the code, where the inputs are passed to the system under test, and the output is generated. In the above example, the Act part is calling the find_seats_in_front method.

seats = finder.find_seats_in_front(1)

Assert

This is where the test checks the output of the method or code being tested against the expected outcome. This typically involves using an assertion method, such as assertEqual(), to compare the actual output with the expected output. In the above example, the Assert part is using self.assertEqual(expected_seats, seats) to check the output of the tested methods.

self.assertEqual(expected_seats, seats)

This pattern is powerful because it is simple. It makes unit tests easy to understand, maintain and debug. It also makes it clear what is being tested and what the expected result is, making it easier to identify the source of a failure when a test fails.

Conclusion

In this story, we understood the basics of unit testing. We gained an understanding of what unit tests are, the appropriate situations to use them, and when they should not be utilized.

Furthermore, we became familiar with the commonly used terminology in unit testing and were provided with an efficient method for designing our tests.

We are now ready for what comes next!

What's next?

In the following parts, we will dive deeper into different testing modules, such as the unittest module, a built-in module in Python’s standard library that provides tools for creating and running test cases, andpytest, 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.

Twitter | LinkedIn | YouTube | Instagram

--

--

Raphael De Lio

Software Consultant @ Xebia - Dutch Kotlin User Group Organizer: https://kotlin.nl