|
| 1 | +# Why Use Decoy? |
| 2 | + |
| 3 | +The Python testing world already has [unittest.mock][] for creating fakes, so why is a library like Decoy even necessary? |
| 4 | + |
| 5 | +The `MagicMock` class (and friends) provided by the Python standard library are great, which is why Decoy uses them under the hood. They are, however: |
| 6 | + |
| 7 | +- Not very opinionated in how they are used |
| 8 | +- Not able to adhere to type annotations of your actual interfaces |
| 9 | + |
| 10 | +At its core, Decoy wraps `MagicMock` with a more opinionated, strictly typed interface to encourage well written tests and, ultimately, well written source code. |
| 11 | + |
| 12 | +[unittest.mock]: https://docs.python.org/3/library/unittest.mock.html |
| 13 | + |
| 14 | +## Recommended Reading |
| 15 | + |
| 16 | +Decoy is heavily influenced by and/or stolen from the [testdouble.js][] and [Mockito][] projects. These projects have both been around for a while (especially Mockito), and their docs are valuable resources for learning how to test and mock effectively. |
| 17 | + |
| 18 | +If you have the time, you should check out: |
| 19 | + |
| 20 | +- Mockito Wiki - [How to write good tests](https://github.com/mockito/mockito/wiki/How-to-write-good-tests) |
| 21 | +- Test Double Wiki - [Discovery Testing](https://github.com/testdouble/contributing-tests/wiki/Discovery-Testing) |
| 22 | +- Test Double Wiki - [Test Double](https://github.com/testdouble/contributing-tests/wiki/Test-Double) |
| 23 | + |
| 24 | +[testdouble.js]: https://github.com/testdouble/testdouble.js |
| 25 | +[mockito]: https://site.mockito.org/ |
| 26 | + |
| 27 | +## Creating and Using a Stub |
| 28 | + |
| 29 | +A [stub][] is a specific type of test fake that: |
| 30 | + |
| 31 | +- Can be configured to respond in a certain way if given certain inputs |
| 32 | +- Will no-op if given output outside of its pre-configured specifications |
| 33 | + |
| 34 | +Stubs are great for simulating dependencies that provide data to or run calculations on input data for the code under test. |
| 35 | + |
| 36 | +For the following examples, let's assume: |
| 37 | + |
| 38 | +- We're testing a library to deal with `Book` objects |
| 39 | +- That library depends on a `Database` provider to store objects in a database |
| 40 | +- That library depends on a `Logger` interface to log access |
| 41 | + |
| 42 | +[stub]: https://en.wikipedia.org/wiki/Test_stub |
| 43 | + |
| 44 | +### Stubbing with a MagicMock |
| 45 | + |
| 46 | +```python |
| 47 | +# setup |
| 48 | +from pytest |
| 49 | +from unittest.mock import MagicMock |
| 50 | +from typing import cast |
| 51 | + |
| 52 | +from my_lib.database import Database |
| 53 | +from my_lib.bookshelf import get_book, Book |
| 54 | + |
| 55 | + |
| 56 | +@pytest.fixture |
| 57 | +def mock_database() -> MagicMock: |
| 58 | + return MagicMock(spec=Database) |
| 59 | + |
| 60 | +@pytest.fixture |
| 61 | +def mock_book() -> Book: |
| 62 | + return cast(Book, {"title": "The Metamorphosis"}) |
| 63 | +``` |
| 64 | + |
| 65 | +`MagicMock` does not provide an explicit stubbing interface, so to stub you could: |
| 66 | + |
| 67 | +```python |
| 68 | +# option 1: |
| 69 | +# - return mock data unconditionally |
| 70 | +# - assert dependency was called correctly after the fact |
| 71 | + |
| 72 | +def test_get_book(mock_database: MagicMock, mock_book: Book) -> None: |
| 73 | + # arrange mock to always return mock data |
| 74 | + mock_database.get_by_id.return_value = mock_book |
| 75 | + |
| 76 | + # exercise the code under test |
| 77 | + result = get_book("unique-id", database=mock_database) |
| 78 | + |
| 79 | + # assert that the result is correct |
| 80 | + assert result == mock_book |
| 81 | + # also assert that the database mock was called correctly |
| 82 | + mock_database.get_by_id.assert_called_with("unique-id) |
| 83 | +``` |
| 84 | + |
| 85 | +```python |
| 86 | +# option 2: create a side-effect function to check the conditions |
| 87 | +def test_get_book(mock_database: MagicMock, mock_book: Book) -> None: |
| 88 | + def stub_get_by_id(uid: str) -> Optional[Book]: |
| 89 | + if uid == "unique-id": |
| 90 | + return mock_book |
| 91 | + else: |
| 92 | + return None |
| 93 | + |
| 94 | + # arrange mock to always return mock data |
| 95 | + mock_database.get_by_id.side_effect = stub_get_by_id |
| 96 | + |
| 97 | + # exercise the code under test |
| 98 | + result = get_book("unique-id", database=mock_database) |
| 99 | + |
| 100 | + # assert that the result is correct |
| 101 | + # because of the `if` in the side-effect, we know the stub was called |
| 102 | + # correctly because there's no other way the code-under-test could have |
| 103 | + # gotten the mock_book data |
| 104 | + assert result == mock_book |
| 105 | +``` |
| 106 | + |
| 107 | +Both of these options have roughly the same upside and downsides: |
| 108 | + |
| 109 | +- Upside: `MagicMock` is part of the Python standard library |
| 110 | +- Downside: they're both a little difficult to read |
| 111 | + - Option 1 separates the input checking from output value, and they appear in reverse chronological order (you define the dependency output before you define the input) |
| 112 | + - Option 2 forces you to create a whole new function and assign it to the `side_effect` value |
| 113 | +- Downside: `MagicMock` is effectively `Any` typed |
| 114 | + - `return_value` and `assert_called_with` are not typed according to the dependency's type definition |
| 115 | + - A manual `side_effect`, if typed, needs to be manually typed, which may not match the actual dependency type definition |
| 116 | + |
| 117 | +Option 1 has another downside, specifically: |
| 118 | + |
| 119 | +- The mocked return value is unconditional |
| 120 | + - If the assert step is wrong or accidentally skipped, the code-under-test is still fed the mock data |
| 121 | + - This increases the likelihood of a false pass or insufficient test coverage |
| 122 | + |
| 123 | +### Stubbing with Decoy |
| 124 | + |
| 125 | +```python |
| 126 | +# setup |
| 127 | +from pytest |
| 128 | +from decoy import Decoy |
| 129 | + |
| 130 | +from my_lib.database import Database |
| 131 | +from my_lib.bookshelf import get_book, Book |
| 132 | + |
| 133 | +@pytest.fixture |
| 134 | +def decoy() -> Decoy: |
| 135 | + return Decoy() |
| 136 | + |
| 137 | +@pytest.fixture |
| 138 | +def mock_database(decoy: Decoy) -> Database: |
| 139 | + return decoy.create_decoy(spec=Database) |
| 140 | + |
| 141 | +@pytest.fixture |
| 142 | +def mock_book() -> Book: |
| 143 | + return cast(Book, {"title": "The Metamorphosis"}) |
| 144 | +``` |
| 145 | + |
| 146 | +Decoy wraps `MagicMock` with a simple, rehearsal-based stubbing interface. |
| 147 | + |
| 148 | +```python |
| 149 | +def test_get_book(decoy: Decoy, mock_database: Database, mock_book: Book) -> None: |
| 150 | + # arrange stub to return mock data when called correctly |
| 151 | + decoy.when( |
| 152 | + # this is a rehearsal, which might look a little funny at first |
| 153 | + # it's an actual call to the test double that Decoy captures |
| 154 | + mock_database.get_by_id("unique-id") |
| 155 | + ).then_return(mock_book) |
| 156 | + |
| 157 | + # exercise the code under test |
| 158 | + result = get_book("unique-id", database=mock_database) |
| 159 | + |
| 160 | + # assert that the result is correct |
| 161 | + assert result == mock_book |
| 162 | +``` |
| 163 | + |
| 164 | +Benefits to note over the vanilla `MagicMock` versions: |
| 165 | + |
| 166 | +- The rehearsal syntax for stub configuration is terse but very easy to read |
| 167 | + - Inputs and outputs from the dependency are specified together |
| 168 | + - You specify the inputs _before_ outputs, which can be easier to grok |
| 169 | +- The entire test fits neatly into "arrange", "act", and "assert" phases |
| 170 | +- Decoy casts test doubles as the actual types they are mimicking |
| 171 | + - This means stub configuration arguments _and_ return values are type-checked |
| 172 | + |
| 173 | +## Creating and Using a Spy |
| 174 | + |
| 175 | +A [spy][] is another kind of test fake that simply records calls made to it. Spies are useful to model dependencies that are used for their side-effects rather than providing or calculating data. |
| 176 | + |
| 177 | +[spy]: https://github.com/testdouble/contributing-tests/wiki/Spy |
| 178 | + |
| 179 | +### Spying with a MagicMock |
| 180 | + |
| 181 | +```python |
| 182 | +# setup |
| 183 | +from pytest |
| 184 | +from decoy import Decoy |
| 185 | + |
| 186 | +from my_lib.logger import Logger |
| 187 | +from my_lib.bookshelf import get_book, Book |
| 188 | + |
| 189 | + |
| 190 | +@pytest.fixture |
| 191 | +def decoy() -> Decoy: |
| 192 | + return Decoy() |
| 193 | + |
| 194 | + |
| 195 | +@pytest.fixture |
| 196 | +def mock_logger() -> MagicMock: |
| 197 | + return MagicMock(spec=Logger) |
| 198 | +``` |
| 199 | + |
| 200 | +`MagicMock` is well suited to spying, since it's an object that records all calls made to it. |
| 201 | + |
| 202 | +```python |
| 203 | +def test_get_book_logs(mock_logger: MagicMock) -> None: |
| 204 | + # exercise the code under test |
| 205 | + get_book("unique-id", logger=mock_logger) |
| 206 | + |
| 207 | + # assert logger spy was called correctly |
| 208 | + mock_logger.log.assert_called_with(level="debug", msg="Get book unique-id") |
| 209 | +``` |
| 210 | + |
| 211 | +The only real downside to `MagicMock` in this case is the lack of typechecking. |
| 212 | + |
| 213 | +### Spying with Decoy |
| 214 | + |
| 215 | +```python |
| 216 | +# setup |
| 217 | +from pytest |
| 218 | +from decoy import Decoy |
| 219 | + |
| 220 | +from my_lib.logger import Logger |
| 221 | +from my_lib.bookshelf import get_book, Book |
| 222 | + |
| 223 | + |
| 224 | +@pytest.fixture |
| 225 | +def decoy() -> Decoy: |
| 226 | + return Decoy() |
| 227 | + |
| 228 | + |
| 229 | +@pytest.fixture |
| 230 | +def mock_logger(decoy: Decoy) -> Logger: |
| 231 | + return decoy.create_decoy(spec=Logger) |
| 232 | +``` |
| 233 | + |
| 234 | +For verification of spies, Decoy doesn't do much except set out to add typechecking. |
| 235 | + |
| 236 | +```python |
| 237 | +def test_get_book_logs(decoy: Decoy, mock_logger: Logger) -> None: |
| 238 | + # exercise the code under test |
| 239 | + get_book("unique-id", logger=mock_logger) |
| 240 | + |
| 241 | + # assert logger spy was called correctly |
| 242 | + # uses the same type-checked "rehearsal" syntax as stubbing |
| 243 | + decoy.verify( |
| 244 | + mock_logger(level="debug", msg="Get book unique-id") |
| 245 | + ) |
| 246 | +``` |
0 commit comments