Skip to content

Commit c342826

Browse files
LucasRoesleralexellis
authored andcommitted
Add walkthrough of testing with pytest
Add a blog post that provides a walkthrough of using pytest with the python3-flask template. Alex: Edits for publications including date, intro and fixing some typos. Signed-off-by: Lucas Roesler <[email protected]> Signed-off-by: Alex Ellis (OpenFaaS Ltd) <[email protected]>
1 parent 5bd6962 commit c342826

File tree

2 files changed

+353
-0
lines changed

2 files changed

+353
-0
lines changed
+353
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
---
2+
title: "Learn how to unit-test your OpenFaaS Functions using Pytest"
3+
description: "Lucas will show you how to test OpenFaaS functions written in Python with the Pytest framework"
4+
5+
date: 2021-06-15
6+
image: /images/2021-06-pytesting/conor-samuel-K5BFXOsFp7g-unsplash.jpg
7+
categories:
8+
- python
9+
- flask
10+
- testing
11+
- pytest
12+
- usecase
13+
author_staff_member: lucas
14+
dark_background: false
15+
---
16+
17+
Lucas will show you how to test OpenFaaS functions written in Python with the Pytest framework.
18+
19+
In a recent [Pull Request](https://github.com/openfaas/python-flask-template/pull/42), we integrated unit-testing to the build process of the [OpenFaaS Python Flask templates](https://github.com/openfaas/python-flask-template). Since there are multiple test frameworks including [pytest](https://docs.pytest.org/en/6.2.x/), we opted to integrate with a test runner called [tox](https://tox.readthedocs.io/), so that you can pick whichever unit test framework you prefer.
20+
21+
The upshot is that you can focus on implementing your functions _and_ tests and then let OpenFaaS handle the rest. The `faas-cli up` command can then be run locally or in a CI/CD environment to build, test, and deploy your Python functions with a single.
22+
23+
In blog post, I'll show you how to take advantage of unit tests written with Pytest in your OpenFaaS workflow.
24+
25+
## Introduction
26+
27+
In this post we are going to build a _very small_ calculator function and then write a few tests that show how we can ensure our calculator works _before_ we deploy it. We will show you how to run the tests locally during development and then show how this is integrated into the OpenFaaS build flow so that you you can run the tests automatically in your CI/CD flows with a single command.
28+
29+
### Setup the project
30+
31+
All of the code in this example can be found on [Github](https://github.com/LucasRoesler/pytest-openfaas-sample), if you are already familiar with the python3-flask template and pytest, then you can jump ahead and see what the final implementation looks like.
32+
33+
Fetch the template from the store:
34+
35+
```sh
36+
$ mkdir pytest-sample
37+
$ cd pytest-sample
38+
$ faas-cli template store pull python3-flask
39+
```
40+
41+
Create a new function called `calc` and then rename its YAML file to the default `stack.yml`, to avoid needing the `-f` flag later on.
42+
43+
```sh
44+
$ faas-cli new --lang python3-flask calc
45+
$ mv calc.yml stack.yml
46+
```
47+
48+
Since the templates are never committed to Git, each time someone clones the repository, they would need to run `faas-cli template store pull`. Fortunately, there is a work-around which adds the template name and source to the `stack.yml` file to automate this task.
49+
50+
```yaml
51+
configuration:
52+
templates:
53+
- name: python3-flask
54+
source: https://github.com/openfaas/python-flask-template
55+
```
56+
57+
### Setup the local python environment
58+
59+
It's possible to install dependencies like `flask`, `tox` and `pytest` directly to your system using `pip3 install`, however Python practitioners will often use a virtual environment so that each project or function can have a different version of dependencies.
60+
61+
> See also: [RealPython's primer on virtual environments](https://realpython.com/python-virtual-environments-a-primer/)
62+
63+
I use [conda](https://docs.conda.io/projects/conda/en/latest/) for my local virtual environments, but you can of course use [`virtualenv`](https://virtualenv.readthedocs.org/en/latest/) or [`venv`](https://docs.python.org/3/library/venv.html).
64+
65+
In short this creates an isolated and repeatable development environment which you can delete and recreate if you need.
66+
67+
```sh
68+
$ conda create -n pytest-sample tox
69+
$ conda activate pytest-sample
70+
$ cat <<EOF >> requirements.txt
71+
pydantic==1.7.3
72+
flask==1.1.2
73+
EOF
74+
75+
$ cat <<EOF >> dev.txt
76+
tox
77+
pytest
78+
black
79+
pylint
80+
EOF
81+
$ conda install --yes --file requirements.txt
82+
$ conda install --yes --file dev.txt
83+
```
84+
85+
Now we are ready to develop the calculator.
86+
87+
### The calculator implementation
88+
89+
The calculator will be a simple web endpoint that accepts payloads like the following:
90+
91+
```json
92+
{
93+
"op":"+",
94+
"var1":"1",
95+
"var2":"2"
96+
}
97+
```
98+
99+
This is the output you can expect:
100+
101+
```json
102+
{
103+
"value":"3"
104+
}
105+
```
106+
107+
There are several ways that this request could fail. To help validate the request (and to give us more a few more things to test) we can use [`pydantic`](https://pydantic-docs.helpmanual.io/) to do the hard validation work and keep our function lean.
108+
109+
Update your `requirements.txt` to include
110+
111+
```
112+
pydantic==1.7.3
113+
```
114+
115+
Put this into your `handler.py`:
116+
117+
```py
118+
from pydantic import BaseModel, ValidationError
119+
from enum import Enum, unique
120+
121+
122+
@unique
123+
class OperationType(Enum):
124+
ADD = "+"
125+
SUBTRACT = "-"
126+
MULTIPLY = "*"
127+
DIVIDE = "/"
128+
POWER = "^"
129+
130+
131+
class Calculation(BaseModel):
132+
op: OperationType
133+
var1: float
134+
var2: float
135+
136+
def execute(self) -> float:
137+
if self.op is OperationType.ADD:
138+
return self.var1 + self.var2
139+
140+
if self.op is OperationType.MULTIPLY:
141+
return self.var1 * self.var2
142+
143+
raise ValueError("unknown operation")
144+
145+
146+
def handle(req) -> (dict, int):
147+
"""handle a request to the function
148+
Args:
149+
req (str): request body
150+
"""
151+
152+
try:
153+
c = Calculation.parse_raw(req)
154+
except ValidationError as e:
155+
return {"message": e.errors()}, 422
156+
except Exception as e:
157+
return {"message": e}, 500
158+
159+
return {"value": c.execute()}, 200
160+
```
161+
162+
At this point we could deploy the function and use it
163+
164+
```sh
165+
$ faas-cli deploy
166+
Deploying: calc.
167+
168+
Deployed. 202 Accepted.
169+
URL: http://127.0.0.1:8080/function/calc
170+
$ echo '{"op":"+", "var1": 1, "var2": 1}' | faas-cli invoke calc
171+
{"value":2.0}
172+
```
173+
174+
We can see the nice work `pydantic` does for us by sending an empty `{}` payload
175+
176+
```sh
177+
$ echo '{}' | faas-cli invoke calc
178+
Server returned unexpected status code: 422 - {"message":[{"loc":["op"],"msg":"field required","type":"value_error.missing"},{"loc":["var1"],"msg":"field required","type":"value_error.missing"},{"loc":["var2"],"msg":"field required","type":"value_error.missing"}]}
179+
```
180+
181+
## Adding tests
182+
[`pytest`](https://docs.pytest.org/en/6.2.x/) is a popular testing framework that provides automated test discovery and detailed info on failing assert statements, among [other features](https://docs.pytest.org/en/6.2.x/#id1). We will setup our project so that `pytest` works out of the box, this means we will name the test files `*_test.py` and prefix out test functions with `test_`.
183+
184+
Create a new file in your function
185+
186+
```sh
187+
$ touch calc/handler_test.py
188+
```
189+
then add the following test cases for a couple of our happy paths
190+
191+
```py
192+
from . import handler as h
193+
194+
class TestParsing:
195+
def test_operation_addition(self):
196+
req = '{"op": "+", "var1": "1.0", "var2": 0}'
197+
resp, code = h.handle(req)
198+
assert code == 200
199+
assert resp["value"] == 1.0
200+
201+
def test_operation_multiplication(self):
202+
req = '{"op": "*", "var1": "100.01", "var2": 1}'
203+
resp, code = h.handle(req)
204+
assert code == 200
205+
assert resp["value"] == 100.01
206+
```
207+
208+
Note that we import then `handler` from `.`, we don't use an absolute import like `from calc import handler as h`. This is required to be compatible with the the OpenFaaS build process.
209+
210+
Now, change into the function directory and run the tests using `pytest`
211+
212+
```sh
213+
$ pytest
214+
==================== test session starts =====================
215+
platform linux -- Python 3.8.8, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
216+
rootdir: pytest-sample/calc
217+
collected 2 items
218+
219+
handler_test.py .. [100%]
220+
221+
===================== 2 passed in 0.03s ======================
222+
```
223+
224+
If you want to see an error, just change the assertion in one of the tests to a "wrong" value and run pytest again:
225+
226+
```sh
227+
pytest
228+
==================== test session starts =====================
229+
platform linux -- Python 3.8.8, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
230+
rootdir: /home/lucas/code/openfaas/sandbox/pytest-sample/calc
231+
collected 2 items
232+
233+
handler_test.py F. [100%]
234+
235+
========================== FAILURES ==========================
236+
____________ TestParsing.test_operation_addition _____________
237+
238+
self = <calc.handler_test.TestParsing object at 0x7f01f8a08400>
239+
240+
def test_operation_addition(self):
241+
req = '{"op": "+", "var1": "1.0", "var2": 0}'
242+
resp, code = h.handle(req)
243+
assert code == 200
244+
> assert resp["value"] == 2.0
245+
E assert 1.0 == 2.0
246+
247+
handler_test.py:51: AssertionError
248+
================== short test summary info ===================
249+
FAILED handler_test.py::TestParsing::test_operation_addition
250+
================ 1 failed, 1 passed in 0.04s =================
251+
```
252+
253+
254+
A test for the validation will look like
255+
256+
```python
257+
def test_operation_parsing_error_on_empty_obj(self):
258+
req = '{}'
259+
260+
resp, code = h.handle(req)
261+
assert code == 422
262+
# should be a list of error
263+
errors = resp.get("message", [])
264+
assert len(errors) == 3
265+
assert errors[0].get("loc") == ('op', )
266+
assert errors[0].get("msg") == "field required"
267+
268+
assert errors[1].get("loc") == ('var1', )
269+
assert errors[1].get("msg") == "field required"
270+
271+
assert errors[2].get("loc") == ('var2', )
272+
assert errors[2].get("msg") == "field required"
273+
```
274+
275+
Checkout the [example repo](https://github.com/LucasRoesler/pytest-openfaas-sample/blob/main/calc/handler_test.py) for the other example tests.
276+
277+
### Integrate testing into the OpenFaaS workflow
278+
279+
The `python3-flask` template can run pytest unit tests automatically. If one of your tests fails, the build will fail and you can see the `pytest` output
280+
281+
```sh
282+
$ faas-cli build
283+
# truncated ....
284+
#24 7.477 test run-test: commands[0] | pytest
285+
#24 7.662 ============================= test session starts ==============================
286+
#24 7.662 platform linux -- Python 3.7.10, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
287+
#24 7.662 cachedir: .tox/test/.pytest_cache
288+
#24 7.662 rootdir: /home/app/function
289+
#24 7.662 collected 8 items
290+
#24 7.662
291+
#24 7.662 handler_test.py ...F.... [100%]
292+
#24 7.689
293+
#24 7.689 =================================== FAILURES ===================================
294+
#24 7.689 _____________________ TestParsing.test_operation_addition ______________________
295+
#24 7.689
296+
#24 7.689 self = <function.handler_test.TestParsing object at 0x7fc06f52bf50>
297+
#24 7.689
298+
#24 7.689 def test_operation_addition(self):
299+
#24 7.689 req = '{"op": "+", "var1": "1.0", "var2": 0}'
300+
#24 7.689 resp, code = h.handle(req)
301+
#24 7.689 assert code == 200
302+
#24 7.689 > assert resp["value"] == 2.0
303+
#24 7.689 E assert 1.0 == 2.0
304+
#24 7.689
305+
#24 7.689 handler_test.py:51: AssertionError
306+
#24 7.689 =========================== short test summary info ============================
307+
#24 7.689 FAILED handler_test.py::TestParsing::test_operation_addition - assert 1.0 == 2.0
308+
#24 7.689 ========================= 1 failed, 7 passed in 0.07s ==========================
309+
#24 7.709 ERROR: InvocationError for command /home/app/function/.tox/test/bin/pytest (exited with code 1)
310+
#24 7.709 ___________________________________ summary ____________________________________
311+
#24 7.709 lint: commands succeeded
312+
#24 7.709 ERROR: test: commands failed
313+
#24 ERROR: executor failed running [/bin/sh -c if [ "$TEST_ENABLED" == "false" ]; then echo "skipping tests"; else eval "$TEST_COMMAND"; fi]: exit code: 1
314+
------
315+
> [stage-1 18/19] RUN if [ "true" == "false" ]; then echo "skipping tests"; else eval "tox"; fi:
316+
------
317+
executor failed running [/bin/sh -c if [ "$TEST_ENABLED" == "false" ]; then echo "skipping tests"; else eval "$TEST_COMMAND"; fi]: exit code: 1
318+
[0] < Building calc done in 16.67s.
319+
# truncated ....
320+
```
321+
322+
If you are working locally and need to disable the tests for a build, you can use the build arg `--build-arg TEST_ENABLED=false`.
323+
324+
### Running the tests in CI
325+
326+
If you are a fan of Github Actions, you only need two steps:
327+
328+
```yaml
329+
- name: Setup tools
330+
env:
331+
ARKADE_VERSION: "0.6.21"
332+
run: |
333+
curl -SLs https://github.com/alexellis/arkade/releases/download/$ARKADE_VERSION/arkade > arkade
334+
chmod +x ./arkade
335+
./arkade get faas-cli
336+
337+
- name: Build and Test Functions
338+
run: faaas-cli build
339+
```
340+
341+
Here the [arkade tool](https://get-arkade.dev/) created by the OpenFaaS community is used as a downloader for `faas-cli`.
342+
343+
## Wrapping up
344+
345+
In this post we've shown how to add unit tests to your Python functions and how to run those tests in your local development and CI environments.
346+
347+
Do you have any tips and tricks for testing in Python? [Let us know on Twitter @openfaas](https://twitter.com/openfaas) or join us on [on Slack](https://slack.openfaas.io/) to chat more.
348+
349+
Would you like to keep learning? The Python 3 template is a core part of the new [Introduction to Serverless course by the LinuxFoundation](https://www.openfaas.com/blog/introduction-to-serverless-linuxfoundation/)
350+
351+
If Python is not your language of choice, then the [Go](https://github.com/openfaas/templates/blob/6b8c6082ffb98bd4e951b11509508e99c769bce1/template/go/Dockerfile#L35), [Node12](https://github.com/openfaas/templates/blob/6b8c6082ffb98bd4e951b11509508e99c769bce1/template/node12/Dockerfile#L44), and the [Node14](https://github.com/openfaas/templates/blob/6b8c6082ffb98bd4e951b11509508e99c769bce1/template/node14/Dockerfile#L44) templates also have testing integrated into the build process.
352+
353+
Would like to have automated testing in _your_ favorite language template? Checkout out the implementation in the [`python3-flask` template](https://github.com/openfaas/python-flask-template/blob/12db680950b42c7cfcc7d21ba036bd1397d62eb7/template/python3-flask/Dockerfile#L45-L49) and let us know how to adapt it to your favorite [template](https://github.com/openfaas/templates/tree/master/template).
Loading

0 commit comments

Comments
 (0)