How to built-in tests into a source file
There are many cases when you have no luxury of having a full-scale Python project. All you allowed to is to have a self-contained script consisting of a single file. No setup.py
, no tox.ini
, nothing.
Sometimes those files are embedded into other projects and a cost of creating a full-fledged delivery pipeline is too high. Or it can be a minor Nagios check you don’t want to bother to package and maintain.
Even in a script mode, tests are great. No! They are necessity. Period.
But where one can put them? As I said, there is a restriction: ‘a single self-contained file’. That means, you need to put tests into this exact file you want to test.
And, indeed, a documentation for py.test
provides many examples of tests been placed right after function they test. Those example, though, are …em… non-production grade. They import pytest
, mock
, and many other modules you may want to avoid importing in a production environment.
So, how can you place tests into a source code without causing any disturbances for your script?
Structure of a script
Most scripts are created without an entry points mechanism, therefore, they usually have this boilerplate at the bottom:
if __name__ == "__main__":
main()
Following stanzas are executed at run time by Python interpreter:
- import statements
- function decorators with arguments
- function/class definitions (*)
- any code outside of functions/classes (at top-level).
* Note: A function’s definition IS a statement for execution. It creates new object with function’s name and it’s code inside. It does not run the function itself.
Structure of a test
Pytest supports many types of tests, but I’ll focus only on ‘function-type tests’, where each test is a function with name started with test
.
Pytest care about following things in file with tests:
- It imports file with tests. That means, all lines from the ‘structure of a script’ chapter are executed. ‘
name=='__main__’
’ is false, therefore, main is not executed. - It tries to run
setup_module()
function if it’s present. - It scans file for classes and functions started with
Test
and execute them one by one.
Contradiction
- we don’t want to import pytest or mock or any other test-related modules at the ‘normal execution time’
- we need to import them at the test time
Solution
I developed a following pattern for ‘build-in’ tests.
# normal script code here
# ...if __name__ == "__main__":
main()
# --- tests ---
def setup_module():
global pytest
global mock
import pytest
import mockdef test_foo():
with mock.patch(...):
with pytest.raises(...):
main()
I use setup_module
function to inject pytest
and mock
modules into the global namespace. But this code will be executed only in pytest mode and will be ignored by a normal script execution.
To run tests for a script with a name ‘myscript.py
’ just execute pytest:
pytest myscript.py
This trick has one limitation: One couldn’t use any of nice pytest decorators, including @pytest.mark.parametrize
, which is really sad. Those decorators are executed at function declaration time, and that happens every time script runs. Therefore, no @pytest.decorators
or @mock.patch
at all.
Outside of that limitation, resulting code can be tested as usual.