Python Testing Basics
Introduction
Writing code is only half the battle in software development. How do you know your code works as expected? How can you be confident your changes won't break existing functionality? This is where testing comes in.
Testing is the process of verifying that your code behaves as expected under various conditions. In Python, we have several built-in and third-party frameworks that make testing straightforward and effective.
By the end of this guide, you'll understand:
- Why testing is important
- Different types of tests
- How to write basic tests using
unittest
- How to use the popular
pytest
framework - Best practices for testing your Python code
Why Testing Matters
Testing might seem like an extra step that slows down development, but it actually offers several critical benefits:
- Bug Detection: Tests help you catch bugs before your users do
- Code Confidence: You can refactor or add features with confidence that you haven't broken existing functionality
- Documentation: Tests serve as executable documentation showing how your code should work
- Design Improvement: Writing tests often leads to better code design
- Regression Prevention: Tests ensure that fixed bugs don't reappear later
Types of Tests
Unit Tests
Unit tests verify that individual components (functions, classes, methods) work correctly in isolation. They're small, focused, and should run quickly.
Integration Tests
Integration tests check that different components work correctly together. They verify that integrated parts of your application function properly as a group.
Functional Tests
Functional tests validate that your system works according to specifications from an end-user perspective.
Getting Started with unittest
Python's standard library includes the unittest
module, which provides a framework for organizing and running tests.
A Simple Example
Let's create a simple function and test it:
# math_functions.py
def add(a, b):
return a + b
Now, let's write a test for this function:
# test_math_functions.py
import unittest
from math_functions import add
class TestMathFunctions(unittest.TestCase):
def test_add(self):
self.assertEqual(add(1, 2), 3)
self.assertEqual(add(-1, 1), 0)
self.assertEqual(add(-1, -1), -2)
if __name__ == '__main__':
unittest.main()
Running this test:
python test_math_functions.py
Output:
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
Anatomy of a unittest Test
- Import the
unittest
module and the code you want to test - Create a class that inherits from
unittest.TestCase
- Write methods that start with
test_
- Use assertion methods to verify expected outcomes
- Run the tests using
unittest.main()
Common Assertion Methods
unittest
provides many assertion methods:
# Equal and Not Equal
self.assertEqual(a, b)
self.assertNotEqual(a, b)
# True and False
self.assertTrue(x)
self.assertFalse(x)
# Is and Is Not
self.assertIs(a, b)
self.assertIsNot(a, b)
# None and Not None
self.assertIsNone(x)
self.assertIsNotNone(x)
# In and Not In
self.assertIn(a, b)
self.assertNotIn(a, b)
# Instance and Not Instance
self.assertIsInstance(a, b)
self.assertNotIsInstance(a, b)
# Exceptions
with self.assertRaises(SomeException):
function_that_raises()
Using pytest
While unittest
is included in Python's standard library, many developers prefer pytest
for its simplicity and powerful features.
First, install pytest:
pip install pytest
A Simple pytest Example
Let's write the same test using pytest:
# test_math_functions_pytest.py
from math_functions import add
def test_add():
assert add(1, 2) == 3
assert add(-1, 1) == 0
assert add(-1, -1) == -2
Running this test:
pytest test_math_functions_pytest.py -v
Output:
test_math_functions_pytest.py::test_add PASSED
================ 1 passed in 0.01s ================
Advantages of pytest
- Simpler syntax: Use plain
assert
statements instead of various assertion methods - Auto-discovery: Automatically finds test files and functions
- Detailed failure information: Shows comprehensive information when tests fail
- Fixtures: Powerful way to set up and tear down test dependencies
- Parameterization: Run the same test with different inputs
Fixtures in pytest
Fixtures are a way to set up preconditions for your tests:
import pytest
@pytest.fixture
def sample_data():
return [1, 2, 3, 4, 5]
def test_sum(sample_data):
assert sum(sample_data) == 15
def test_average(sample_data):
assert sum(sample_data) / len(sample_data) == 3
Parameterized Tests
Run the same test with different inputs:
import pytest
from math_functions import add
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(-1, 1, 0),
(-1, -1, -2),
(0, 0, 0),
])
def test_add_parameterized(a, b, expected):
assert add(a, b) == expected
A Real-World Example
Let's create a more practical example. We'll build a simple BankAccount
class and test it:
# bank_account.py
class InsufficientFundsError(Exception):
pass
class BankAccount:
def __init__(self, account_number, initial_balance=0):
self.account_number = account_number
self.balance = initial_balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.balance += amount
return self.balance
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > self.balance:
raise InsufficientFundsError("Not enough funds")
self.balance -= amount
return self.balance
def get_balance(self):
return self.balance
Now let's test this class using pytest:
# test_bank_account.py
import pytest
from bank_account import BankAccount, InsufficientFundsError
@pytest.fixture
def account():
return BankAccount("12345", 100)
def test_initial_balance(account):
assert account.get_balance() == 100
def test_deposit(account):
assert account.deposit(50) == 150
assert account.get_balance() == 150
def test_withdraw(account):
assert account.withdraw(50) == 50
assert account.get_balance() == 50
def test_negative_deposit(account):
with pytest.raises(ValueError):
account.deposit(-50)
def test_negative_withdrawal(account):
with pytest.raises(ValueError):
account.withdraw(-50)
def test_insufficient_funds(account):
with pytest.raises(InsufficientFundsError):
account.withdraw(150)
Test-Driven Development (TDD)
Test-Driven Development is a programming methodology where you:
- Write a failing test for a feature
- Implement the simplest code to pass the test
- Refactor the code while keeping the tests passing
The TDD cycle is often called "Red-Green-Refactor":
- Red: Write a failing test
- Green: Make the test pass with the simplest code possible
- Refactor: Improve the code without changing its behavior
TDD Example
Let's implement a simple function to check if a number is prime using TDD:
First, write a failing test:
# test_prime.py
import pytest
from prime import is_prime
def test_is_prime():
assert is_prime(2) is True
assert is_prime(3) is True
assert is_prime(4) is False
assert is_prime(5) is True
assert is_prime(9) is False
assert is_prime(11) is True
This test will fail because we haven't implemented is_prime
yet. Now let's implement the function:
# prime.py
def is_prime(n):
"""Check if a number is prime."""
if n <= 1:
return False
if n <= 3:
return True
if n % 2 == 0 or n % 3 == 0:
return False
i = 5
while i * i <= n:
if n % i == 0 or n % (i + 2) == 0:
return False
i += 6
return True
Now the test should pass. If needed, we could refactor our implementation to make it more efficient while ensuring the tests still pass.
Testing Best Practices
- Test One Thing Per Test: Each test should verify a single aspect of functionality
- Keep Tests Independent: Tests should not depend on each other
- Name Tests Descriptively: Use clear names that describe what's being tested
- Don't Test Standard Library: Focus on testing your own code
- Keep Tests Fast: Slow tests discourage running them frequently
- Use Mocks for External Services: Don't depend on external APIs or services in unit tests
- Test Edge Cases: Test boundary conditions and error cases
- Maintain Test Coverage: Aim for high test coverage but focus on critical paths
Summary
Testing is a critical skill for any Python developer. In this guide, you've learned:
- Why testing is important for code quality and maintainability
- Different types of tests and when to use them
- How to write tests using both
unittest
andpytest
- The basics of Test-Driven Development (TDD)
- Best practices for effective testing
By implementing these testing strategies in your Python projects, you'll write more reliable code and catch bugs earlier in the development process.
Additional Resources
- pytest Documentation
- unittest Documentation
- Python Testing with pytest by Brian Okken
- Test-Driven Development with Python
Exercises
- Write tests for a function that checks if a string is a palindrome
- Create a
Calculator
class with add, subtract, multiply and divide methods, and write comprehensive tests for it - Practice TDD by writing tests first for a function that converts temperatures between Celsius and Fahrenheit
- Extend the
BankAccount
class with atransfer
method and write tests for it
Happy testing!
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)