0%

46- Unit Testing Basics

Test your code automatically. Catch bugs before they reach production. Write tests that verify your functions work correctly. The foundation of reliable software.

You write a function. It seems to work. You test it manually a few times. Then you change something. Does it still work? You are not sure. You test again. Then you add another function. Then another. Soon, manual testing becomes impossible. Unit testing is the solution. A unit test is a small piece of code that tests one specific behavior of your code. You write tests once. You run them often. They tell you immediately when something breaks. Unit tests give you confidence. They let you change code without fear. They document how your code should behave. They are the safety net that catches bugs before they reach users. Python has a built-in module for unit testing called unittest. This lesson will teach you how to write and run unit tests, organize test suites, and use mocking to isolate code.

🕯️ Magic Note

The practice of writing tests before code is called Test-Driven Development (TDD). Write a failing test first. Then write just enough code to make it pass. Then refactor. This cycle (Red-Green-Refactor) ensures every line of code is covered by a test.

Why Unit Test?
Unit testing provides numerous benefits for any project.
  • Catches bugs early, before they reach production
  • Gives confidence to refactor and change code
  • Serves as living documentation
  • Forces better code design (testable code tends to be better organized)
  • Saves time in the long run (debugging takes much longer than testing)
  • Enables continuous integration and deployment
The unittest Module
Python’s built-in unittest module is inspired by Java’s JUnit. It provides a framework for writing and running tests.

Python

# File: calculator.py

def add(a, b):

return a + b

def subtract(a, b):

return a – b

def multiply(a, b):

return a * b

def divide(a, b):

if b == 0:

raise ValueError(“Cannot divide by zero”)

return a / b

# File: test_calculator.py

import unittest

from calculator import add, subtract, multiply, divide

class TestCalculator(unittest.TestCase):

def test_add(self):

self.assertEqual(add(2, 3), 5)

self.assertEqual(add(-1, 1), 0)

self.assertEqual(add(0, 0), 0)

def test_subtract(self):

self.assertEqual(subtract(5, 3), 2)

self.assertEqual(subtract(0, 5), -5)

self.assertEqual(subtract(-3, -2), -1)

def test_multiply(self):

self.assertEqual(multiply(2, 3), 6)

self.assertEqual(multiply(-2, 3), -6)

self.assertEqual(multiply(0, 100), 0)

def test_divide(self):

self.assertEqual(divide(10, 2), 5)

self.assertEqual(divide(7, 2), 3.5)

self.assertEqual(divide(-10, 2), -5)

def test_divide_by_zero(self):

with self.assertRaises(ValueError):

divide(10, 0)

if __name__ == “__main__”:

unittest.main()

💡 By convention, test files are named test_*.py or *_test.py. Test methods must start with test_ to be discovered automatically.
Running Tests
There are several ways to run unittest tests.

Bash

# Run a single test file

python -m unittest test_calculator.py

# Run with verbosity (more details)

python -m unittest -v test_calculator.py

# Discover and run all tests in a directory

python -m unittest discover

# Discover tests in a specific directory

python -m unittest discover -s tests

# Run a specific test method

python -m unittest test_calculator.TestCalculator.test_add

# Run using pytest (third-party, often nicer)

pytest test_calculator.py

🕯️ Magic Note

The if __name__ == “__main__” guard allows you to run the test file directly with python test_calculator.py. This is convenient during development.

Common Assertion Methods
unittest provides many assertion methods to verify different conditions.
MethodChecks That
assertEqual(a, b)a == b
assertNotEqual(a, b)a != b
assertTrue(x)x is True
assertFalse(x)x is False
assertIs(a, b)a is b (same object)
assertIsNot(a, b)a is not b
assertIsNone(x)x is None
assertIsNotNone(x)x is not None
assertIn(a, b)a in b
assertNotIn(a, b)a not in b
assertIsInstance(a, b)isinstance(a, b)
assertRaises(Error, func)func raises Error

Python

class TestAssertions(unittest.TestCase):

def test_assertions(self):

# Equality

self.assertEqual(5, 5)

self.assertNotEqual(5, 3)

# Truthiness

self.assertTrue(10 > 5)

self.assertFalse(10 < 5)

# Identity

a = [1, 2, 3]

b = a

self.assertIs(a, b)

self.assertIsNot(a, [1, 2, 3])

# None

value = None

self.assertIsNone(value)

# Membership

self.assertIn(2, [1, 2, 3])

self.assertNotIn(5, [1, 2, 3])

# Type checking

self.assertIsInstance(“hello”, str)

# Exception checking

with self.assertRaises(ValueError):

int(“not a number”)

Setup and Teardown Methods
Use setUp and tearDown to run code before and after each test method. This avoids duplication.

Python

class TestDatabase(unittest.TestCase):

def setUp(self):

“””Run before EACH test method.”””

self.db = Database()

self.db.connect()

self.test_data = {“name”: “Ali”, “age”: 25}

def tearDown(self):

“””Run after EACH test method.”””

self.db.clear()

self.db.disconnect()

def test_insert(self):

self.db.insert(self.test_data)

self.assertEqual(self.db.count(), 1)

def test_find(self):

self.db.insert(self.test_data)

result = self.db.find(name=”Ali”)

self.assertEqual(result[“age”], 25)

🕯️ Magic Note

There are also class-level setup methods: setUpClass and tearDownClass run once per test class, not per test method. Use them for expensive operations like creating a database connection pool.

Python

class TestExpensiveOperations(unittest.TestCase):

@classmethod

def setUpClass(cls):

“””Run once before all tests in this class.”””

cls.connection = create_database_connection()

@classmethod

def tearDownClass(cls):

“””Run once after all tests in this class.”””

cls.connection.close()

Skipping Tests
You can skip tests conditionally or unconditionally using decorators.

Python

class TestSkipping(unittest.TestCase):

@unittest.skip(“Not implemented yet”)

def test_feature_not_ready(self):

pass

@unittest.skipIf(not hasattr(sys, “getwindowsversion”), “Windows only”)

def test_windows_only(self):

pass

@unittest.skipUnless(sys.platform.startswith(“linux”), “Linux only”)

def test_linux_only(self):

pass

def test_expected_failure(self):

@unittest.expectedFailure

def test_broken_feature(self):

self.assertEqual(1, 2) # This failure is expected

Organizing Test Suites
For larger projects, organize tests into suites and test loaders.

Python

def suite():

“””Create a test suite with specific tests.”””

test_suite = unittest.TestSuite()

test_suite.addTest(TestCalculator(“test_add”))

test_suite.addTest(TestCalculator(“test_divide_by_zero”))

test_suite.addTest(TestStringMethods(“test_upper”))

return test_suite

if __name__ == “__main__”:

runner = unittest.TextTestRunner()

runner.run(suite())

# Or use test loader

loader = unittest.TestLoader()

suite = loader.discover(“tests”)

unittest.TextTestRunner().run(suite)

Practical Example: Testing a User Class
A complete example testing a User class with multiple methods.

Python

# File: user.py

class User:

def __init__(self, username, email):

self.username = username

self.email = email

self.is_active = True

def activate(self):

self.is_active = True

def deactivate(self):

self.is_active = False

def change_email(self, new_email):

if “@” not in new_email:

raise ValueError(“Invalid email address”)

self.email = new_email

# File: test_user.py

import unittest

from user import User

class TestUser(unittest.TestCase):

def setUp(self):

self.user = User(“alirezai”, “ali@example.com”)

def test_initialization(self):

self.assertEqual(self.user.username, “alirezai”)

self.assertEqual(self.user.email, “ali@example.com”)

self.assertTrue(self.user.is_active)

def test_activate(self):

self.user.deactivate()

self.user.activate()

self.assertTrue(self.user.is_active)

def test_deactivate(self):

self.user.deactivate()

self.assertFalse(self.user.is_active)

def test_change_email_valid(self):

self.user.change_email(“new@example.com”)

self.assertEqual(self.user.email, “new@example.com”)

def test_change_email_invalid(self):

with self.assertRaises(ValueError):

self.user.change_email(“invalid-email”)

Mocking with unittest.mock
Mocking replaces real objects with fake ones during testing. This is useful for isolating code from external dependencies (databases, APIs, files).

Python

from unittest.mock import Mock, patch

class TestExternalAPI(unittest.TestCase):

def test_api_call_with_mock(self):

# Create a mock response object

mock_response = Mock()

mock_response.status_code = 200

mock_response.json.return_value = {“result”: “success”}

# Replace requests.get with our mock

with patch(“requests.get”, return_value=mock_response):

result = call_external_api()

self.assertEqual(result, {“result”: “success”})

def test_mock_called_with_correct_arguments(self):

mock_function = Mock()

# Call the mock

mock_function(1, 2, key=”value”)

# Verify it was called correctly

mock_function.assert_called_once_with(1, 2, key=”value”)

🕯️ Magic Note

The unittest.mock module is powerful. You can mock objects, functions, even entire modules. It allows you to test code that depends on databases, networks, or other external systems without actually connecting to them.

Test Coverage
Test coverage measures which lines of code are executed by your tests. Use the coverage.py tool.

Bash

# Install coverage

pip install coverage

# Run tests with coverage

coverage run -m unittest discover

# Report coverage

coverage report

# Generate HTML report

coverage html

# Then open htmlcov/index.html

💡 Aim for 80-90% test coverage. 100% coverage does not mean bug-free code, but low coverage definitely means untested code. Focus coverage on critical business logic.
pytest: A Modern Alternative
pytest is a third-party testing framework that is simpler and more powerful than unittest. It is widely used in the Python community.

Bash

# Install pytest

pip install pytest

# Run tests (auto-discovers test_*.py)

pytest

Python

# test_calculator.py with pytest (no class needed!)

from calculator import add, subtract, multiply, divide

import pytest

def test_add():

assert add(2, 3) == 5

assert add(-1, 1) == 0

def test_divide_by_zero():

with pytest.raises(ValueError):

divide(10, 0)

@pytest.mark.parametrize(“a,b,expected”, [

(2, 3, 6),

(-2, 3, -6),

(0, 100, 0),

])

def test_multiply(a, b, expected):

assert multiply(a, b) == expected

🕯️ Magic Note

pytest is often preferred because it requires less boilerplate. No classes needed. Simple assert statements work. The parameterized testing feature is excellent. Many Python projects now use pytest over unittest.

Common Mistakes in Unit Testing
  • Testing multiple things in one test (each test should test one behavior)
  • Tests that depend on order (tests should be independent)
  • Testing implementation details instead of public interface
  • Not testing edge cases (empty lists, zero, negative numbers, None)
  • Tests that are too slow (they should be fast to encourage frequent running)
  • Forgetting to test error conditions and exceptions
Check Your Understanding
  • Write a test for a function is_even(n) that returns True for even numbers.
  • What is the purpose of setUp and tearDown methods?
  • How do you test that a function raises a specific exception?
  • What is mocking and when would you use it?
  • Name three unittest assertion methods.
  • What is the difference between unittest and pytest?

⚡ Whisper

A test is a promise. It says: “This code will behave this way.” When you change the code, the test reminds you of the promise. If the promise is broken, the test fails. This is not a punishment. It is a conversation. The test tells you: “You changed something. Was that intentional?” With tests, you change code without fear. Without tests, every change is a gamble. Write tests before you write code. Or write them after. But write them. They are not extra work. They are the foundation. A codebase without tests is a house without inspection. It might stand. It might collapse. You cannot know. Tests give you confidence. Tests give you freedom. Tests give you the courage to refactor, to improve, to make bold changes. This is not a luxury. This is professionalism. Start small. One test for one function. Then another. Soon, you will not imagine coding without tests. And your code will be stronger for it.

Related posts