|
| 1 | +--- |
| 2 | +layout: page |
| 3 | +title: Testing recommendations |
| 4 | +permalink: /principles/testing/ |
| 5 | +nav_order: 2 |
| 6 | +parent: Principles |
| 7 | +--- |
| 8 | + |
| 9 | +{% include toc.html %} |
| 10 | + |
| 11 | +# Testing recommendations |
| 12 | + |
1 | 13 | ## Unit Tests |
2 | 14 |
|
3 | 15 | ### Advantages of unit testing: |
4 | 16 |
|
5 | | -- Unit tests ensure that the code, as written, is correct, and executes |
6 | | - properly. |
7 | | -- They communicate the intention of the creator of the code, how the code is |
8 | | - expected to behave, in its expected use-case. |
9 | | - |
10 | | -- Writing unit tests can reveal weakensses in our implementations, and lead us |
11 | | - to better design decisions: |
12 | | - - If the test requires excessive setup, the unit may be dependent on too many |
13 | | - external variables. |
14 | | - - If the test requires many assertions, the unit may be doing too many things |
15 | | - / have too many side-effects. |
16 | | - - If the unit is very difficult to test, it will likely be difficult to |
17 | | - understand and maintain. Refactoring code to make it easier to test often |
18 | | - leads us to write better code overall. |
| 17 | +Unit tests ensure that the code, as written, is correct, and executes properly. |
| 18 | +they communicate the intention of the creator of the code, how the code is |
| 19 | +expected to behave, in its expected use-case. |
| 20 | + |
| 21 | +Unit tests should be simple, isolated, and run very quickly. Which allows us to |
| 22 | +run them quickly, while we make changes to the code (even automatically, each |
| 23 | +time we save a file for example) to ensure our changes did not break anything... |
| 24 | +or only break what we expected to. |
| 25 | + |
| 26 | +Writing unit tests can reveal weakensses in our implementations, and lead us to |
| 27 | +better design decisions: |
| 28 | + |
| 29 | +- If the test requires excessive setup, the unit may be dependent on too many |
| 30 | + external variables. |
| 31 | +- If the test requires many assertions, the unit may be doing too many things / |
| 32 | + have too many side-effects. |
| 33 | +- If the unit is very difficult to test, it will likely be difficult to |
| 34 | + understand and maintain. Refactoring code to make it easier to test often |
| 35 | + leads us to write better code overall. |
| 36 | + |
| 37 | +### When to write unit tests: |
| 38 | + |
| 39 | +Unit tests are considered "low level", and used for [Isolation Testing](). Not |
| 40 | +all projects need full unit test coverage, some may not need unit tests at all. |
| 41 | + |
| 42 | +- When your project matures enough to justify the work! higher-level testing is |
| 43 | + often sufficient for small projects, which are not part of critical |
| 44 | + infrastructure. |
| 45 | + |
| 46 | +- When you identify a critical part of the code-base, parts that are especially |
| 47 | + prone to breaking, Use unit tests to ensure that code continues to behave as |
| 48 | + designed. |
| 49 | + |
| 50 | +- When other projects start to depend heavily on your library, thorough unit |
| 51 | + testing helps ensure the reliability of your code for your users. |
| 52 | + |
| 53 | +- When doing test-driven development, unit tests should be created after |
| 54 | + higher-level 'integration' or 'outside-in' test cases, before writing the code |
| 55 | + to make the tests pass. |
19 | 56 |
|
20 | 57 | ### Guidelines for unit testing: |
21 | 58 |
|
22 | | -- Unit tests live alongside the code they test, in a /tests folder. |
23 | | - - It looks like `unittest discover` cannot find TestCase classes within source |
24 | | - files. |
| 59 | +- Unit tests live alongside the code they test, in a /tests folder. They should |
| 60 | + be in a different directory than higher-level tests (integration, e2e, |
| 61 | + behavioral, etc.) So that they can be run quickly before the full test suite, |
| 62 | + and to avoid confusing them. |
25 | 63 |
|
26 | | -- Test files should be named `test_{{file under test}}.py`, so that stdlib |
27 | | - unittest can find them easily. |
| 64 | +- Test files should be named `test_{{file under test}}.py`, so that test runners |
| 65 | + can find them easily. |
28 | 66 |
|
29 | 67 | - test\_.py files should match your source files (file-under-test) one-to-one, |
30 | | - and contain only tests for code in the file-file-under test. |
31 | | - |
32 | | -- Consider using the stdlib `unittest.TestCase` and other stdlib tools instead |
33 | | - of pytest. |
34 | | - - Allows running unit tests for diagnostics in production environments, |
35 | | - without installing additional packages. |
| 68 | + and contain only tests for code in the file-file-under test. The code in |
| 69 | + `mymodule/source.py` is tested by `mymodule/tests/test_source.py`. |
36 | 70 |
|
37 | 71 | - Keep it simple! If a test-case requires extra setup and external tools, It may |
38 | 72 | be more appropriate as an external test, instead of in the unit tests |
39 | 73 |
|
40 | | -- Test single units of code! A single Function, or a single attribute or method |
41 | | - on a class. Not the interactions between them. (see mocking and patching) |
| 74 | +- Avoid the temptation to test edge-cases! Focus your unit tests on the |
| 75 | + "happy-path". The UT should describe the expected and officially supported |
| 76 | + usage of the code under test. |
| 77 | + |
| 78 | +- Isolation: Test single units of code! A single Function, or a single attribute |
| 79 | + or method on a class. If you have two units (classes, functions, class |
| 80 | + attributes) with deeply coupled behavior, it is better to test them |
| 81 | + individually, using mocking and patching, instead of testing both in a single |
| 82 | + test. This makes refactoring easier, helps you understand the interactions |
| 83 | + between units, and will correctly tell you which part is failing if one |
| 84 | + breaks. |
42 | 85 |
|
43 | 86 | #### Importing in test files: |
44 | 87 |
|
45 | | -- Use relative imports, from the file under test: |
| 88 | +Keep things local! prefer to import only from the file-under-test when possible. |
| 89 | +This helps keep the context of the unit tests focused on the file-under-test. |
| 90 | + |
| 91 | +It makes refactoring much smoother; think about factoring a class out of a |
| 92 | +source file where many functions operate on it, and tests require it. |
| 93 | + |
| 94 | +```python |
| 95 | +# src/project/lib.py |
| 96 | +class MyClass: ... |
| 97 | + |
| 98 | + |
| 99 | +def func(my_class: MyClass): ... |
| 100 | + |
| 101 | + |
| 102 | +# src/project/tests/test_lib.py |
| 103 | +from project.lib import MyClass, func |
| 104 | + |
| 105 | + |
| 106 | +def test_func(): |
| 107 | + ret = func(MyClass()) |
| 108 | + ... |
| 109 | + |
| 110 | + |
| 111 | +class TestMyClass: ... |
| 112 | +``` |
| 113 | + |
| 114 | +When we move MyClass into another source file, we only need to move its |
| 115 | +TestMyClass unit tests along with it. Even moving MyClass to another module, or |
| 116 | +swapping it for a drop-in replacement, is minimally disruptive to the tests that |
| 117 | +rely on it. |
| 118 | + |
| 119 | +```python |
| 120 | +# src/project/lib.py |
| 121 | +from .util import MyClass |
| 122 | + |
| 123 | + |
| 124 | +def func(my_class: MyClass): ... |
46 | 125 |
|
47 | | - ``` |
48 | | - from ..file_under_test import MyClass |
49 | | - ``` |
50 | 126 |
|
51 | | - - This ensures that the test file always imports from its source file, and |
52 | | - does not accidentally import from an installed module. |
| 127 | +# src/project/tests/test_lib.py |
| 128 | +from project.lib import MyClass, func |
53 | 129 |
|
54 | | -- Only import from the file-under-test, unless absolutely necessary: |
55 | | - - If your source runs `from pandas import DataFrame` and you need a DataFrame |
56 | | - for a test, import it `from ..file_under_test import DataFrame` NOT |
57 | | - `from pandas import DataFrame` in the test file. |
58 | | - - This has some important implications for mocking/patching to elaborate on. |
| 130 | + |
| 131 | +def test_func(): |
| 132 | + ret = func(MyClass()) |
| 133 | + ... |
| 134 | +``` |
| 135 | + |
| 136 | +- Importing from other source files is a code smell (for unit tests), It |
| 137 | + indicates that the test is not well isolated. |
| 138 | + |
| 139 | +It is worth cultivating a deep understanding of how python's imports work. The |
| 140 | +interactions between imports and patches can some times be surprising, and cause |
| 141 | +us to write invalid tests... or worse, tests that pass when they should fail. |
| 142 | +These are a few of the cases that I have seen cause the most confusion. |
| 143 | + |
| 144 | +- If you import `SomeThing` from your file-under-test, Then patch |
| 145 | + `file.under.test.SomeThing`, it does not patch `SomeThing` in your test file. |
| 146 | + Only in the file-under-test. So, code in your file-under-test which calls |
| 147 | + `SomeThing()`, will use the Mock. But in your test case. `SomeThing()` will |
| 148 | + create a new instance, not call the Mock. |
| 149 | + |
| 150 | +- Prefer to import only the object that you actually use, not the entire |
| 151 | + library. |
| 152 | + - This simplifies mocking/patching in unit tests. |
| 153 | + - Makes using drop-in replacements simpler. Changing |
| 154 | + `from pandas import DataFrame` to `from polars import DataFrame` in your |
| 155 | + file-under-test, should result in all tests passing, with no other changes. |
| 156 | + |
| 157 | +It is common practice to import all of pandas or numpy `import numpy as np`, And |
| 158 | +this style is helpful for ensuring that we are using the version of `sum()` we |
| 159 | +expect... was it python's builtin `sum` or `np.sum`? However, as we develop our |
| 160 | +unit tests, this can cause difficulty with mocking, and complicate refactoring. |
| 161 | +consider the benefits of refactoring your imports like so: |
| 162 | + |
| 163 | +```python |
| 164 | +from numpy import sum as numeric_sum, Array as NumericArray |
| 165 | +``` |
59 | 166 |
|
60 | 167 | #### Running unit tests: |
61 | 168 |
|
62 | 169 | - Pytest is great for running tests in your development environments! |
| 170 | +- to run unit tests in your source folder, from your package root, use |
63 | 171 | `pytest {{path/to/source}}` |
64 | | -- stdlib's unittest can be used in environments where pytest is not available: |
65 | | - - To use unittest to run tests in your source folder, from your package root, |
66 | | - use |
67 | | - `python -m unittest discover --start-folder {{source folder}} --top-level-directory .` |
68 | | - - To use unittest to run tests from an installed package (outside of your |
69 | | - source repository), use `python -m unittest discover -s {{module.name}}` |
| 172 | +- To run tests from an installed package (outside of your source repository), |
| 173 | + use `pytest --pyargs {package name}}` |
70 | 174 |
|
71 | 175 | #### Mocking and Patching to Isolate the code under test: |
72 | 176 |
|
73 | | -- When the unit you are testing touches any external unit (usually something you |
74 | | - imported, or another unit that has its own tests), the external unit should be |
75 | | - Patched, replacing it with a Mock for the durration of the test. |
| 177 | +When the unit you are testing touches any external unit (usually something you |
| 178 | +imported, or another unit that has its own tests), the external unit should be |
| 179 | +Patched, replacing it with a Mock for the durration of the test. |
76 | 180 |
|
77 | 181 | - Verify that the external unit is called with the expected input |
78 | 182 | - Verify that any value returned from the external unit is utilized as expected. |
79 | 183 |
|
80 | | - ``` |
81 | | - @patch(f'{SRC}.otherfunction', autospec=true) |
82 | | - def test_myfunction(t, external_unit: Mock): |
83 | | - ret = myfunction() |
84 | | - external_unit.assert_called_with('input from myfunction') |
85 | | - t.assertIs(ret, external_unit.return_value) |
| 184 | +```python |
| 185 | +import pytest |
| 186 | + |
| 187 | +SRC = "path.to.module.under.test" |
| 188 | + |
86 | 189 |
|
87 | | - ``` |
| 190 | +def test_myfunction(mocker): |
| 191 | + patchme: Mock = mocker.patch(f"{SRC}.patchme", autospec=True) |
| 192 | + ret = myfunction() |
| 193 | + patchme.assert_called_with("input from myfunction") |
| 194 | + assert ret is patchme.return_value |
| 195 | +``` |
88 | 196 |
|
89 | 197 | - Consider what needs to be mocked, and the level of isolation your unit test |
90 | 198 | really needs. |
91 | 199 | - Anything imported into the module you are testing should probably be mocked. |
92 | 200 | - external units with side-effects, or which do a lot of computation should |
93 | 201 | probably be mocked. |
94 | 202 | - Some people prefer to never test `_private` attributes. |
| 203 | + |
| 204 | +- Excessive mocking is a code smell! Consider ways to refactor the code, so that |
| 205 | + it needs fewer mocks, less setup, and fewer assertions in a single test case. |
| 206 | + This frequently leads us to write more readable and maintainable code. |
| 207 | + |
| 208 | +## Diagnostic Tests |
| 209 | + |
| 210 | +Diagnostic tests are used to verify the installation of a package. They should |
| 211 | +be runable on production systems, like when we need to ssh into a live server to |
| 212 | +troubleshoot problems. |
| 213 | + |
| 214 | +### Advantages of Diagnostic Tests |
| 215 | + |
| 216 | +- Diagnostic tests allow us to verify an installation of a package. |
| 217 | +- They can be used to verify system-level dependencies like: |
| 218 | + - Compiled binary dependencies |
| 219 | + - Access to specific hardware, like GPUs |
| 220 | + |
| 221 | +### Guidelines for Diagnostic Tests |
| 222 | + |
| 223 | +- Consider using the stdlib `unittest.TestCase` and other stdlib tools instead |
| 224 | + of pytest. |
| 225 | + - Allows running unit tests for diagnostics in production environments, |
| 226 | + without installing additional packages. |
| 227 | + |
| 228 | +- Test files should be named `test_{{file under test}}.py`, so that stdlib |
| 229 | + unittest can find them easily. |
| 230 | + |
| 231 | +### Running Diagnostic Tests: |
| 232 | + |
| 233 | +stdlib's unittest can be used in environments where pytest is not available: |
| 234 | + |
| 235 | +- To use unittest to run tests from an installed package (outside of your source |
| 236 | + repository), use `python -m unittest discover -s {{module.name}}` |
| 237 | +- To use unittest to run tests in your source folder, from your package root, |
| 238 | + use |
| 239 | + `python -m unittest discover --start-folder {{source folder}} --top-level-directory .` |
| 240 | + |
| 241 | +#### Mocking and Patching to Isolate the code under test: |
| 242 | + |
| 243 | +Test Isolation is less necessary in diagnostic tests than unit tests. We often |
| 244 | +want diagnostic tests to execute compiled code, or run a test on GPU hardware. |
| 245 | +In cases where we do need to mock some part of our code, `unittest.mock.patch` |
| 246 | +is similar to the pytest mocker module. |
| 247 | + |
| 248 | +```python |
| 249 | +from unittest.mock import patch, Mock |
| 250 | + |
| 251 | +SRC = "mymodule.path.to.source" |
| 252 | + |
| 253 | + |
| 254 | +@patch(f"{SRC}.patchme", autospec=true) |
| 255 | +def test_myfunction(t, patchme: Mock): |
| 256 | + ret = myfunction() |
| 257 | + patchme.assert_called_with("input from myfunction") |
| 258 | + t.assertIs(ret, patchme.return_value) |
| 259 | +``` |
0 commit comments