doctest

unit testingPythondocumentation testingdocstringstandard libraryinteractive

doctest

Overview

doctest is a documentation testing module included in Python's standard library. It searches for pieces of text that look like interactive Python sessions, and then executes those sessions to verify that they work exactly as shown. As a lightweight testing framework, it provides quick and straightforward test automation from your project's documentation.

Details

Key Features

Documentation-Integrated Testing

  • Reads and executes test cases from docstrings and documentation
  • Automatically ensures synchronization between documentation and tests
  • Test cases written in interactive Python session format

Standard Library Built-in

  • Included with Python by default, no additional installation required
  • Uses >>> prompt (Primary) and ... prompt (Secondary)
  • Expected output results written immediately below

Automatic Test Discovery

  • Searches module docstrings, functions, classes, and method docstrings
  • Objects imported into the module are not searched
  • Automatic test execution and result verification

CI/CD Pipeline Integration

  • Can be added to modern development workflows as of 2024
  • Enables continuous maintenance of documentation quality

Pros and Cons

Pros

  1. Documentation Synchronization: Automatically ensures consistency between documentation and code
  2. Low Learning Curve: Easy to use as a Python standard feature
  3. Lightweight: No additional dependencies or setup required
  4. Practical Examples: Can include actually working code examples in documentation
  5. Immediate Feedback: Verify accuracy of documentation examples with a single command

Cons

  1. Limited Test Coverage: Mainly applicable to small code snippets or simple functions
  2. Output Comparison Limitations: Only compares printed output; variable output causes test failures
  3. Unsuitable for Complex Tests: Cannot cover all aspects of complex functions or classes
  4. Limited Error Messages: Standard error messages can sometimes be insufficient

Reference Links

Code Examples

Basic doctest Examples

def add(a, b):
    """
    Add two numbers.
    
    >>> add(2, 3)
    5
    >>> add(-1, 1)
    0
    >>> add(0, 0)
    0
    """
    return a + b

def multiply(a, b):
    """
    Multiply two numbers.
    
    >>> multiply(3, 4)
    12
    >>> multiply(-2, 5)
    -10
    >>> multiply(0, 100)
    0
    """
    return a * b

if __name__ == "__main__":
    import doctest
    doctest.testmod()

String Operations doctest

def reverse_string(text):
    """
    Reverse a string.
    
    >>> reverse_string("hello")
    'olleh'
    >>> reverse_string("Python")
    'nohtyP'
    >>> reverse_string("")
    ''
    >>> reverse_string("a")
    'a'
    """
    return text[::-1]

def count_vowels(text):
    """
    Count vowels in a string.
    
    >>> count_vowels("hello")
    2
    >>> count_vowels("Python")
    1
    >>> count_vowels("aeiou")
    5
    >>> count_vowels("xyz")
    0
    >>> count_vowels("")
    0
    """
    vowels = "aeiouAEIOU"
    return sum(1 for char in text if char in vowels)

List and Collection Operations

def find_max(numbers):
    """
    Find the maximum value in a list.
    
    >>> find_max([1, 2, 3, 4, 5])
    5
    >>> find_max([-1, -2, -3])
    -1
    >>> find_max([42])
    42
    >>> find_max([])
    Traceback (most recent call last):
        ...
    ValueError: List is empty
    """
    if not numbers:
        raise ValueError("List is empty")
    return max(numbers)

def remove_duplicates(items):
    """
    Remove duplicates from a list while preserving order.
    
    >>> remove_duplicates([1, 2, 2, 3, 1, 4])
    [1, 2, 3, 4]
    >>> remove_duplicates(['a', 'b', 'a', 'c'])
    ['a', 'b', 'c']
    >>> remove_duplicates([])
    []
    >>> remove_duplicates([1, 1, 1])
    [1]
    """
    seen = set()
    result = []
    for item in items:
        if item not in seen:
            seen.add(item)
            result.append(item)
    return result

Class doctest

class Calculator:
    """
    A simple calculator class.
    
    >>> calc = Calculator()
    >>> calc.add(5, 3)
    8
    >>> calc.subtract(10, 4)
    6
    >>> calc.multiply(6, 7)
    42
    >>> calc.divide(15, 3)
    5.0
    >>> calc.divide(10, 0)
    Traceback (most recent call last):
        ...
    ZeroDivisionError: Cannot divide by zero
    """
    
    def add(self, a, b):
        """
        >>> calc = Calculator()
        >>> calc.add(2, 3)
        5
        """
        return a + b
    
    def subtract(self, a, b):
        """
        >>> calc = Calculator()
        >>> calc.subtract(10, 4)
        6
        """
        return a - b
    
    def multiply(self, a, b):
        """
        >>> calc = Calculator()
        >>> calc.multiply(3, 4)
        12
        """
        return a * b
    
    def divide(self, a, b):
        """
        >>> calc = Calculator()
        >>> calc.divide(15, 3)
        5.0
        >>> calc.divide(1, 0)
        Traceback (most recent call last):
            ...
        ZeroDivisionError: Cannot divide by zero
        """
        if b == 0:
            raise ZeroDivisionError("Cannot divide by zero")
        return a / b

Exception Handling Tests

def validate_age(age):
    """
    Validate age value.
    
    >>> validate_age(25)
    True
    >>> validate_age(0)
    True
    >>> validate_age(120)
    True
    >>> validate_age(-1)
    Traceback (most recent call last):
        ...
    ValueError: Age cannot be negative
    >>> validate_age(150)
    Traceback (most recent call last):
        ...
    ValueError: Age cannot exceed 150
    >>> validate_age("25")
    Traceback (most recent call last):
        ...
    TypeError: Age must be an integer
    """
    if not isinstance(age, int):
        raise TypeError("Age must be an integer")
    if age < 0:
        raise ValueError("Age cannot be negative")
    if age > 150:
        raise ValueError("Age cannot exceed 150")
    return True

def parse_email(email):
    """
    Parse an email address.
    
    >>> parse_email("[email protected]")
    ('user', 'example.com')
    >>> parse_email("[email protected]")
    ('test.email', 'domain.org')
    >>> parse_email("invalid-email")
    Traceback (most recent call last):
        ...
    ValueError: Invalid email format
    >>> parse_email("")
    Traceback (most recent call last):
        ...
    ValueError: Invalid email format
    """
    if "@" not in email:
        raise ValueError("Invalid email format")
    
    parts = email.split("@")
    if len(parts) != 2 or not parts[0] or not parts[1]:
        raise ValueError("Invalid email format")
    
    return parts[0], parts[1]

File Test Execution

# math_utils.py
def factorial(n):
    """
    Calculate factorial.
    
    >>> factorial(5)
    120
    >>> factorial(0)
    1
    >>> factorial(1)
    1
    """
    if n < 0:
        raise ValueError("Factorial is not defined for negative numbers")
    if n == 0 or n == 1:
        return 1
    return n * factorial(n - 1)

# Execution method
if __name__ == "__main__":
    import doctest
    # Run tests in verbose mode
    doctest.testmod(verbose=True)

External File doctest

# test_examples.txt file

Mathematical Calculation Tests
===============================

Addition examples:
>>> 2 + 3
5

>>> 10 + (-5)
5

String manipulation examples:
>>> "Hello" + " " + "World"
'Hello World'

>>> "Python".upper()
'PYTHON'

List operation examples:
>>> numbers = [1, 2, 3, 4, 5]
>>> len(numbers)
5

>>> numbers[0]
1

>>> numbers[-1]
5

# Execution code
if __name__ == "__main__":
    import doctest
    # Run external file tests
    doctest.testfile("test_examples.txt", verbose=True)

Custom Configuration doctest Execution

def division_with_precision(a, b, precision=2):
    """
    Perform division with specified precision.
    
    >>> division_with_precision(22, 7)  # doctest: +ELLIPSIS
    3.1...
    >>> division_with_precision(1, 3, 4)
    0.3333
    >>> division_with_precision(10, 3)  # doctest: +SKIP
    3.33
    """
    return round(a / b, precision)

def get_current_time():
    """
    Get current time (ignored in tests).
    
    >>> get_current_time()  # doctest: +SKIP
    '2024-01-01 12:00:00'
    """
    from datetime import datetime
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

# Test execution with flags
if __name__ == "__main__":
    import doctest
    # Allow ellipsis notation and normalized whitespace
    doctest.testmod(
        optionflags=doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE
    )

pytest Integration with doctest

# Configuration in pytest.ini or pyproject.toml
[tool.pytest.ini_options]
addopts = "--doctest-modules"

# Or at runtime
# pytest --doctest-modules

def fibonacci(n):
    """
    Return the nth value in the Fibonacci sequence.
    
    >>> fibonacci(0)
    0
    >>> fibonacci(1)
    1
    >>> fibonacci(5)
    5
    >>> fibonacci(10)
    55
    """
    if n <= 1:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

# pytest doctest execution example
def test_fibonacci_doctests():
    """Run doctest with pytest"""
    import doctest
    import sys
    current_module = sys.modules[__name__]
    result = doctest.testmod(current_module, verbose=True)
    assert result.failed == 0, f"{result.failed} doctests failed"

Practical doctest Design Patterns

class BankAccount:
    """
    Bank account class.
    
    Basic usage:
    >>> account = BankAccount("Alice", 1000)
    >>> account.get_balance()
    1000
    >>> account.deposit(500)
    >>> account.get_balance()
    1500
    >>> account.withdraw(200)
    >>> account.get_balance()
    1300
    
    Error cases:
    >>> account.withdraw(2000)
    Traceback (most recent call last):
        ...
    ValueError: Insufficient balance
    >>> account.withdraw(-100)
    Traceback (most recent call last):
        ...
    ValueError: Amount must be positive
    """
    
    def __init__(self, name, initial_balance=0):
        """
        Create a new account.
        
        >>> account = BankAccount("Bob")
        >>> account.get_balance()
        0
        >>> account.name
        'Bob'
        """
        self.name = name
        self.balance = initial_balance
    
    def deposit(self, amount):
        """
        Deposit the specified amount.
        
        >>> account = BankAccount("Charlie", 100)
        >>> account.deposit(50)
        >>> account.get_balance()
        150
        """
        if amount <= 0:
            raise ValueError("Amount must be positive")
        self.balance += amount
    
    def withdraw(self, amount):
        """
        Withdraw the specified amount.
        
        >>> account = BankAccount("David", 200)
        >>> account.withdraw(50)
        >>> account.get_balance()
        150
        """
        if amount <= 0:
            raise ValueError("Amount must be positive")
        if amount > self.balance:
            raise ValueError("Insufficient balance")
        self.balance -= amount
    
    def get_balance(self):
        """
        Get the current balance.
        
        >>> account = BankAccount("Eve", 300)
        >>> account.get_balance()
        300
        """
        return self.balance

# Test execution and coverage verification
if __name__ == "__main__":
    import doctest
    result = doctest.testmod(verbose=True)
    print(f"\nTest results: {result.attempted} attempted, {result.failed} failed")