I think it is an uncontroversial statement to say testing is important in software development. Writing tests may not always be fun, but nothing is a sweet as that moment when a unit test catches a bug before you deploy.
In OpenFaaS we have tons of tests in each project, even the certifier
itself runs a short suite of end-to-end tests. But, not all of our function templates have first class testing support. For Go, Node, and Java, this has already been added. For Go it is easy because there is a standard test command go test
. For Node and Java we (or the community) have picked and optional default test command. Soon, we are going to do the same for Python.
But what do you do in the mean time? Or, what if you have your own Python template and you want to add testing to it? In this post I am going to show you how to use tox
and pytest
with any Python OpenFaaS template.
Intro
For this demo we are going to create a tiny calculator function. This gives us just enough code to demonstrate interesting but very common test cases:
- is the request correct, i.e did the request specify a valid math operation and two numbers,
- is the response correct, i.e. the calculation.
Almost all functions will want these kind of tests and, as we will see, they are easy to write.
If you want to skip to the final result, checkout the sample repo here: https://github.com/LucasRoesler/pytest-openfaas-sample
Start the project
$ mkdir pytest-sample
$ cd pytest-sanmple
$ faas-cli template store pull python3-flask
$ mv calc.yml stack.yml
Note Don’t forget to add this section to your stack file so that the faas-cli
will know how to automatically pull the template
configuration:
templates:
- name: python3-flask
source: https://github.com/openfaas/python-flask-template
Setup the local python environment
I use conda for my local virtual environments, but you can of course use virtualenv
or venv
(see also RealPython’s primer on virtual environments).
This creates an isolated and repeatable development environment which you can delete and recreate if you need.
$ conda create -n pytest-sample tox
$ conda activate pytest-sample
$ cat <<EOF >> requirements.txt
pydantic==1.7.3
flask==1.1.2
EOF
$ cat <<EOF >> dev.txt
tox
pytest
black
pylint
EOF
$ conda install --yes --file requirements.txt
$ conda install --yes --file dev.txt
Now we are ready to develop the calculator.
Add the first tests
Let’s start with some simple tests:
$ touch calc/handler_test.py
then add the following test cases for a couple of our happy paths
import calc.handler as h
class TestParsing:
def test_operation_addition(self):
req = '{"op": "+", "var1": "1.0", "var2": 0}'
resp, code = h.handle(req)
assert code == 200
assert resp["value"] == 1.0
def test_operation_multiplication(self):
req = '{"op": "*", "var1": "100.01", "var2": 1}'
resp, code = h.handle(req)
assert code == 200
assert resp["value"] == 100.01
At this point, we have followed normal conventions for pytest, by default pytest will look for files that match the pattern *_test.py
and then the run the functions that match test_*
(including matching methods if the class is named Test*
, which is what we did above).
Now, we can run our test and see all of the errors in their wonderful glory.
$ pytest
========================================= test session starts =========================================
platform linux -- Python 3.8.8, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /home/lucas/code/openfaas/sandbox/pytest-sample
collected 2 items
calc/handler_test.py FF [100%]
============================================== FAILURES ===============================================
_________________________________ TestParsing.test_operation_addition _________________________________
self = <calc.handler_test.TestParsing object at 0x7fc8029ad820>
def test_operation_addition(self):
req = '{"op": "+", "var1": "1.0", "var2": 0}'
> resp, code = h.handle(req)
E ValueError: too many values to unpack (expected 2)
calc/handler_test.py:49: ValueError
______________________________ TestParsing.test_operation_multiplication ______________________________
self = <calc.handler_test.TestParsing object at 0x7fc8029add30>
def test_operation_multiplication(self):
req = '{"op": "^", "var1": "2", "var2": -2}'
> resp, code = h.handle(req)
E ValueError: too many values to unpack (expected 2)
calc/handler_test.py:73: ValueError
======================================= short test summary info =======================================
FAILED calc/handler_test.py::TestParsing::test_operation_addition - ValueError: too many values to u...
FAILED calc/handler_test.py::TestParsing::test_operation_multiplication - ValueError: too many value...
========================================== 2 failed in 0.05s ==========================================
In this case we get a value error because our handler function only returns a string, not a dictionary and a status code like the test expects. These test cases expect that the handler to return something that looks like this
return {"value": 1.1}, 200
This return value works with Flask; it will json serialize the dictionary and set the status code to 200 for us. Once we update the function to look like the above snippet and run pytest again, then we get a new error, a value error because we got the wrong calculation result
$ pytest
========================================= test session starts =========================================
platform linux -- Python 3.8.8, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /home/lucas/code/openfaas/sandbox/pytest-sample
collected 2 items
calc/handler_test.py .F [100%]
============================================== FAILURES ===============================================
______________________________ TestParsing.test_operation_multiplication ______________________________
self = <calc.handler_test.TestParsing object at 0x7f9f30ecbd30>
def test_operation_multiplication(self):
req = '{"op": "^", "var1": "2", "var2": -2}'
resp, code = h.handle(req)
assert code == 200
> assert resp["value"] == 0.25
E assert 1 == 0.25
calc/handler_test.py:75: AssertionError
======================================= short test summary info =======================================
FAILED calc/handler_test.py::TestParsing::test_operation_multiplication - assert 1 == 0.25
===================================== 1 failed, 1 passed in 0.06s =====================================
Adding tox
Before we fix the test, we want to setup one more thing: tox
: tox
provides a unified set of tooling that will run pytest
for us. This provides a standardized interface that we will use later in CI. In fact, it ensures we are running exactly the same thing in CI and our local dev environment, hopefully reducing the number of “Actually fix CI” commit messages. Later, if you decide that you prefer nose
or some other testing tool or if you application is even more complex and requires pre-test configuration – we can manage that through tox
.
Create a calc/tox.ini
file that looks like
# calc/tox.ini
[tox]
# list the testenv to run by default
envlist = lint,test
skipsdist = true
[testenv:test]
deps =
pytest
-rrequirements.txt
commands =
# run unit tests
pytest
[testenv:lint]
deps =
flake8
commands =
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --extend-exclude template
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics --extend-exclude template
This configures tox
to create two environments and run our tests and flake8. Now we can test and lint our calc
function using
cd calc
tox
or just run the tests using
$ cd calc
$ tox -q -e test
======================================== test session starts ========================================
platform linux -- Python 3.8.8, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
cachedir: .tox/test/.pytest_cache
rootdir: /home/lucas/code/openfaas/sandbox/pytest-sample/calc
collected 2 items
handler_test.py FF [100%]
============================================= FAILURES ==============================================
________________________________ TestParsing.test_operation_addition ________________________________
self = <calc.handler_test.TestParsing object at 0x7ff1aa19a550>
def test_operation_addition(self):
req = '{"op": "+", "var1": "1.0", "var2": 0}'
> resp, code = h.handle(req)
E ValueError: too many values to unpack (expected 2)
handler_test.py:6: ValueError
_____________________________ TestParsing.test_operation_multiplication _____________________________
self = <calc.handler_test.TestParsing object at 0x7ff1aa19afd0>
def test_operation_multiplication(self):
req = '{"op": "*", "var1": "100.01", "var2": 1}'
> resp, code = h.handle(req)
E ValueError: too many values to unpack (expected 2)
handler_test.py:12: ValueError
====================================== short test summary info ======================================
FAILED handler_test.py::TestParsing::test_operation_addition - ValueError: too many values to unpa...
FAILED handler_test.py::TestParsing::test_operation_multiplication - ValueError: too many values t...
========================================= 2 failed in 0.04s =========================================
ERROR: InvocationError for command /home/lucas/code/openfaas/sandbox/pytest-sample/calc/.tox/test/bin/pytest (exited with code 1)
______________________________________________ summary ______________________________________________
ERROR: test: commands failed
The working implementation
Jumping forward a bit, here is the final implementation. I have used the enum
package and pydantic
to help simplify the validation and parsing of requests into my internal Calculation
model
from pydantic import BaseModel, ValidationError
from enum import Enum, unique
from typing import Callable
@unique
class OperationType(Enum):
ADD = "+"
SUBTRACT = "-"
MULTIPLY = "*"
DIVIDE = "/"
POWER = "^"
class Caclucation(BaseModel):
op: OperationType
var1: float
var2: float
def execute(self) -> float:
if self.op is OperationType.ADD:
return self.var1 + self.var2
if self.op is OperationType.SUBTRACT:
return self.var1 - self.var2
if self.op is OperationType.MULTIPLY:
return self.var1 * self.var2
if self.op is OperationType.DIVIDE:
return self.var1 / self.var2
if self.op is OperationType.POWER:
return self.var1 ** self.var2
raise ValueError("unknown operation")
def handle(req) -> (dict, int):
"""handle a request to the function
Args:
req (str): request body
"""
try:
c = Caclucation.parse_raw(req)
except ValidationError as e:
return {"message": e.errors()}, 422
except Exception as e:
return {"message": e}, 500
return {"value": c.execute()}, 200
Now our tests will pass
$ tox -q -e test
======================================== test session starts ========================================
platform linux -- Python 3.8.8, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
cachedir: .tox/test/.pytest_cache
rootdir: /home/lucas/code/openfaas/sandbox/pytest-sample/calc
collected 2 items
calc/handler_test.py .. [100%]
========================================== 2 passed in 0.04s ==========================================
Testing error cases
We can even extend our test cases to cover validation errors. Because we using pydantic
and we are returning {"message": e.errors()}
, these tests look like this
def test_operation_parsing_error_on_empty_obj(self):
req = '{}'
resp, code = h.handle(req)
assert code == 422
# should be a list of error
errors = resp.get("message", [])
assert len(errors) == 3
assert errors[0].get("loc") == ('op', )
assert errors[0].get("msg") == "field required"
assert errors[1].get("loc") == ('var1', )
assert errors[1].get("msg") == "field required"
assert errors[2].get("loc") == ('var2', )
assert errors[2].get("msg") == "field required"
def test_operation_parsing_error_on_unknown_operation(self):
req = '{"op": "foo", "var1": "1.0", "var2": 0}'
resp, code = h.handle(req)
assert code == 422
# should be a list of error
errors = resp.get("message", [])
assert len(errors) == 1
assert errors[0].get("loc") == ('op', )
assert errors[0].get("msg") == (
"value is not a valid enumeration member; permitted: "
"'+', '-', '*', '/', '^'"
)
Checkout the repo for the other example tests https://github.com/LucasRoesler/pytest-openfaas-sample/blob/main/calc/handler_test.py
Running this in CI
I’ve added a Github Action Workflow to my sample repo. It is based on the sample testing workflow that Github provides for Python and the function workflow from Serverless for Everyone
This workflow will test and build your function in parallel. The resulting Docker image is pushed to Docker Container Registry.
NOTE you need to configure the DOCKER_PASSWORD
secret for your Github repo for this workflow to work correctly.
Next steps
This is just a tiny step to more stable functions. Over the next couple weeks we are going to look into how to build the tox
runner into the Python functions. Which means, you can just add a tox.ini
to each function and faas-cli
will run any tests you can configured and then build the function. If you have more examples of interesting tox
configurations, let me know on Twitter or stop by the OpenFaas Slack and share it with the community.