Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(snapshots): move to separate module #152

Merged
merged 2 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading