Unit Tests
Unittest provides developers with a set of tools to construct and run tests on individual components or units of code to ensure their correctness. By running unittests, developers can identify and fix bugs, creating more reliable code.
Concepts
Unittest relies on the following concepts:
- Test Fixture: Prepares the environment to perform one or more tests, including any necessary cleanup actions. This could involve creating temporary databases, directories, or starting server processes.
- Test Case: An individual unit of testing that checks for a specific response to a set of inputs. The
TestCaseclass provided by unittest can be used to create new test cases. - Test Suite: A collection of test cases or test suites that should be executed together.
- Test Runner: Executes the tests and provides the outcome to the developer. It can use different interfaces, like graphical or textual, to present the test results.
Use Case
Let's look at a test case example where Python code simulates a cake factory performing different functions, such as choosing different sizes and flavors of a cake, adding toppings, returning a list of ingredients, and calculating the price.
from typing import List
class CakeFactory:
def __init__(self, cake_type: str, size: str):
self.cake_type = cake_type
self.size = size
self.toppings = []
# Price based on cake type and size
self.price = 10 if self.cake_type == "chocolate" else 8
self.price += 2 if self.size == "medium" else 4 if self.size == "large" else 0
def add_topping(self, topping: str):
self.toppings.append(topping)
# Adding 1 to the price for each topping
self.price += 1
def check_ingredients(self) -> List[str]:
ingredients = ['flour', 'sugar', 'eggs']
if self.cake_type == "chocolate":
ingredients.append('cocoa')
else:
ingredients.append('vanilla extract')
ingredients += self.toppings
return ingredients
def check_price(self) -> float:
return self.price
# Example of creating a cake and adding toppings
cake = CakeFactory("chocolate", "medium")
cake.add_topping("sprinkles")
cake.add_topping("cherries")
cake_ingredients = cake.check_ingredients()
cake_price = cake.check_price()
cake_ingredients, cake_price
In the code above, the CakeFactory class and its methods are defined. Now, let's define the unittest methods to test the different functions of the code. The test suite includes tests for the cake's flavor, size, toppings, ingredients, and price.
import unittest
class TestCakeFactory(unittest.TestCase):
def test_create_cake(self):
cake = CakeFactory("vanilla", "small")
self.assertEqual(cake.cake_type, "vanilla")
self.assertEqual(cake.size, "small")
self.assertEqual(cake.price, 8) # Vanilla cake, small size
def test_add_topping(self):
cake = CakeFactory("chocolate", "large")
cake.add_topping("sprinkles")
self.assertIn("sprinkles", cake.toppings)
def test_check_ingredients(self):
cake = CakeFactory("chocolate", "medium")
cake.add_topping("cherries")
ingredients = cake.check_ingredients()
self.assertIn("cocoa", ingredients)
self.assertIn("cherries", ingredients)
self.assertNotIn("vanilla extract", ingredients)
def test_check_price(self):
cake = CakeFactory("vanilla", "large")
cake.add_topping("sprinkles")
cake.add_topping("cherries")
price = cake.check_price()
self.assertEqual(price, 13) # Vanilla cake, large size + 2 toppings
# Running the unittests
unittest.TextTestRunner().run(unittest.TestLoader().loadTestsFromTestCase(TestCakeFactory))
This results in the output:
..F.
======================================================================
FAIL: test_check_price (__main__.TestCakeFactory)
----------------------------------------------------------------------
Traceback (most recent call last):
File "<ipython-input-9-32dbf74b3655>", line 33, in test_check_price
self.assertEqual(price, 13) # Vanilla cake, large size + 2 toppings
AssertionError: 14 != 13
----------------------------------------------------------------------
Ran 4 tests in 0.007s
FAILED (failures=1)
<unittest.runner.TextTestResult run=4 errors=0 failures=1>
The test for test_check_price failed because the expected price was incorrect. The cake price should have been 14, not 13. We can correct that part of the test:
def test_check_price(self):
cake = CakeFactory("vanilla", "large")
cake.add_topping("sprinkles")
cake.add_topping("cherries")
price = cake.check_price()
self.assertEqual(price, 14) # Vanilla cake, large size + 2 toppings
Re-running the unittests now gives:
....
----------------------------------------------------------------------
Ran 4 tests in 0.002s
OK
<unittest.runner.TextTestResult run=4 errors=0 failures=0>
Key Takeaways
Unittest assists developers in building robust and effective code. It allows testing of small, isolated units of functionality to catch bugs early and ensure consistent behavior.
Writing Unit Tests in Python
To write unit tests in Python, we use the unittest module, which provides a framework for constructing and running tests.
Example: Testing a Name Rearrangement Function
Suppose we have a function rearrange_name that rearranges names from "Last, First" format to "First Last":
import re
def rearrange_name(name):
result = re.search(r"^([\w .]*), ([\w .]*)$", name)
if result is None:
return name
return "{} {}".format(result[2], result[1])
We can create a test file rearrange_test.py to test this function:
import unittest
from rearrange import rearrange_name
class TestRearrange(unittest.TestCase):
def test_basic(self):
testcase = "Lovelace, Ada"
expected = "Ada Lovelace"
self.assertEqual(rearrange_name(testcase), expected)
if __name__ == '__main__':
unittest.main()
Running the tests:
python rearrange_test.py
The output will indicate whether the tests have passed or failed.
Edge Cases
Edge cases are inputs that fall at the extreme ends of the spectrum of possible inputs. They can often cause unexpected behavior if not properly handled.
Handling Empty Input
Let's test how the function behaves with an empty string:
def test_empty(self):
testcase = ""
expected = ""
self.assertEqual(rearrange_name(testcase), expected)
If this test fails, we need to modify the rearrange_name function to handle empty input appropriately.
Modifying the Function
We can update the function to return the original name if the regex search doesn't find a match:
def rearrange_name(name):
result = re.search(r"^([\w .]*), ([\w .]*)$", name)
if result is None:
return name
return "{} {}".format(result[2], result[1])
Now, the test for the empty input should pass.
Additional Test Cases
Testing Names with Middle Names
Let's add a test case for names with middle names or initials:
def test_double_name(self):
testcase = "Hopper, Grace M."
expected = "Grace M. Hopper"
self.assertEqual(rearrange_name(testcase), expected)
Testing Names Without Comma
Testing names that don't contain a comma:
def test_one_name(self):
testcase = "Voltaire"
expected = "Voltaire"
self.assertEqual(rearrange_name(testcase), expected)
After adding these tests, we can run the test suite to ensure all tests pass.
Pytest
Pytest is a powerful Python testing tool that simplifies writing, organizing, and executing tests. It supports automatic test discovery and generates informative test reports.
Writing Tests with Pytest
Pytest uses simple assert statements for writing tests, making tests easier to read and write.
Example:
def divide(a, b):
assert b != 0, "Cannot divide by zero"
return a / b
An AssertionError is raised if the condition b != 0 is false.
Pytest Fixtures
Fixtures are reusable pieces of test setup and teardown code shared across multiple tests.
Example:
import pytest
class Fruit:
def __init__(self, name):
self.name = name
self.cubed = False
def cube(self):
self.cubed = True
class FruitSalad:
def __init__(self, *fruit_bowl):
self.fruit = fruit_bowl
self._cube_fruit()
def _cube_fruit(self):
for fruit in self.fruit:
fruit.cube()
@pytest.fixture
def fruit_bowl():
return [Fruit("apple"), Fruit("banana")]
def test_fruit_salad(fruit_bowl):
fruit_salad = FruitSalad(*fruit_bowl)
assert all(fruit.cubed for fruit in fruit_salad.fruit)
In this example, fruit_bowl is a fixture that provides test data to the test_fruit_salad test function.
Key Takeaways
- Pytest allows for simple and clear test writing using
assertstatements. - Fixtures help in sharing common test data and configurations across multiple tests.
Comparing Unittest and Pytest
Both unittest and pytest provide tools for creating robust and reliable code through different forms of tests.
Key Differences
| Feature | unittest | pytest |
|---|---|---|
| Inclusion | Built into Python | External library (requires installation) |
| Test Discovery | Requires command-line invocation | Automatic using test_ prefix |
| Test Style | Object-oriented (classes and methods) | Functional (simple functions) |
| Assertions | Special methods like assertEqual() | Uses standard assert statements |
| Compatibility | Backward compatibility with pytest | Can run unittest tests |
When to Use Each
- unittest: Preferred if you want a framework that's built into Python and if you prefer an object-oriented approach.
- pytest: Preferred for its simplicity and powerful features like fixtures and plugins.
Key Takeaways
Both unittest and pytest are beneficial for executing tests in Python. The choice between them depends on the developer's preference and the specific needs of the project.
Best Practices for Unit Testing
- Isolation: Tests should be isolated to ensure any success or failure is caused by the unit being tested, not external factors.
- Avoid Modifying Production Environment: Tests should not modify the live production environment.
- Edge Cases: Include tests for edge cases and unusual inputs to ensure robust code.
- Automatic Testing: Use test runners and frameworks to automate the testing process.
- Consistent Testing Conditions: Use fixtures to maintain consistent testing conditions.