|
| 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). |
0 commit comments