Skip to content

Commit

Permalink
Add testbook to notebook subsystem
Browse files Browse the repository at this point in the history
It allows convenient patching and injection into Jupyter Notebooks,
to support software testing.
  • Loading branch information
amotl committed Dec 4, 2023
1 parent 2d740db commit dc53919
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 42 deletions.
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- ngr: Fix Gradle test runner by wiping existing Gradle wrappers, to accommodate
for contemporary versions of Java
- Add support for Python 3.7
- Add `testbook` to `notebook` subsystem

## 2023-11-06 v0.0.3
- ngr: Fix `contextlib.chdir` only available on Python 3.11 and newer
Expand Down
7 changes: 7 additions & 0 deletions pueblo/testing/snippet.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
import pytest


def fourty_two() -> int:
"""
A dummy function to be patched by testing machinery.
"""
return 42


def pytest_module_function(request: pytest.FixtureRequest, filepath: t.Union[str, Path], entrypoint: str = "main"):
"""
From individual Python file, collect and wrap the `main` function into a test case.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ notebook = [
"nbdime<5",
"notebook<8",
"pytest-notebook<0.11",
"testbook<0.5",
]
release = [
"build<2",
Expand Down
97 changes: 97 additions & 0 deletions tests/test_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from pathlib import Path

from pueblo.testing.notebook import monkeypatch_pytest_notebook_treat_cell_exit_as_notebook_skip
from pueblo.testing.snippet import pytest_module_function, pytest_notebook

HERE = Path(__file__).parent
TESTDATA_FOLDER = HERE / "testdata" / "folder"
TESTDATA_SNIPPET = HERE / "testdata" / "snippet"


def test_monkeypatch_pytest_notebook_treat_cell_exit_as_notebook_skip():
"""
Verify loading a monkeypatch supporting Jupyter Notebook testing.
"""
monkeypatch_pytest_notebook_treat_cell_exit_as_notebook_skip()


def test_pytest_module_function(request, capsys):
"""
Verify running an arbitrary Python function from an arbitrary Python file.
"""
outcome = pytest_module_function(request=request, filepath=TESTDATA_FOLDER / "dummy.py")
assert isinstance(outcome[0], Path)
assert outcome[0].name == "dummy.py"
assert outcome[1] == 0
assert outcome[2] == "test_pytest_module_function.main"

out, err = capsys.readouterr()
assert out == "Hallo, Räuber Hotzenplotz.\n"


def test_pytest_notebook(request):
"""
Verify executing code cells in an arbitrary Jupyter Notebook.
"""
from _pytest._py.path import LocalPath

outcomes = pytest_notebook(request=request, filepath=TESTDATA_FOLDER / "dummy.ipynb")
assert isinstance(outcomes[0][0], LocalPath)
assert outcomes[0][0].basename == "dummy.ipynb"
assert outcomes[0][1] == 0
assert outcomes[0][2] == "notebook: nbregression(dummy)"


def test_list_python_files():
"""
Verify utility function for enumerating all Python files in given directory.
"""
from pueblo.testing.folder import list_python_files, str_list

outcome = str_list(list_python_files(TESTDATA_FOLDER))
assert outcome == ["dummy.py"]


def test_list_notebooks():
"""
Verify utility function for enumerating all Jupyter Notebook files in given directory.
"""
from pueblo.testing.folder import list_notebooks, str_list

outcome = str_list(list_notebooks(TESTDATA_FOLDER))
assert outcome == ["dummy.ipynb"]


def test_notebook_injection():
"""
Execute a Jupyter Notebook with custom code injected into a cell.
"""
from testbook import testbook

notebook = TESTDATA_SNIPPET / "notebook.ipynb"
with testbook(str(notebook)) as tb:
tb.inject(
"""
import pandas as pd
df = pd.DataFrame.from_dict({"baz": "qux"}, orient="index")
""",
run=True,
after=3,
)
tb.execute()
output = tb.cell_output_text(5)
assert output == "0\nbaz qux"


def test_notebook_patching():
"""
Execute a Jupyter Notebook calling code which has been patched.
"""
from testbook import testbook

notebook = TESTDATA_SNIPPET / "notebook.ipynb"
with testbook(str(notebook)) as tb:
with tb.patch("pueblo.testing.snippet.fourty_two", return_value=33.33):
tb.execute()
output = tb.cell_output_text(6)
assert output == "33.33"
42 changes: 0 additions & 42 deletions tests/test_testing.py → tests/test_dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,51 +4,9 @@
from dask.utils import is_dataframe_like
from pandas._testing import raise_assert_detail

from pueblo.testing.notebook import monkeypatch_pytest_notebook_treat_cell_exit_as_notebook_skip
from pueblo.testing.snippet import pytest_module_function, pytest_notebook

HERE = Path(__file__).parent


def test_monkeypatch_pytest_notebook_treat_cell_exit_as_notebook_skip():
monkeypatch_pytest_notebook_treat_cell_exit_as_notebook_skip()


def test_pytest_module_function(request, capsys):
outcome = pytest_module_function(request=request, filepath=HERE / "testing" / "dummy.py")
assert isinstance(outcome[0], Path)
assert outcome[0].name == "dummy.py"
assert outcome[1] == 0
assert outcome[2] == "test_pytest_module_function.main"

out, err = capsys.readouterr()
assert out == "Hallo, Räuber Hotzenplotz.\n"


def test_pytest_notebook(request):
from _pytest._py.path import LocalPath

outcomes = pytest_notebook(request=request, filepath=HERE / "testing" / "dummy.ipynb")
assert isinstance(outcomes[0][0], LocalPath)
assert outcomes[0][0].basename == "dummy.ipynb"
assert outcomes[0][1] == 0
assert outcomes[0][2] == "notebook: nbregression(dummy)"


def test_list_python_files():
from pueblo.testing.folder import list_python_files, str_list

outcome = str_list(list_python_files(HERE / "testing"))
assert outcome == ["dummy.py"]


def test_list_notebooks():
from pueblo.testing.folder import list_notebooks, str_list

outcome = str_list(list_notebooks(HERE / "testing"))
assert outcome == ["dummy.ipynb"]


def assert_shape_equal(left: pd.DataFrame, right: pd.DataFrame, obj: str = "DataFrame"):
if left.shape != right.shape:
raise_assert_detail(obj, f"{obj} shape mismatch", f"{repr(left.shape)}", f"{repr(right.shape)}")
Expand Down
File renamed without changes.
File renamed without changes.
128 changes: 128 additions & 0 deletions tests/testdata/snippet/notebook.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
{
"cells": [
{
"cell_type": "markdown",
"source": [
"# A little notebook with injection and patching\n",
"\n",
"Foo."
],
"metadata": {
"collapsed": false
}
},
{
"cell_type": "markdown",
"source": [
"## Here will be injected into"
],
"metadata": {
"collapsed": false
}
},
{
"cell_type": "code",
"execution_count": 1,
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Hallo, Räuber Hotzenplotz.\n"
]
}
],
"source": [
"print(\"Hallo, Räuber Hotzenplotz.\")"
],
"metadata": {
"collapsed": false
}
},
{
"cell_type": "code",
"execution_count": 8,
"outputs": [],
"source": [
"import pandas as pd\n",
"\n",
"df = pd.DataFrame.from_dict({\"foo\": \"bar\"}, orient=\"index\")"
],
"metadata": {
"collapsed": false
}
},
{
"cell_type": "code",
"execution_count": 7,
"outputs": [
{
"data": {
"text/plain": " 0\nfoo bar",
"text/html": "<div>\n<style scoped>\n .dataframe tbody tr th:only-of-type {\n vertical-align: middle;\n }\n\n .dataframe tbody tr th {\n vertical-align: top;\n }\n\n .dataframe thead th {\n text-align: right;\n }\n</style>\n<table border=\"1\" class=\"dataframe\">\n <thead>\n <tr style=\"text-align: right;\">\n <th></th>\n <th>0</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <th>foo</th>\n <td>bar</td>\n </tr>\n </tbody>\n</table>\n</div>"
},
"execution_count": 7,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"df"
],
"metadata": {
"collapsed": false
}
},
{
"cell_type": "markdown",
"source": [
"## I will be patched"
],
"metadata": {
"collapsed": false
}
},
{
"cell_type": "code",
"execution_count": 19,
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"42\n"
]
}
],
"source": [
"from pueblo.testing.snippet import fourty_two\n",
"\n",
"print(fourty_two())"
],
"metadata": {
"collapsed": false
}
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 2
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython2",
"version": "2.7.6"
}
},
"nbformat": 4,
"nbformat_minor": 0
}

0 comments on commit dc53919

Please sign in to comment.