Metadata-Version: 2.4
Name: pytest-ditto
Version: 1.0.0
Summary: Snapshot testing pytest plugin with minimal ceremony and flexible persistence formats.
Author-email: Lachlan Taylor <95459213+owlowlyowl@users.noreply.github.com>
Maintainer-email: Lachlan Taylor <95459213+owlowlyowl@users.noreply.github.com>
License-Expression: MIT
License-File: LICENSE
Keywords: pytest,testing
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Pytest
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Testing
Requires-Python: >=3.12
Requires-Dist: pytest>=3.5.0
Requires-Dist: pyyaml
Provides-Extra: dev
Requires-Dist: hatch-vcs>=0.4.0; extra == 'dev'
Requires-Dist: hatch>=1.9.4; extra == 'dev'
Requires-Dist: pre-commit; extra == 'dev'
Provides-Extra: pandas
Requires-Dist: pytest-ditto-pandas; extra == 'pandas'
Provides-Extra: pyarrow
Requires-Dist: pytest-ditto-pyarrow; extra == 'pyarrow'
Description-Content-Type: text/markdown

# pytest-ditto
[![PyPI version](https://badge.fury.io/py/pytest-ditto.svg)](https://badge.fury.io/py/pytest-ditto)
[![Continuous Integration](https://github.com/owlowlyowl/pytest-ditto/actions/workflows/ci.yml/badge.svg)](https://github.com/owlowlyowl/pytest-ditto/actions/workflows/ci.yml)

Snapshot testing pytest plugin with minimal ceremony and flexible recorders.

## Introduction
The `pytest-ditto` plugin is intended to be used for snapshot/regression testing. There are
two key components: the `snapshot` fixture and snapshot recorders.

### The `snapshot` Fixture
In the following basic example, the function to test is `fn`, the test is using the
`snapshot` fixture and it is asserting that the result of calling `fn` with the
value of `x` does not change.


```python
import ditto


def fn(x: int) -> int:
    return x + 1  # original implementation
    # return x + 2  # new implementation


def test_fn(snapshot) -> None:
    x = 1
    result = fn(x)
    assert result == snapshot(result, key="fn")
```

The first time the test is run, the `snapshot` fixture takes the data passed to it and
persists it to a `.ditto` directory in the same location as the test module. Subsequent
test runs load the stored file and use that value for comparison.

By default, snapshot data is persisted using `pickle`; however, a range of recorders
can be selected per test using `ditto` marks.

### @ditto Marks
If the default recorder (`pickle`) isn't appropriate, a different recorder can be
specified per test using `ditto` marks — customised `pytest` mark decorators.

The built-in recorders are:

| Mark | Recorder | File extension |
| --- | --- | --- |
| `@ditto.pickle` | pickle | `.pkl` |
| `@ditto.yaml` | yaml | `.yaml` |
| `@ditto.json` | json | `.json` |
| `@ditto.record("name")` | any registered recorder | varies |

`@ditto.pickle`, `@ditto.yaml`, and `@ditto.json` are convenience shorthands for
`@ditto.record("pickle")`, `@ditto.record("yaml")`, and `@ditto.record("json")`
respectively.

Additional recorders can be installed via plugins:

| Recorders | Plugin | Marks |
| --- | --- | --- |
| `pandas` | `pytest-ditto-pandas` | <ul><li>`@ditto.pandas.parquet`</li><li>`@ditto.pandas.json`</li><li>`@ditto.pandas.csv`</li> |
| `pyarrow` | `pytest-ditto-pyarrow` | <ul><li>`@ditto.pyarrow.parquet`</li><li>`@ditto.pyarrow.feather`</li><li>`@ditto.pyarrow.csv`</li> |


## Usage

### `pd.DataFrame`

Install `pytest-ditto[pandas]` to make `pandas` recorders available.

```python
import pandas as pd

import ditto


def awesome_fn_to_test(df: pd.DataFrame):
    df.loc[:, "a"] *= 2
    return df


@ditto.pandas.parquet
def test_fn_with_parquet_dataframe_snapshot(snapshot):
    input_data = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 9]})
    result = awesome_fn_to_test(input_data)
    pd.testing.assert_frame_equal(result, snapshot(result, key="ab_dataframe"))


@ditto.pandas.json
def test_fn_with_json_dataframe_snapshot(snapshot):
    input_data = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 9]})
    result = awesome_fn_to_test(input_data)
    pd.testing.assert_frame_equal(result, snapshot(result, key="ab_dataframe"))
```

For the above example the snapshot files would be found in the following locations:
- `.ditto/test_fn_with_parquet_dataframe_snapshot@ab_dataframe.pandas.parquet`
- `.ditto/test_fn_with_json_dataframe_snapshot@ab_dataframe.pandas.json`


### `pyarrow.Table`

Install `pytest-ditto[pyarrow]` to make `pyarrow` recorders available.

```python
import pyarrow as pa
import pyarrow.compute as pc
import ditto
import pytest


@pytest.fixture
def table() -> pa.Table:
    return pa.table(
        [
            [1, 2, 3, 4],
            [4.5, 5.2, 6.8, 3.5],
            [7, 8.5, None, None],
            [True, False, True, True],
            ["a", "b", "c", "x"],
        ],
        names=list("abcde"),
    )


def fn(x: pa.Table):
    even_filter = (pc.bit_wise_and(pc.field("a"), pc.scalar(1)) == pc.scalar(0))
    return x.filter(even_filter)


@ditto.pyarrow.parquet
def test_fn_with_pyarrow_parquet_snapshot(snapshot, table):
    result = fn(table)
    assert result.equals(snapshot(result, key="filtered"))
```

For the above example the snapshot files would be found in the following location:
- `.ditto/test_fn_with_pyarrow_parquet_snapshot@filtered.pyarrow.parquet`


### `unittest.TestCase`

`DittoTestCase` provides the `snapshot` fixture as a `cached_property` for use with
`unittest.TestCase`:

```python
import unittest
from ditto import DittoTestCase


def fn(x: int) -> int:
    return x + 1


class TestFn(DittoTestCase):
    def test_fn(self):
        result = fn(1)
        assert result == self.snapshot(result, key="fn")
```

Snapshot files are placed in a `.ditto` directory adjacent to the test file, using the
fully-qualified test method name as the group name.


## Custom Recorders

A `Recorder` is a frozen dataclass pairing a file extension with `save` and `load`
functions. Plugin packages register `Recorder` instances via the `ditto_recorders`
entry point group.

```python
from pathlib import Path
from ditto.recorders import Recorder


def _save(data: MyType, filepath: Path) -> None:
    ...  # write data to filepath


def _load(filepath: Path) -> MyType:
    ...  # read and return data from filepath


my_recorder: Recorder[MyType] = Recorder(
    extension="myformat",
    save=_save,
    load=_load,
)
```

Register it in `pyproject.toml`:

```toml
[project.entry-points.ditto_recorders]
my_recorder = "my_package.recorders:my_recorder"
```

Once registered, the recorder is available by name via `@ditto.record("my_recorder")`.
Plugin marks (e.g. `@ditto.myplugin.myformat`) can also be registered via the
`ditto_marks` entry point group.
