Skip to content

Unit Testing API Functions — Testing Individual Components in Isolation

DodaTech Updated 2026-06-28 4 min read

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

  1. What makes a good unit test target?
  2. When should you use mocks in unit tests?
  3. Why should you avoid testing framework behavior?
  4. What edge cases should every validation unit test cover?
  5. How fast should unit tests be?

Answers:

  1. Pure functions with clear inputs and outputs: validation, transformation, serialization, calculation, and format conversion functions.
  2. Mock at the system boundaries: network calls, database access, file system, and external APIs. Do not mock internal functions.
  3. Framework behavior is tested by the framework authors. Your test should verify your usage produces the expected result, not that the framework works correctly.
  4. Empty input, null/None, minimum values, maximum values, invalid types, special characters, and values just above/below boundaries.
  5. 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

Should I write unit tests before or after code?

Test-Driven Development (TDD) writes tests first. For existing code, write tests when refactoring or fixing bugs. Both approaches are valid.

How many assertions per unit test?

One logical assertion per test, but multiple assertions that verify different aspects of the same result are acceptable. Avoid testing multiple behaviors in one test.

Should I test private functions?

Test through the public interface. If a private function has complex logic worth testing, consider extracting it to a separate module with a public API.

What is the difference between a stub and a mock?

A stub returns predefined data. A mock records calls and allows assertions on how it was called. Use stubs for setup, mocks for verification.

Can unit tests replace integration tests?

No. Unit tests verify individual components. Integration tests verify that components work together correctly. Both are necessary.

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