Skip to content

Commit 4a19427

Browse files
committed
update testing.md content
1 parent 0799f53 commit 4a19427

File tree

1 file changed

+219
-54
lines changed

1 file changed

+219
-54
lines changed

docs/pages/principles/testing.md

Lines changed: 219 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,259 @@
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+
113
## Unit Tests
214

315
### Advantages of unit testing:
416

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.
1956

2057
### Guidelines for unit testing:
2158

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.
2563

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.
2866

2967
- 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`.
3670

3771
- Keep it simple! If a test-case requires extra setup and external tools, It may
3872
be more appropriate as an external test, instead of in the unit tests
3973

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.
4285

4386
#### Importing in test files:
4487

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): ...
46125

47-
```
48-
from ..file_under_test import MyClass
49-
```
50126

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
53129

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+
```
59166

60167
#### Running unit tests:
61168

62169
- Pytest is great for running tests in your development environments!
170+
- to run unit tests in your source folder, from your package root, use
63171
`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}}`
70174

71175
#### Mocking and Patching to Isolate the code under test:
72176

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.
76180

77181
- Verify that the external unit is called with the expected input
78182
- Verify that any value returned from the external unit is utilized as expected.
79183

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+
86189

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+
```
88196

89197
- Consider what needs to be mocked, and the level of isolation your unit test
90198
really needs.
91199
- Anything imported into the module you are testing should probably be mocked.
92200
- external units with side-effects, or which do a lot of computation should
93201
probably be mocked.
94202
- 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

Comments
 (0)