Unit Testing – Building Better Code as a Software Engineer Part 3
Unit testing is a critical practice for any codebase, especially in teams where reliability and robustness are essential. This part of Good Code, Bad Code dives into unit testing principles and practical approaches that help software engineers catch bugs early, maintain code quality, and ensure that individual code units perform as expected.
1. Unit Testing Principles: Building Blocks of Reliable Tests
Effective unit tests adhere to principles that make them reliable, clear, and valuable. Key principles include isolating units under test, writing tests that are deterministic, and ensuring high readability.
Key Principles of Unit Testing:
1. Isolation Unit tests should focus on isolated “units” of code (e.g., a single function or method) to avoid dependencies and ensure they test only what they’re intended to. Isolation minimizes external influences, like database connections, that could affect test outcomes.
Example of Isolated Testing: Imagine testing a calculate_discount
function that applies a percentage discount to a product’s price. Here, isolating the function from unrelated components (like a database of product details) keeps the test focused on its primary role: calculating discounts accurately.
Code Example:
def calculate_discount(price, discount_rate):
return price * (1 - discount_rate)
# Unit test for calculate_discount function
def test_calculate_discount():
assert calculate_discount(100, 0.2) == 80
In this test, we use hardcoded values and avoid dependencies to focus solely on the function's behavior.
2. Determinism Unit tests should be deterministic, producing the same result every time they’re run under the same conditions. Non-deterministic tests can lead to inconsistent test results and complicate debugging.
Example of a Deterministic Test: Tests that rely on system time can introduce non-determinism. To avoid this, we can use mocks to simulate consistent values for tests involving time-based logic.
Code Example:
from unittest.mock import patch
from datetime import datetime
def get_greeting():
hour = datetime.now().hour
return "Good morning" if hour < 12 else "Good evening"
# Deterministic test for get_greeting using mock
@patch('datetime.datetime')
def test_get_greeting(mock_datetime):
mock_datetime.now.return_value = datetime(2023, 10, 25, 8, 0, 0)
assert get_greeting() == "Good morning"
In this test, we mock datetime.now
()
to ensure the same time is always returned, producing a consistent result.
3. Readability A clear and readable test improves maintainability and helps developers understand what’s being tested. Use descriptive function names, relevant comments, and structure test cases logically to improve readability.
Best Practices for Test Principles:
Write one assert per test to focus on a single outcome.
Use descriptive names that convey the test’s purpose.
Organize tests logically (e.g., arrange tests by module or function).
2. Unit Testing Practices: Writing Effective and Maintainable Tests
In addition to principles, best practices for writing unit tests ensure they remain effective and maintainable over time. Effective unit tests offer high coverage without becoming fragile or excessively specific.
Essential Practices for Unit Testing:
1. Avoid Over-Testing Over-testing occurs when you test private methods or implementation details that may change without affecting the code’s functionality. Tests should focus on public interfaces and intended behavior, not on internal details.
Example of Over-Testing vs. Effective Testing: Consider a class OrderProcessor
that contains private methods to manage discount calculations.
Avoid Testing Private Methods Directly: Instead of testing the private discount method, focus on the public process_order
function that internally uses it.
Effective Test Example:
class OrderProcessor:
def process_order(self, order):
return self._apply_discount(order)
def test_process_order():
order = {'total': 100, 'discount': 0.2}
processor = OrderProcessor()
assert processor.process_order(order) == 80
This way, you test the class functionality rather than its internals, keeping the test resilient to internal changes.
2. Test-Driven Development (TDD) In TDD, tests are written before the code. This practice encourages minimal code needed to pass the tests and focuses on function rather than implementation. TDD also reduces the likelihood of missing edge cases and ensures that testing aligns with requirements from the start.
TDD Workflow Example:
Write a test case for a new feature (failing initially).
Implement just enough code to make the test pass.
Refactor the code to improve design while keeping tests green.
Example Code for TDD Workflow: Suppose we’re implementing a function is_even(number)
.
Step 1: Write the Test (Failing Initially):
def test_is_even():
assert is_even(2) == True
assert is_even(3) == False
Step 2: Implement Minimal Code:
def is_even(number):
return number % 2 == 0
Step 3: Refactor if Necessary: If further cases arise, refactor as needed.
3. Use Mocking for Dependencies Mocks replace dependencies like databases, APIs, or file systems during testing, keeping tests focused on the function’s logic rather than external interactions.
Mocking Example for Database Dependency: If testing a function that retrieves user data from a database, we can mock the database query.
from unittest.mock import Mock
# Function to test
def get_user_age(user_id, database):
return database.fetch_user_age(user_id)
# Mocking the database dependency
def test_get_user_age():
mock_database = Mock()
mock_database.fetch_user_age.return_value = 30
assert get_user_age(123, mock_database) == 30
Mocking ensures that the test doesn’t rely on an actual database, making it faster and more reliable.
Practical Testing Practices:
Use fixtures and setup functions to reduce repetitive code.
Keep tests fast and isolated to improve test performance.
Regularly update tests to match refactored or altered code.
Final Thoughts: Building a Strong Testing Foundation
Unit testing isn’t just about adding tests; it’s about integrating thoughtful, effective tests that enhance code quality, reliability, and maintainability. By adhering to principles of isolation, determinism, and readability, and practicing over-testing avoidance, TDD, and mocking, your codebase becomes resilient to changes and robust in functionality. Implement these unit testing principles and practices, and you’ll create code that is more reliable, adaptable, and easier to maintain.
Subscribe to my newsletter
Read articles from Alyaa Talaat directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Alyaa Talaat
Alyaa Talaat
As a continuous learner, I’m always exploring new technologies and best practices to enhance my skills in software development. I enjoy tackling complex coding challenges, whether it's optimizing performance, implementing new features, or debugging intricate issues.