Skip to content

Commit

Permalink
refactor(snapshots): move to separate module (MODFLOW-USGS#152)
Browse files Browse the repository at this point in the history
  • Loading branch information
wpbonelli authored May 13, 2024
1 parent c9e445d commit d96089e
Show file tree
Hide file tree
Showing 12 changed files with 189 additions and 194 deletions.
1 change: 0 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ jobs:
ref="${{ github.ref_name }}"
version="${ref#"v"}"
python scripts/update_version.py -v "$version"
python scripts/lint.py
python -c "import modflow_devtools; print('Version: ', modflow_devtools.__version__)"
echo "version=$version" >> $GITHUB_OUTPUT
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ pip install "modflow-devtools[test]"

To install from source and set up a development environment please see the [developer documentation](DEVELOPER.md).

To import `pytest` fixtures in a project consuming `modflow-devtools`, add the following to a `conftest.py` file:
To import `pytest` fixtures in a project consuming `modflow-devtools`, add the following to a test file or `conftest.py` file:

```python
pytest_plugins = [ "modflow_devtools.fixtures" ]
Expand Down
61 changes: 0 additions & 61 deletions autotest/test_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import platform
from pathlib import Path

import numpy as np
import pytest
from _pytest.config import ExitCode

Expand Down Expand Up @@ -303,63 +302,3 @@ def test_tabular(tabular, arg, function_tmpdir):
assert pytest.main(args) == ExitCode.OK
res = open(next(function_tmpdir.rglob(test_tabular_fname))).readlines()[0]
assert tabular == res


# test snapshot fixtures


snapshot_array = np.array([1.1, 2.2, 3.3])
snapshots_path = proj_root / "autotest" / "__snapshots__"


def test_binary_array_snapshot(array_snapshot):
assert array_snapshot == snapshot_array
snapshot_path = (
snapshots_path
/ module_path.stem
/ f"{inspect.currentframe().f_code.co_name}.npy"
)
assert snapshot_path.is_file()
assert np.allclose(np.load(snapshot_path), snapshot_array)


# todo: reinstate if/when we support multiple arrays
# def test_binary_array_snapshot_multi(array_snapshot):
# arrays = {"ascending": snapshot_array, "descending": np.flip(snapshot_array)}
# assert array_snapshot == arrays
# snapshot_path = (
# snapshots_path
# / module_path.stem
# / f"{inspect.currentframe().f_code.co_name}.npy"
# )
# assert snapshot_path.is_file()
# assert np.allclose(np.load(snapshot_path)["ascending"], snapshot_array)
# assert np.allclose(np.load(snapshot_path)["descending"], np.flip(snapshot_array))


def test_text_array_snapshot(text_array_snapshot):
assert text_array_snapshot == snapshot_array
snapshot_path = (
snapshots_path
/ module_path.stem
/ f"{inspect.currentframe().f_code.co_name}.txt"
)
assert snapshot_path.is_file()
assert np.allclose(np.loadtxt(snapshot_path), snapshot_array)


def test_readable_text_array_snapshot(readable_array_snapshot):
assert readable_array_snapshot == snapshot_array
snapshot_path = (
snapshots_path
/ module_path.stem
/ f"{inspect.currentframe().f_code.co_name}.txt"
)
assert snapshot_path.is_file()
assert np.allclose(
np.fromstring(
open(snapshot_path).readlines()[0].replace("[", "").replace("]", ""),
sep=" ",
),
snapshot_array,
)
63 changes: 63 additions & 0 deletions autotest/test_snapshots.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import inspect
from pathlib import Path

import numpy as np

proj_root = Path(__file__).parents[1]
module_path = Path(inspect.getmodulename(__file__))
pytest_plugins = [ "modflow_devtools.snapshots" ] # activate snapshot fixtures
snapshot_array = np.array([1.1, 2.2, 3.3])
snapshots_path = proj_root / "autotest" / "__snapshots__"


def test_binary_array_snapshot(array_snapshot):
assert array_snapshot == snapshot_array
snapshot_path = (
snapshots_path
/ module_path.stem
/ f"{inspect.currentframe().f_code.co_name}.npy"
)
assert snapshot_path.is_file()
assert np.allclose(np.load(snapshot_path), snapshot_array)


# todo: reinstate if/when we support multiple arrays
# def test_binary_array_snapshot_multi(array_snapshot):
# arrays = {"ascending": snapshot_array, "descending": np.flip(snapshot_array)}
# assert array_snapshot == arrays
# snapshot_path = (
# snapshots_path
# / module_path.stem
# / f"{inspect.currentframe().f_code.co_name}.npy"
# )
# assert snapshot_path.is_file()
# assert np.allclose(np.load(snapshot_path)["ascending"], snapshot_array)
# assert np.allclose(np.load(snapshot_path)["descending"], np.flip(snapshot_array))


def test_text_array_snapshot(text_array_snapshot):
assert text_array_snapshot == snapshot_array
snapshot_path = (
snapshots_path
/ module_path.stem
/ f"{inspect.currentframe().f_code.co_name}.txt"
)
assert snapshot_path.is_file()
assert np.allclose(np.loadtxt(snapshot_path), snapshot_array)


def test_readable_text_array_snapshot(readable_array_snapshot):
assert readable_array_snapshot == snapshot_array
snapshot_path = (
snapshots_path
/ module_path.stem
/ f"{inspect.currentframe().f_code.co_name}.txt"
)
assert snapshot_path.is_file()
assert np.allclose(
np.fromstring(
open(snapshot_path).readlines()[0].replace("[", "").replace("]", ""),
sep=" ",
),
snapshot_array,
)
10 changes: 0 additions & 10 deletions docs/md/fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,3 @@ Model-loading fixtures use a set of utility functions to find and enumerate mode
- `get_namefile_paths()`

These functions are used internally in a `pytest_generate_tests` hook to implement the above model-parametrization fixtures. See `fixtures.py` and/or this project's test suite for usage examples.

## Snapshot testing

Snapshot testing is a form of regression testing in which a "snapshot" of the results of some computation is verified and captured by the developer to be compared against when tests are subsequently run. This is accomplished with [`syrupy`](https://github.com/tophat/syrupy), which provides a `snapshot` fixture overriding the equality operator to allow comparison with e.g. `snapshot == result`. A few custom fixtures for snapshots of NumPy arrays are also provided:

- `array_snapshot`: saves an array in a binary file for compact storage, can be inspected programmatically with `np.load()`
- `text_array_snapshot`: flattens an array and stores it in a text file, compromise between readability and disk usage
- `readable_array_snapshot`: stores an array in a text file in its original shape, easy to inspect but largest on disk

By default, tests run in comparison mode. This means a newly written test using any of the snapshot fixtures will fail until a snapshot is created. Snapshots can be created/updated by running pytest with the `--snapshot-update` flag.
17 changes: 17 additions & 0 deletions docs/md/snapshots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Snapshot testing

Snapshot testing is a form of regression testing in which a "snapshot" of the results of some computation is verified and captured by the developer to be compared against when tests are subsequently run. This is accomplished with [`syrupy`](https://github.com/tophat/syrupy), which provides a `snapshot` fixture overriding the equality operator to allow comparison with e.g. `snapshot == result`. A few custom fixtures for snapshots of NumPy arrays are also provided:

- `array_snapshot`: saves an array in a binary file for compact storage, can be inspected programmatically with `np.load()`
- `text_array_snapshot`: flattens an array and stores it in a text file, compromise between readability and disk usage
- `readable_array_snapshot`: stores an array in a text file in its original shape, easy to inspect but largest on disk

By default, tests run in comparison mode. This means a newly written test using any of the snapshot fixtures will fail until a snapshot is created. Snapshots can be created/updated by running pytest with the `--snapshot-update` flag.

## Using snapshot fixtures

To use snapshot fixtures, add the following line to a test file or `conftest.py` file:

```python
pytest_plugins = [ "modflow_devtools.snapshots" ]
```
100 changes: 0 additions & 100 deletions modflow_devtools/fixtures.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from collections import OrderedDict
from io import BytesIO, StringIO
from itertools import groupby
from os import PathLike, environ
from pathlib import Path
Expand All @@ -9,91 +8,7 @@
from modflow_devtools.imports import import_optional_dependency
from modflow_devtools.misc import get_namefile_paths, get_packages

np = import_optional_dependency("numpy")
pytest = import_optional_dependency("pytest")
syrupy = import_optional_dependency("syrupy")

# ruff: noqa: E402
from syrupy.extensions.single_file import (
SingleFileSnapshotExtension,
WriteMode,
)
from syrupy.types import (
PropertyFilter,
PropertyMatcher,
SerializableData,
SerializedData,
)

# snapshot extensions


class BinaryArrayExtension(SingleFileSnapshotExtension):
"""
Binary snapshot of a NumPy array. Can be read back into NumPy with
.load(), preserving dtype and shape. This is the recommended array
snapshot approach if human-readability is not a necessity, as disk
space is minimized.
"""

_write_mode = WriteMode.BINARY
_file_extension = "npy"

def serialize(
self,
data,
*,
exclude=None,
include=None,
matcher=None,
):
buffer = BytesIO()
np.save(buffer, data)
return buffer.getvalue()


class TextArrayExtension(SingleFileSnapshotExtension):
"""
Text snapshot of a NumPy array. Flattens the array before writing.
Can be read back into NumPy with .loadtxt() assuming you know the
shape of the expected data and subsequently reshape it if needed.
"""

_write_mode = WriteMode.TEXT
_file_extension = "txt"

def serialize(
self,
data: "SerializableData",
*,
exclude: Optional["PropertyFilter"] = None,
include: Optional["PropertyFilter"] = None,
matcher: Optional["PropertyMatcher"] = None,
) -> "SerializedData":
buffer = StringIO()
np.savetxt(buffer, data.ravel())
return buffer.getvalue()


class ReadableArrayExtension(SingleFileSnapshotExtension):
"""
Human-readable snapshot of a NumPy array. Preserves array shape
at the expense of possible loss of precision (default 8 places)
and more difficulty loading into NumPy than TextArrayExtension.
"""

_write_mode = WriteMode.TEXT
_file_extension = "txt"

def serialize(
self,
data: "SerializableData",
*,
exclude: Optional["PropertyFilter"] = None,
include: Optional["PropertyFilter"] = None,
matcher: Optional["PropertyMatcher"] = None,
) -> "SerializedData":
return np.array2string(data, threshold=np.inf)


# fixtures
Expand Down Expand Up @@ -176,21 +91,6 @@ def tabular(request) -> str:
return tab


@pytest.fixture
def array_snapshot(snapshot):
return snapshot.use_extension(BinaryArrayExtension)


@pytest.fixture
def text_array_snapshot(snapshot):
return snapshot.use_extension(TextArrayExtension)


@pytest.fixture
def readable_array_snapshot(snapshot):
return snapshot.use_extension(ReadableArrayExtension)


# configuration hooks


Expand Down
Loading

0 comments on commit d96089e

Please sign in to comment.