1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192
|
# Usage
The motivation behind creating testbook was to be able to write conventional unit tests for Jupyter Notebooks.
## How it works
Testbook achieves conventional unit tests to be written by setting up references to variables/functions/classes in the Jupyter Notebook. All interactions with these reference objects are internally "pushed down" into the kernel, which is where it gets executed.
## Set up Jupyter Notebook under test
### Decorator and context manager pattern
These patterns are interchangeable in most cases. If there are nested decorators on your unit test function, consider using the context manager pattern instead.
- Decorator pattern
```{code-block} python
from testbook import testbook
@testbook('/path/to/notebook.ipynb', execute=True)
def test_func(tb):
func = tb.get("func")
assert func(1, 2) == 3
```
- Context manager pattern
```{code-block} python
from testbook import testbook
def test_func():
with testbook('/path/to/notebook.ipynb', execute=True) as tb:
func = tb.get("func")
assert func(1, 2) == 3
```
### Using `execute` to control which cells are executed before test
You may also choose to execute all or some cells:
- Pass `execute=True` to execute the entire notebook before the test. In this case, it might be better to set up a [module scoped pytest fixture](#share-kernel-context-across-multiple-tests).
- Pass `execute=['cell1', 'cell2']` or `execute='cell1'` to only execute the specified cell(s) before the test.
- Pass `execute=slice('start-cell', 'end-cell')` or `execute=range(2, 10)` to execute all cells in the specified range.
## Obtain references to objects present in notebook
### Testing functions in Jupyter Notebook
Consider the following code cell in a Jupyter Notebook:
```{code-block} python
def foo(name):
return f"You passed {name}!"
my_list = ['list', 'from', 'notebook']
```
Reference objects to functions can be called with,
- explicit JSON serializable values (like `dict`, `list`, `int`, `float`, `str`, `bool`, etc)
- other reference objects
```{code-block} python
@testbook.testbook('/path/to/notebook.ipynb', execute=True)
def test_foo(tb):
foo = tb.get("foo")
# passing in explicitly
assert foo(['spam', 'eggs']) == "You passed ['spam', 'eggs']!"
# passing in reference object as arg
my_list = tb.get("my_list")
assert foo(my_list) == "You passed ['list', 'from', 'notebook']!"
```
### Testing function/class returning a non-serializable value
Consider the following code cell in a Jupyter Notebook:
```{code-block} python
class Foo:
def __init__(self):
self.name = name
def say_hello(self):
return f"Hello {self.name}!"
```
When `Foo` is instantiated from the test, the return value will be a reference object which stores a reference to the non-serializable `Foo` object.
```{code-block} python
@testbook.testbook('/path/to/notebook.ipynb', execute=True)
def test_say_hello(tb):
Foo = tb.get("Foo")
bar = Foo("bar")
assert bar.say_hello() == "Hello bar!"
```
## Share kernel context across multiple tests
If your use case requires you to execute many cells (or all cells) of a Jupyter Notebook, before a test can be executed, then it would make sense to share the kernel context with multiple tests.
It can be done by setting up a [module or package scoped pytest fixture][fixture].
Consider the code cells below,
```{code-block} python
def foo(a, b):
return a + b
```
```{code-block} python
def bar(a):
return [x*2 for x in a]
```
The unit tests can be written as follows,
```{code-block} python
import pytest
from testbook import testbook
@pytest.fixture(scope='module')
def tb():
with testbook('/path/to/notebook.ipynb', execute=True) as tb:
yield tb
def test_foo(tb):
foo = tb.get("foo")
assert foo(1, 2) == 3
def test_bar(tb):
bar = tb.get("bar")
tb.inject("""
data = [1, 2, 3]
""")
data = tb.get("data")
assert bar(data) == [2, 4, 6]
```
```{warning}
Note that since the kernel is being shared in case of module scoped fixtures, you might run into weird state issues. Please keep in mind that changes made to an object in one test will reflect in other tests too. This will likely be fixed in future versions of testbook.
```
## Support for patching objects
Use the `patch` and `patch_dict` contextmanager to patch out objects during unit test. Learn more about how to use `patch` [here](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch).
**Example usage of `patch`:**
```{code-block} python
def foo():
bar()
```
```{code-block} python
@testbook('/path/to/notebook.ipynb', execute=True)
def test_method(tb):
with tb.patch('__main__.bar') as mock_bar:
foo = tb.get("foo")
foo()
mock_bar.assert_called_once()
```
**Example usage of `patch_dict`:**
```{code-block} python
my_dict = {'hello': 'world'}
```
```{code-block} python
@testbook('/path/to/notebook.ipynb', execute=True)
def test_my_dict(tb):
with tb.patch('__main__.my_dict', {'hello' : 'new world'}) as mock_my_dict:
my_dict = tb.get("my_dict")
assert my_dict == {'hello' : 'new world'}
```
[fixture]: https://docs.pytest.org/en/stable/fixture.html#scope-sharing-a-fixture-instance-across-tests-in-a-class-module-or-session
|