Skip to main content

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:

  1. Bug Detection: Tests help you catch bugs before your users do
  2. Code Confidence: You can refactor or add features with confidence that you haven't broken existing functionality
  3. Documentation: Tests serve as executable documentation showing how your code should work
  4. Design Improvement: Writing tests often leads to better code design
  5. 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:

python
# math_functions.py
def add(a, b):
return a + b

Now, let's write a test for this function:

python
# 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:

bash
python test_math_functions.py

Output:

.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

Anatomy of a unittest Test

  1. Import the unittest module and the code you want to test
  2. Create a class that inherits from unittest.TestCase
  3. Write methods that start with test_
  4. Use assertion methods to verify expected outcomes
  5. Run the tests using unittest.main()

Common Assertion Methods

unittest provides many assertion methods:

python
# 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:

bash
pip install pytest

A Simple pytest Example

Let's write the same test using pytest:

python
# 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:

bash
pytest test_math_functions_pytest.py -v

Output:

test_math_functions_pytest.py::test_add PASSED

================ 1 passed in 0.01s ================

Advantages of pytest

  1. Simpler syntax: Use plain assert statements instead of various assertion methods
  2. Auto-discovery: Automatically finds test files and functions
  3. Detailed failure information: Shows comprehensive information when tests fail
  4. Fixtures: Powerful way to set up and tear down test dependencies
  5. Parameterization: Run the same test with different inputs

Fixtures in pytest

Fixtures are a way to set up preconditions for your tests:

python
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:

python
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:

python
# 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:

python
# 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:

  1. Write a failing test for a feature
  2. Implement the simplest code to pass the test
  3. Refactor the code while keeping the tests passing

The TDD cycle is often called "Red-Green-Refactor":

  1. Red: Write a failing test
  2. Green: Make the test pass with the simplest code possible
  3. 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:

python
# 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:

python
# 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

  1. Test One Thing Per Test: Each test should verify a single aspect of functionality
  2. Keep Tests Independent: Tests should not depend on each other
  3. Name Tests Descriptively: Use clear names that describe what's being tested
  4. Don't Test Standard Library: Focus on testing your own code
  5. Keep Tests Fast: Slow tests discourage running them frequently
  6. Use Mocks for External Services: Don't depend on external APIs or services in unit tests
  7. Test Edge Cases: Test boundary conditions and error cases
  8. 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 and pytest
  • 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

Exercises

  1. Write tests for a function that checks if a string is a palindrome
  2. Create a Calculator class with add, subtract, multiply and divide methods, and write comprehensive tests for it
  3. Practice TDD by writing tests first for a function that converts temperatures between Celsius and Fahrenheit
  4. Extend the BankAccount class with a transfer 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! :)