Unit Testing API Functions — Testing Individual Components in Isolation
In this tutorial, you will learn about Unit Testing API Functions. We cover key concepts, practical examples, and best practices to help you master this topic.
Unit testing API functions validates individual components in isolation — request validation, data transformation, serialization, error formatting, and business rules — without network or database dependencies.
What You'll Learn
Writing unit tests for API validation, serializers, error handlers, middleware helpers, and business logic with mocked dependencies.
Why It Matters
Unit tests are the fastest and most reliable tests. They catch logic errors immediately, document expected behavior, and enable safe Refactoring. A strong unit test foundation prevents bugs before they reach Integration Testing.
Code Example: Testing Request Validation
import pytest
from pydantic import BaseModel, ValidationError, Field
class ThreatCreateRequest(BaseModel):
name: str = Field(..., min_length=3, max_length=200)
severity: str = Field(..., pattern="^(low|medium|high|critical)$")
source_ip: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
description: str = Field("", max_length=5000)
class TestThreatCreateRequest:
def test_valid_request(self):
data = {
"name": "SQL Injection Attempt",
"severity": "high",
"source_ip": "192.168.1.100",
"description": "Detected SQL injection in login form"
}
request = ThreatCreateRequest(**data)
assert request.name == "SQL Injection Attempt"
assert request.severity == "high"
def test_short_name_raises_error(self):
with pytest.raises(ValidationError) as exc:
ThreatCreateRequest(
name="AB",
severity="high",
source_ip="192.168.1.1"
)
assert "name" in str(exc.value)
def test_invalid_severity_raises_error(self):
with pytest.raises(ValidationError):
ThreatCreateRequest(
name="Valid Threat",
severity="ultra-critical",
source_ip="192.168.1.1"
)
def test_invalid_ip_raises_error(self):
with pytest.raises(ValidationError):
ThreatCreateRequest(
name="Valid Threat",
severity="high",
source_ip="not-an-ip"
)
Code Example: Testing Serializer Logic
from datetime import datetime
import json
class ThreatSerializer:
@staticmethod
def serialize(threat_dict):
return {
"id": str(threat_dict.get("_id")),
"name": threat_dict.get("name"),
"severity": threat_dict.get("severity", "low"),
"source_ip": threat_dict.get("source_ip"),
"created_at": threat_dict.get("created_at", datetime.utcnow()).isoformat(),
"status": threat_dict.get("status", "open"),
"tags": threat_dict.get("tags", [])
}
@staticmethod
def serialize_summary(threat_dict):
return {
"id": str(threat_dict.get("_id")),
"name": threat_dict.get("name"),
"severity": threat_dict.get("severity")
}
class TestThreatSerializer:
def test_full_serialization(self):
threat = {
"_id": "507f1f77bcf86cd799439011",
"name": "XSS Attempt",
"severity": "medium",
"source_ip": "10.0.0.5",
"created_at": datetime(2026, 6, 1, 12, 0, 0),
"status": "investigating",
"tags": ["xss", "web"]
}
result = ThreatSerializer.serialize(threat)
assert result["id"] == "507f1f77bcf86cd799439011"
assert result["name"] == "XSS Attempt"
assert result["tags"] == ["xss", "web"]
assert result["created_at"] == "2026-06-01T12:00:00"
def test_summary_serialization(self):
threat = {
"_id": "507f1f77bcf86cd799439012",
"name": "Port Scan",
"severity": "low"
}
result = ThreatSerializer.serialize_summary(threat)
assert "source_ip" not in result
assert "created_at" not in result
Code Example: Testing Error Handler Functions
from api.errors import AppError, NotFoundError, ValidationError, format_error_response
class TestErrorHandler:
def test_not_found_error_format(self):
error = NotFoundError("Threat not found")
response = format_error_response(error)
assert response["status_code"] == 404
assert response["body"]["error"] == "NOT_FOUND"
assert response["body"]["message"] == "Threat not found"
assert "timestamp" in response["body"]
def test_validation_error_with_details(self):
error = ValidationError(
message="Invalid input",
details={"field": "severity", "reason": "Must be one of: low, medium, high, critical"}
)
response = format_error_response(error)
assert response["status_code"] == 400
assert response["body"]["details"]["field"] == "severity"
def test_generic_error_hides_internal_details(self):
error = AppError("Internal server error")
response = format_error_response(error, debug=False)
assert "traceback" not in response["body"]
def test_debug_mode_includes_traceback(self):
try:
raise ValueError("test error")
except ValueError as e:
error = AppError(str(e))
response = format_error_response(error, debug=True)
assert "traceback" in response["body"]
Common Mistakes
1. Testing the Framework
Do not test that Pydantic validates fields or that SQLAlchemy queries work. Test your business logic, validation rules, and transformations.
2. Too Many Mocks
Mocking everything creates brittle tests. Mock external boundaries (database, network) but use real objects for internal logic.
3. Testing Implementation, Not Behavior
A test like "assert function_called_with(x)" breaks on refactoring. Test the output or side effect, not internal calls.
4. Missing Edge Cases
Test empty strings, None values, boundary conditions (max length, zero), and unexpected types. These are where real bugs live.
5. Slow Unit Tests
Unit tests should complete in milliseconds. If a unit test takes more than 100ms, it is probably not a unit test.
Practice Questions
- What makes a good unit test target?
- When should you use mocks in unit tests?
- Why should you avoid testing framework behavior?
- What edge cases should every validation unit test cover?
- How fast should unit tests be?
Answers:
- Pure functions with clear inputs and outputs: validation, transformation, serialization, calculation, and format conversion functions.
- Mock at the system boundaries: network calls, database access, file system, and external APIs. Do not mock internal functions.
- Framework behavior is tested by the framework authors. Your test should verify your usage produces the expected result, not that the framework works correctly.
- Empty input, null/None, minimum values, maximum values, invalid types, special characters, and values just above/below boundaries.
- Under 10ms each. A suite of 1000 unit tests should complete in under 10 seconds. If slower, they are likely integration tests.
Challenge: Write a complete unit test suite for an API request validation module covering valid data, all error conditions, boundary values, and edge cases.
FAQ
Mini Project
Write a unit test suite for an API threat detection module with validation (input format, severity levels), serialization (response formatting, field mapping), error handling (error types, formatting), and scoring logic (threat score calculation).
What's Next
Now learn about Integration Testing API Endpoints for testing how your components work together with databases and middleware.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro