A Practical Introduction to Unit Testing for Busy Developers
I fixed a bug last Tuesday. Deployed it. Broke three other things. Spent the rest of the day fixing those. Then broke something else.
The codebase had no tests. Every change was a gamble. I couldn't verify that my fix didn't break existing behavior because there was no definition of what "correct behavior" even meant.
"I don't have time to write tests" is backwards. You don't have time NOT to write tests. The hours you "save" by skipping them get paid back with interest during debugging.
What's a Unit Test?
A unit test verifies one small piece of your code—usually a function. It follows three steps:
- Arrange: Set up your data
- Act: Call the function
- Assert: Check if the result is what you expected
Your First Test
Say you have a function that formats names:
# utils.py
def format_name(first, last):
if not first or not last:
raise ValueError("Name parts cannot be empty")
return f"{last.upper()}, {first.capitalize()}"
Here's how you test it:
# test_utils.py
import unittest
from utils import format_name
class TestNameFormatting(unittest.TestCase):
def test_standard_name(self):
result = format_name("john", "doe")
self.assertEqual(result, "DOE, John")
def test_empty_input_raises_error(self):
with self.assertRaises(ValueError):
format_name("", "doe")
if __name__ == '__main__':
unittest.main()
Run it: python test_utils.py. If it prints "OK", your code works. If you later change format_name and break it, this script tells you immediately.
Why This Saves Time
Without tests: You change the code. Run the app. Manually create a user "John Doe". Check output. Try "John" with empty last name. Check error. Takes 5 minutes each time.
With tests: You change the code. Run python test_utils.py. Takes 0.1 seconds. Instant feedback.
Tests are a safety net. They let you refactor without fear.
Test Behavior, Not Implementation
A common mistake is testing HOW code works rather than WHAT it does.
# ❌ Testing internal details
def test_user_internal(self):
user = User("Alice")
self.assertEqual(user._name, "Alice") # Private variable!
# ✅ Testing public behavior
def test_user_public(self):
user = User("Alice")
self.assertEqual(user.get_display_name(), "Alice")
If you test internals, your tests break every time you refactor—even when the output is still correct. Test the public interface, not the private implementation.
Mocking External Dependencies
Real apps call databases and APIs. You don't want tests actually hitting Stripe every time they run. Use mocks:
from unittest.mock import MagicMock
def process_payment(gateway, amount):
if amount <= 0:
return False
return gateway.charge(amount)
class TestPayment(unittest.TestCase):
def test_payment_success(self):
fake_gateway = MagicMock()
fake_gateway.charge.return_value = True
result = process_payment(fake_gateway, 100)
self.assertTrue(result)
fake_gateway.charge.assert_called_with(100)
The mock pretends to be the real gateway. You're testing your logic in isolation, not whether Stripe's servers are up.
Where to Start
Don't try to test everything at once. Start with:
- Utility functions (easiest to test, no dependencies)
- The happy path (valid input, expected output)
- Edge cases (empty input, nulls, negative numbers)
One test is infinitely better than zero tests. Start small. Add more as you go.