testfixtures

repository·main·Indexed 42 days ago

https://github.com/simplistix/testfixtures

A Python testing utility library (v12.3.0) providing high-level helpers for mocking, asserting, and capturing system behaviors. It includes tools for deep object comparison with detailed diffs, flexible matchers, log and output capture, time mocking, and specialized support for frameworks like Django, Pydantic, Pandas, and Polars. It also features a Command class for testing command-line scripts.

Tokens
53.5K
Snippets
180
Records
204
Agent score
53%

What's inside testfixtures

  1. Overview of Testfixtures capabilities

    main

    Testfixtures is a collection of Python helpers and mock objects designed to simplify automated testing. It provides specialized tools for several testing domains:

    • Assertions & Comparisons: Deeply nested diffs for complex data (pandas, polars, numpy, pydantic) and flexible matchers like like, sequence, and RangeComparison.
    • Mocking: Swapping objects, classes, or methods using Replacer (with automatic restoration), and mocking environment variables or module globals.
    • Time/Date Mocking: Drop-in replacements for datetime, date, and time APIs that handle timezones and explicit time ticking.
    • Subprocesses & CLI: Mocking subprocess.Popen with MockPopen and end-to-end testing of entry-point scripts with Command.
    • Output & Logging: Capturing logs (including loguru and structlog) via LogCapture, and capturing stream output (like print) via OutputCapture.
    • Filesystem: Sandboxed directory management with TempDirectory for file/directory assertions and structured data serialization (JSON, YAML, TOML).
    • Exceptions & Warnings: Verifying that specific exceptions, assertion failures, or warnings are raised using ShouldRaise, ShouldAssert, ShouldWarn, and ShouldNotWarn.
    • Framework Specifics: Specialized helpers for Django models and Twisted logging.
  2. Implement custom serialization formats

    main

    You can extend TempDir support to custom formats by implementing the testfixtures.formats.Format protocol. A custom format class must implement two methods:

    • parse(data: str) -> Any: Deserializes a string into a Python object.
    • render(obj: Any) -> str: Serializes a Python object into a string.
    import csv
    from io import StringIO
    from testfixtures.formats import Format
    
    class CSVFormat:
        def parse(self, data: str) -> list[list[str]]:
            reader = csv.reader(StringIO(data))
            return list(reader)
    
        def render(self, obj: list[list[str]]) -> str:
            output = StringIO()
            writer = csv.writer(output)
            writer.writerows(obj)
            return output.getvalue()
    
    # Usage
    with TempDir() as d:
        path = d.dump('data.csv', [['name', 'age'], ['Alice', '30']], format=CSVFormat())
        rows = d.parse('data.csv', format=CSVFormat())
  3. Important: Replace where an object is used, not where it is defined

    main

    When mocking functions or modules, you must replace the object in the module where it is consumed, not where it is originally defined.

    If module_a imports time.time, calling replace.in_module(time.time, Mock()) will not affect module_a because module_a already holds a reference to the original time.time. You must instead target module_a specifically.

    # WRONG: This won't affect modules that already imported time.time
    replace.in_module(time.time, Mock())
    
    # CORRECT: Target the module that is actually using the function
    from tests import sample1 as sample1_module
    replace.in_module(time.time, Mock(), module=sample1_module)
  4. Customise Command setup by overriding Run methods

    main

    To handle scripts that configure their own logging or use complex setup logic, you can provide a custom runner class by inheriting from testfixtures.Run (or AbstractRun) and overriding specific methods:

    • setup_mocks(cls, replace: Replacer) -> Mock: Intercept and record configuration calls (e.g., logging.basicConfig).
    • setup_logging(cls) -> LogCapture: Define how logging is captured (e.g., for loguru).
    • setup_output(cls) -> OutputCapture: Define how stdout/stderr are captured.
    • check_logging(expected, logging): Customize how log assertions are performed.
    • check_output(expected, output): Customize how output assertions are performed.
  5. Handling type checking with strict mock modes

    main

    By default, mock_datetime and mock_date return instances of the real datetime.datetime and datetime.date classes. This ensures compatibility with database adapters and other libraries that perform type checks against the standard library classes.

    However, if your code performs type checks against the mock classes themselves (e.g., isinstance(obj, mock_datetime)), these checks will fail in default mode. To fix this, use the strict=True keyword parameter. This causes the mocks to return instances of the mock classes instead of the real standard library classes.

    from testfixtures import mock_datetime
    
    # Default behavior: returns real datetime objects
    datetime_ = mock_datetime()
    print(isinstance(datetime_.now(), datetime_))  # False
    
    # Strict behavior: returns MockDateTime objects
    datetime_strict = mock_datetime(strict=True)
    print(isinstance(datetime_strict.now(), datetime_strict))  # True
  6. Wrap existing paths with TempDir

    main

    If you are using a testing framework that already provides a temporary directory (like pytest's tmp_path), you can wrap that existing path with TempDir to gain access to its API (like .write() and .compare()).

    Important: When wrapping an existing path, TempDir will not delete the directory it is wrapping during cleanup.

    >>> # Assuming tmp_path is provided by pytest
    >>> with TempDir(tmp_path) as d:
    ...     d.write('some/path.txt', 'some text')
    ...     d.compare(expected=('some/', 'some/path.txt'))
  7. Capture Loguru structured context (extra fields)

    main

    Loguru uses .bind() and .contextualize() to manage structured context, which is stored in the extra key of the record. To verify these fields, you must pass the extra key (and optionally level_name and message) to the LoguruSource constructor via the attributes argument.

    Using .bind(): Creates a logger with fixed extra fields. Using .contextualize(): Temporarily adds context to all logging within a with block.

    from testfixtures.loguru import LoguruSource, level_name
    from loguru import logger
    
    # 1. Testing .bind()
    request_log = logger.bind(request_id='abc123')
    with LogCapture(LoguruSource((level_name, 'message', 'extra'))) as log:
        request_log.info('handling request')
    
    log.check(
        ('INFO', 'handling request', {'request_id': 'abc123'}),
    )
    
    # 2. Testing .contextualize()
    with LogCapture(LoguruSource((level_name, 'message', 'extra'))) as log:
        with logger.contextualize(task_id=1234):
            logger.info('processing task')
    
    log.check(
        ('INFO', 'processing task', {'task_id': 1234}),
    )
  8. How `compare` handles different data types

    main

    The compare function automatically recognizes the type of values provided and produces tailored error descriptions that pinpoint differences and recurse into nested elements. Supported types include:

    • Sets: Highlights items present in expected but not actual, and vice versa.
    • Dictionaries: Identifies common keys, keys missing from one side, and specific values that differ.
    • Lists and Tuples: Identifies common elements and shows the specific elements that differ in position/value.
    • Namedtuples: If types match, it highlights which fields are the same and which specific field values differ.
    • Generators: Both generators are unwound into tuples before comparison.
    • Strings: Provides long-string representations or unified diffs for multi-line strings.
    • Datetimes and Times: Compares temporal values (note: standard comparison may treat different fold values as equal unless strict=True is used).
    • Objects: Compares instances based on their type and attributes, even if they don't natively support comparison.
    >>> compare(expected={1, 2}, actual={2, 3})
    Traceback (most recent call last):
     ...
    AssertionError: set not as expected:
    
    in expected but not actual:
    [1]
    
    in actual but not expected:
    [3]
  9. Configure timezones in mock_datetime

    main

    By default, mock_datetime simulates local time in the UTC timezone. If you need to test timezone-aware logic, you can pass a datetime.tzinfo object to the tzinfo parameter in the mock_datetime constructor.

    Warning: To avoid unexpected behavior, do not use the tzinfo parameter or pass datetime instances with non-None tzinfo attributes when calling .add() or .set() on a MockDateTime object.

    from datetime import tzinfo, timedelta
    from testfixtures import mock_datetime
    
    class ATZInfo(tzinfo):
         def tzname(self, dt):
              return 'A TimeZone'
         def utcoffset(self, dt):
              return timedelta(hours=-5) + self.dst(dt)
         def dst(self, dt):
              if 3 < dt.month < 9:
                  return timedelta(hours=1)
              return timedelta()
    
    # Simulate a specific local timezone
    datetime = mock_datetime(delta=0, tzinfo=ATZInfo())
    datetime.set(2001, 1, 1, 10, 0)
    print(datetime.now()) # datetime.datetime(2001, 1, 1, 10, 0, tzinfo=<ATZInfo ...>)
  10. How Pydantic BaseModel comparison works

    main

    When pydantic is installed, testfixtures automatically registers a comparer for pydantic.BaseModel with ignore_eq=True.

    Instead of using Pydantic's default __eq__ (which can fail or produce misleading results when models contain complex types like Polars or Pandas DataFrames), testfixtures.compare inspects the model field-by-field using its declared attributes. This allows for clear diffs showing exactly which attributes differ.

    Why ignore_eq=True is necessary: Without this registration, Pydantic's __eq__ calls == on each attribute value before testfixtures can intercept it. For types like DataFrames, this often raises a TypeError (e.g., the truth value of a DataFrame is ambiguous).

    from pydantic import BaseModel
    from testfixtures import compare
    
    class Point(BaseModel):
        x: int
        y: int
    
    # Clear diffs are produced for attribute mismatches
    compare(Point(x=1, y=2), expected=Point(x=1, y=3))
  11. How to capture and test structlog events

    main

    Use StructlogSource passed to LogCapture to intercept structlog event dictionaries.

    Important Note on Caching: If your application has cache_logger_on_first_use enabled, you must either acquire loggers inside the LogCapture block or disable that setting during tests. Otherwise, cached loggers will bypass the capture mechanism.

    Warning: Do not confuse testfixtures.LogCapture with structlog.testing.LogCapture.

    import pytest
    from testfixtures import LogCapture
    from testfixtures.structlog import StructlogSource
    
    @pytest.fixture()
    def logs():
        with LogCapture(StructlogSource()) as logs_:
            yield logs_
    
    # Usage in a test
    def test_logging(logs: LogCapture) -> None:
        import structlog
        logger = structlog.get_logger()
        logger.info('42 is fine')
        logs.check(
            ('INFO', '42 is fine'),
        )
  12. How NumPy array comparison works in testfixtures

    main

    When numpy is installed, testfixtures automatically registers comparers for numpy.ndarray and numpy.ma.MaskedArray with ignore_eq=True.

    Comparison Logic

    • Float and Complex arrays: Uses numpy.testing.assert_allclose with default tolerances of rtol=1e-5 and atol=1e-8 (matching pandas and polars behavior).
    • Other dtypes: Uses numpy.testing.assert_array_equal.
    • Strict Equality: To bypass floating-point tolerances and require bitwise equality, pass strict=True to the compare function.
    • Constraints: Shape and dtype must match exactly. There is no broadcasting, and an int32 array is not equal to an int64 array.
    • NaNs: NaNs in matching positions are treated as equal.
    • Scalars: NumPy scalars work with standard testfixtures.compare because their == operator returns a boolean.

    Error Reporting

    When arrays differ, testfixtures uses NumPy's detailed summary output. If arrays are nested within other structures (like dictionaries), testfixtures provides breadcrumbs to the exact location of the mismatch.

    import numpy as np
    from testfixtures import compare
    
    # Standard comparison (uses tolerances for floats)
    fa = np.array([1.0 + 1e-9, 2.0])
    fb = np.array([1.0, 2.0])
    compare(fa, expected=fb)
    
    # Strict comparison (bitwise equality)
    compare(fa, expected=fb, strict=True)