Unit Testing: Complete Guide with Examples and Best Practices
Unit testing is the practice of verifying individual units of source code — functions, methods, or classes — in isolation from the rest of the system to ensure each unit behaves correctly under all expected conditions.
What You’ll Learn
- What defines a unit test and how it differs from other test types
- How to write effective unit tests using Jest, pytest, and JUnit
- Mocking strategies for external dependencies
- Code coverage metrics and how to use them wisely
- Common unit testing anti-patterns to avoid
Why Unit Testing Matters
Unit tests catch 60-70% of defects at the earliest possible stage — during development, before code reaches code review, integration testing, or production. They also serve as executable documentation, enable safe refactoring, and provide rapid feedback. A well-maintained unit test suite pays for itself many times over by reducing debugging time and preventing regressions.
DodaZIP relies on unit tests for every compression and decompression algorithm — a single off-by-one error in a buffer calculation could corrupt archives.
Learning Path
flowchart LR
A[TDD] --> B[Unit Testing<br/>You are here]
B --> C[Integration Testing]
C --> D[Mocking & Stubs]
D --> E[CI/CD Pipeline]
style B fill:#f90,color:#fff
What Makes a Good Unit Test
A good unit test follows the FIRST principles:
| Principle | Meaning |
|---|---|
| Fast | Runs in milliseconds |
| Isolated | No network, database, or filesystem |
| Repeatable | Same result every run |
| Self-validating | Pass/fail, no manual inspection |
| Timely | Written before or alongside the code |
Each test should verify one specific behavior and have a descriptive name that explains what it tests and what the expected outcome is.
Unit Test Structure: AAA Pattern
Every unit test follows three phases:
# Arrange-Act-Assert pattern
def test_withdraw_reduces_balance():
# Arrange
account = BankAccount(balance=100)
# Act
account.withdraw(30)
# Assert
assert account.balance == 70Arrange sets up the test data and objects. Act performs the operation being tested. Assert verifies the result matches expectations.
Example: Testing a Password Validator
# password_validator.py
import re
class PasswordValidator:
def validate(self, password: str) -> dict:
errors = []
if len(password) < 8:
errors.append("Must be at least 8 characters")
if not re.search(r"[A-Z]", password):
errors.append("Must contain an uppercase letter")
if not re.search(r"[0-9]", password):
errors.append("Must contain a number")
if not re.search(r"[!@#$%^&*]", password):
errors.append("Must contain a special character")
return {"valid": len(errors) == 0, "errors": errors}
# test_password_validator.py
from password_validator import PasswordValidator
def test_valid_password_passes():
validator = PasswordValidator()
result = validator.validate("Secure1@pass")
assert result["valid"] is True
assert result["errors"] == []
def test_short_password_fails():
validator = PasswordValidator()
result = validator.validate("Ab1@")
assert result["valid"] is False
assert "8 characters" in result["errors"][0]
def test_missing_uppercase_fails():
validator = PasswordValidator()
result = validator.validate("secure1@pass")
assert result["valid"] is False
assert "uppercase" in result["errors"][0]
def test_missing_number_fails():
validator = PasswordValidator()
result = validator.validate("Secure@pass")
assert "number" in result["errors"][0]Expected output:
4 passed in 0.03sMocking External Dependencies
Mocking replaces real dependencies with controlled substitutes. Use mocks for code that touches the network, filesystem, database, or other slow or non-deterministic resources.
# user_service.py
class UserService:
def __init__(self, db, email_service):
self.db = db
self.email_service = email_service
def register(self, email, password):
existing = self.db.find_user(email)
if existing:
raise ValueError("Email already registered")
user = self.db.create_user(email, password)
self.email_service.send_welcome(user)
return user
# test_user_service.py
from unittest.mock import Mock
from user_service import UserService
def test_register_creates_user_and_sends_email():
db = Mock()
db.find_user.return_value = None
db.create_user.return_value = Mock(id=1, email="test@example.com")
email_service = Mock()
service = UserService(db, email_service)
user = service.register("test@example.com", "Secure1@pass")
assert user.id == 1
db.create_user.assert_called_once()
email_service.send_welcome.assert_called_once_with(user)
def test_register_rejects_duplicate_email():
db = Mock()
db.find_user.return_value = Mock(id=1)
email_service = Mock()
service = UserService(db, email_service)
try:
service.register("test@example.com", "Secure1@pass")
assert False, "Should have raised"
except ValueError as e:
assert "already registered" in str(e)What Not to Mock
Avoid mocking value objects, data classes, or simple utilities. Only mock dependencies that cross a boundary (network, I/O, third-party services).
Code Coverage
Code coverage measures which lines of code are executed during tests. Common metrics:
| Metric | What It Measures |
|---|---|
| Line coverage | Percentage of lines executed |
| Branch coverage | Percentage of decision points (if/else) tested |
| Function coverage | Percentage of functions called |
| Statement coverage | Percentage of statements executed |
# pytest with coverage
pytest --cov=myapp --cov-report=term-missing
# Output
Name Stmts Miss Cover Missing
--------------------------------------------------
myapp/validator.py 25 2 92% 34-35
myapp/service.py 48 5 90% 67-70, 82
--------------------------------------------------
TOTAL 73 7 90%Coverage Traps
- 100% coverage doesn’t mean bug-free — it means every line runs, not that every scenario is tested
- Chasing coverage targets leads to tests that assert trivial things just to hit the number
- Untested error paths — coverage tools show lines executed, not edge cases covered
Focus coverage efforts on critical business logic. For most projects, 80-90% line coverage on core modules is a healthy target.
Unit Testing in JavaScript with Jest
// utils.js
function calculateDiscount(price, isMember) {
if (price <= 0) throw new Error('Invalid price');
if (isMember) return price * 0.9;
return price;
}
module.exports = { calculateDiscount };
// utils.test.js
const { calculateDiscount } = require('./utils');
describe('calculateDiscount', () => {
test('applies 10% discount for members', () => {
expect(calculateDiscount(100, true)).toBe(90);
});
test('no discount for non-members', () => {
expect(calculateDiscount(100, false)).toBe(100);
});
test('throws for invalid price', () => {
expect(() => calculateDiscount(0, true)).toThrow('Invalid price');
expect(() => calculateDiscount(-5, false)).toThrow('Invalid price');
});
test('handles zero after discount', () => {
expect(calculateDiscount(0.01, true)).toBeCloseTo(0.009);
});
});Common Unit Testing Mistakes
1. Testing Implementation Details
Testing private methods or internal state makes tests brittle — they break when behavior hasn’t changed.
Fix: Test public APIs and observable behavior only.
2. Sharing Mutable State Between Tests
Tests that modify shared state (class variables, global config) interfere with each other.
Fix: Create fresh instances in each test. Use setup methods to reduce duplication.
3. Over-Mocking
Mocks produce false confidence — they verify the mock behaves as expected, not the real system.
Fix: Mock only at system boundaries. Prefer real objects for in-process dependencies.
4. Flaky Tests
Tests that pass sometimes and fail sometimes erode trust in the suite.
Fix: Eliminate randomness, time dependencies, and test ordering dependencies.
5. Testing Through the UI
Unit tests should not require a browser or render components.
Fix: Test business logic separately from UI rendering.
6. Giant Test Classes
Single test files with thousands of lines are hard to navigate and maintain.
Fix: One test file per module or class. Keep tests focused and organized.
7. Low Signal-to-Noise Ratio
Tests that repeat the same setup code make it hard to see what each test actually verifies.
Fix: Use descriptive test names and helper functions for common setup.
Practice Questions
1. What does FIRST stand for in unit testing?
Fast, Isolated, Repeatable, Self-validating, Timely.
2. What are the three phases of the AAA pattern?
Arrange (set up), Act (perform operation), Assert (verify result).
3. When should you use mocking?
For dependencies that cross system boundaries — network calls, database queries, filesystem access, third-party APIs.
4. Why is 100% code coverage not a guarantee of quality?
Coverage measures which lines execute, not which behaviors are verified. You can have 100% coverage with zero meaningful assertions.
5. How do you handle tests that share common setup?
Use setup fixtures (pytest fixtures, Jest beforeEach/describe blocks) to reduce duplication while keeping each test isolated.
Challenge: Take a legacy module with no tests. Write unit tests that cover its core behavior without refactoring the module first. Document what makes the module hard to test.
FAQ
What’s Next
| Tutorial | What You’ll Learn |
|---|---|
| Integration Testing Strategies | Testing components that work together |
| Mocking and Stubbing Guide | Advanced mocking techniques |
| Test Automation Frameworks | Comparing test frameworks |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. Updated 2026-06-20.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro