Introduction ๐
Hello! I am currently on my journey to understand the powerful tool that is unit testing in Python. I recently came across an enlightening tutorial from PythonTutorial.net, which provided a great starting point. However, as someone new to this field, I noticed a few areas where things could potentially be made more efficient. So, let's dive into it!
Note: I've recently gained new knowledge about unit testing, and I'm excited to share it with you. Please consider that the information below represents my current understanding, which may evolve over time. I'll continue to update and refine this post as I learn more about the intricacies of unit testing. Your understanding and patience are greatly appreciated.
Python Test Fixtures Example ๐ค
Consider a BankAccount class that we used for our first exercise:
Bank Account class:
class InsufficientFund(Exception): | |
pass | |
class BankAccount: | |
def __init__(self, balance: float) -> None: | |
if balance < 0: | |
raise ValueError('balance cannot be negative') | |
self._balance = balance | |
@property | |
def balance(self) -> float: | |
return self._balance | |
def deposit(self, amount: float) -> None: | |
if amount <= 0: | |
raise ValueError('The amount must be positive') | |
self._balance += amount | |
def withdraw(self, amount: float) -> None: | |
if amount <= 0: | |
raise ValueError('The withdrawal amount must be more than 0') | |
if amount > self._balance: | |
raise InsufficientFund('Insufficient ammount for withdrawal') | |
self._balance -= amount |
We set up our original test class for the BankAccount as follows:
import unittest | |
from bank_account import BankAccount, InsufficientFund | |
class TestBankAccount(unittest.TestCase): | |
def setUp(self) -> None: | |
self.bank_account = BankAccount(100) | |
def test_deposit(self): | |
self.bank_account.deposit(100) | |
self.assertEqual(self.bank_account.balance, 200) | |
def test_withdraw(self): | |
self.bank_account.withdraw(50) | |
self.assertEqual(self.bank_account.balance, 50) | |
def tearDown(self) -> None: | |
self.bank_account = None |
This certainly works, but can we do better? Yes, I believe we can. Here's how we can improve this:
Avoid unnecessary object deletions: In our
tearDown
methods, we are currently setting the objects toNone
. However, Python's garbage collection would automatically handle the deletion of objects that are no longer in use. Thus, we could focus more on ensuring that your tests are independent and do not share mutable state to avoid unexpected side effects.Replace direct answers with calculated results: Instead of using hard-coded values for assertions, using calculations or variables can make our tests more robust and adaptable to changes. This approach allows for flexibility when modifying your code in the future.
My suggestion:
import unittest | |
from bank_account import BankAccount, InsufficientFund | |
# Use constants for initial balance and deposit/withdrawal amounts | |
INIT_BALANCE = 100 | |
DEPOSIT_AMOUNT = 100 | |
WITHDRAW_AMOUNT = 50 | |
class TestBankAccount(unittest.TestCase): | |
def setUp(self) -> None: | |
self.bank_account = BankAccount(INIT_BALANCE) | |
def test_deposit(self): | |
self.bank_account.deposit(DEPOSIT_AMOUNT) | |
# Use calculations in assertions instead of hard-coded values | |
self.assertEqual(self.bank_account.balance, INIT_BALANCE + DEPOSIT_AMOUNT) | |
def test_withdraw(self): | |
self.bank_account.withdraw(WITHDRAW_AMOUNT) | |
# Use calculations in assertions instead of hard-coded values | |
self.assertEqual(self.bank_account.balance, INIT_BALANCE - WITHDRAW_AMOUNT) | |
def tearDown(self) -> None: | |
# Instead of setting to None, let Python's garbage collection handle it | |
del self.bank_account |
Indeed, one can enhance their unit tests further by incorporating helper functions, especially when dealing with complex arithmetic operations. Helper functions improve the readability of your code and make it easier to maintain. However, be mindful that overuse of helper functions for simple operations like addition and subtraction could potentially convolute your code more than clarify it. The goal is to strike a balance between readability and simplicity, ensuring that your test suite remains straightforward and easy to understand, maintain, and debug.
Transitioning from our BankAccount example, let's dive into a more complex scenario to illustrate this concept effectively.
Abstracting Arithmetic Operations for Better Testing ๐ค
Let's look at Heron's formula, a method for calculating the area of a triangle, which involves a blend of arithmetic operations such as multiplication and subtraction. This is a scenario where abstracting the arithmetic operations into helper functions can shine and significantly improve the readability and maintainability of your code. Here's how it could look:
import math | |
class Triangle: | |
def __init__(self, a: float, b: float, c: float): | |
self.a = a | |
self.b = b | |
self.c = c | |
def perimeter(self): | |
return self.a + self.b + self.c | |
def semi_perimeter(self): | |
return self.perimeter() / 2 | |
def area(self): | |
s = self.semi_perimeter() | |
return math.sqrt(s * (s - self.a) * (s - self.b) * (s - self.c)) |
And here's the corresponding test class with helper functions:
import math | |
import unittest | |
from triangle import Triangle | |
# Use constants for initial sides | |
A = 5 | |
B = 12 | |
C = 13 | |
def sum(*args): | |
return math.fsum(args) | |
def subtract(a, b): | |
return a - b | |
def multiply(*args): | |
result = 1 | |
for num in args: | |
result *= num | |
return result | |
def sqrt(a): | |
return math.sqrt(a) | |
class TestTriangle(unittest.TestCase): | |
def setUp(self) -> None: | |
self.triangle = Triangle(A, B, C) | |
def test_perimeter(self): | |
self.assertEqual(self.triangle.perimeter(), sum(A, B, C)) | |
def test_semi_perimeter(self): | |
self.assertEqual(self.triangle.semi_perimeter(), sum(A, B, C) / 2) | |
def test_area(self): | |
s = self.triangle.semi_perimeter() | |
self.assertEqual(self.triangle.area(), sqrt(multiply(s, subtract(s, A), subtract(s, B), subtract(s, C)))) | |
def tearDown(self) -> None: | |
del self.triangle |
In this case, using helper functions to abstract the arithmetic operations makes the assertions easier to read. Also, since Heron's formula involves both multiplication and subtraction, having separate functions for these operations increases the readability of the test_area
method.
And yes, one of the benefits of this approach is composability. For example, the semi_perimeter
function is used as part of the area
function, and this is reflected in the tests as well. This enhances the maintainability of the test suite, as changes to the computation of the semi-perimeter would only require updates to the semi_perimeter
and area
tests, not the perimeter
test.
Conclusion ๐คโ
Unit testing is a critical instrument in any software development process, and becoming proficient at it is indeed an ongoing journey. The insights I've shared today are inspired by my own beginner's perspective, a fresh viewpoint that often brings unique value to seasoned developers. It is my hope that sharing this journey from a junior's perspective can shed light on new insights for more seasoned coders and also prove valuable to those just starting their journey in writing Python unit tests.
Remember, it's advisable to steer clear of unnecessary object deletions in your tearDown methods - Python's garbage collection handles this efficiently. Also, consider replacing hard-coded values with calculated results to enhance the flexibility and resilience of your tests, making them better suited to adapt to future changes. This practice of ensuring your tests are robust, adaptable, and maintainable is integral for efficient unit testing and overall quality software development.
As a beginner in unit testing, I hope my insights can help others who are also starting their journey in writing Python unit tests. I am always looking to improve my skills and welcome feedback and suggestions in the comments section below. Let's learn and grow together!
Follow me on Beacons for more Python and software development insights.
Top comments (0)