├── .gitignore ├── API-docs.md ├── README.md ├── api_app ├── Dockerfile ├── __init__.py ├── src │ ├── __init__.py │ ├── app.py │ ├── books.py │ ├── data.py │ ├── hooks.py │ ├── knockknock.py │ ├── schemas.py │ └── token.py └── tests │ ├── __init__.py │ ├── pytest.ini │ ├── test_auth.py │ ├── test_books.py │ └── test_knockknock.py ├── exercises ├── README.md ├── __init__.py ├── example_solutions │ ├── __init__.py │ ├── apiclients_ex4.py │ ├── apiclients_ex5.py │ ├── apiclients_ex6.py │ ├── ex1_requests.py │ ├── pytest.ini │ ├── test_ex2_pytest.py │ ├── test_ex3_fixtures.py │ ├── test_ex4_apiclients.py │ ├── test_ex5_logging_v1.py │ └── test_ex6_logging_v2.py ├── exercise_1.md ├── exercise_2.md ├── exercise_3.md ├── exercise_4.md ├── exercise_5.md └── exercise_6.md ├── extras ├── README.md ├── __init__.py ├── next_steps │ ├── __init__.py │ ├── debugging.md │ ├── pytest.ini │ ├── test_jsonschema.py │ ├── test_log_to_file.py │ ├── test_parametrization.py │ ├── test_schema_validation.py │ ├── test_setup_teardown_fixture.py │ ├── test_teardown_only_fixture.py │ ├── test_with_testdata_from_file.py │ └── testdata.yml └── same_test_different_tools │ ├── __init__.py │ ├── behave │ ├── __init__.py │ └── features │ │ ├── __init__.py │ │ ├── example.feature │ │ └── steps │ │ ├── __init__.py │ │ └── example_steps.py │ ├── pytest │ ├── __init__.py │ ├── pytest.ini │ └── test_delete_book.py │ ├── robot-framework │ ├── __init__.py │ └── delete_book.robot │ └── tavern │ ├── __init__.py │ ├── pytest.ini │ └── test_delete.tavern.yaml └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # virtual environments 2 | py2venv/ 3 | py3venv/ 4 | venv/ 5 | 6 | # PyCharm 7 | .idea/ 8 | 9 | # python 10 | *.pyc 11 | 12 | # pytest 13 | .pytest_cache/ 14 | 15 | # test log files 16 | *.log 17 | 18 | # robot framework 19 | log.html 20 | output.xml 21 | report.html -------------------------------------------------------------------------------- /API-docs.md: -------------------------------------------------------------------------------- 1 | # API specs 2 | host: `http://localhost:8000` (Note: `https` is not supported.) 3 | 4 | 5 | ## /knockknock 6 | **GET /knockknock** 7 | request body: none 8 | 9 | response status code: 200 10 | response body: (string) 11 | 12 | 13 | ## /books 14 | **GET /books** 15 | request body: none 16 | 17 | response status code: 200 18 | response body: list of books (json) 19 | 20 | 21 | **GET /books/{book-id}** 22 | request body: none 23 | 24 | response status code: 200 25 | response body: book (json) 26 | 27 | example response: 28 | ``` 29 | { 30 | "author": "Gerald Weinberg", 31 | "id": "9b30d321-d242-444f-b2db-884d04a4d806", 32 | "pages": 182, 33 | "publisher": "Dorset House Publishing", 34 | "sub_title": null, 35 | "title": "Perfect Software And Other Illusions About Testing", 36 | "year": 2008 37 | } 38 | ``` 39 | 40 | 41 | **POST /books** 42 | request body: book details (no id) (json) 43 | 44 | response status code: 201 45 | response body: id of created book (json) 46 | 47 | example response: 48 | ``` 49 | { 50 | "id": "3612a30e-800f-4f34-8c2c-670fd2f13a01" 51 | } 52 | ``` 53 | 54 | 55 | **DELETE /books/{book-id}** 56 | request body: none 57 | 58 | requires token 59 | response status code: 200 60 | response body: none 61 | 62 | 63 | **PUT /books/{book-id}** 64 | requires token 65 | request body: book details (no id) (json) 66 | 67 | response status code: 200 68 | response body: updated book (json) 69 | 70 | 71 | ## /token 72 | **POST /token/{user}** 73 | request body: none 74 | 75 | response status code: 201 76 | response body: token (json) 77 | 78 | example response: 79 | ``` 80 | { 81 | "token": "AQuKIjKwcktlzEK" 82 | } 83 | ``` 84 | 85 | 86 | **using tokens** 87 | Calls requiring a token, expect the user and token to be in the header as key-value pairs: 88 | ``` 89 | user: joep 90 | token: owAMNRTDSdjLYtw 91 | ``` 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The "Books" API app - building an API testing framework in Python 2 | 3 | **Disclaimer**: 4 | This app is intended as a practice app for a testing workshop, so I took some shortcuts. ;-) 5 | 6 | 7 | ## Setup & installation 8 | 9 | If you run into any issues with the steps below, please let me know at j19sch@gmail.com. 10 | 11 | ### Python and VirtualEnv 12 | - Install Python 3.6 or higher 13 | - Instructions: https://ehmatthes.github.io/pcc/chapter_01/README.html (no need to install Geany or Sublime Text) 14 | - Install virtualenv. Virtualenv allows you to create an isolated Python environment, with full control over which Python version to use and which Python packages to install. 15 | - If you have only Python 3 installed: `pip install --user virtualenv`. 16 | - If you have Python 2 and Python 3 installed (likely if you use mac or linux), run `pip3 install --user virtualenv` instead. 17 | - If you need to figure out what you have installed, you can run `python --version` and/or `python3 --version` from the command line. 18 | 19 | ### Download this repo 20 | - Download this repository by clicking the green `Code` button at the top (make sure you update to 21 | the latest version right before the workshop) and unzip it. Or if you're familiar with git, fork this repository. 22 | 23 | ### Create and activate a Python virtual environment 24 | We will be using this virtual environment both for running the test app, and for running the code your 25 | write during the exercises. 26 | 27 | - Open a terminal and go into the directory containing the repository files. Important: all commands in this README assume you are in this directory. 28 | - Create a virtual python environment 29 | - Note that this will create the virtual environment in the current directory, so double-check you are in the directory 30 | containing the files from the repository. 31 | - If you have only Python 3 installed: `python -m virtualenv venv`. 32 | - If you have both Python 2.7 and Python 3 installed: `python3 -m virtualenv -p python3 venv`. 33 | - Activate the virtualenv (linux, mac: `source venv/bin/activate`) or (win: `venv\Scripts\activate`) 34 | - Note that once the virtual environment is active, `python` and `pip` will be the Python 3 versions, since that is how we set up the virtual environment. 35 | So for the rest of the instructions it doesn't matter if you also have Python 2 installed, since we run everything in our virtual Python 3 environment. 36 | - Once you're done with the virtual environment (i.e. no longer want to play around with the code and the exercises), type `deactivate` 37 | to deactivate it. Or close the terminal in which the virtual environment runs. 38 | 39 | ### Install the required libraries 40 | Important: perform the steps below with your virtual environment activated. 41 | 42 | - Open the `requirements.txt` and uncomment either the `gunicorn` or `waitress` line depending on your OS 43 | - Install requirements.txt (`pip install -r requirements.txt`) 44 | 45 | ### Running the app 46 | Important: perform the steps below with your virtual environment activated. 47 | 48 | - linux, mac: `gunicorn api_app.src.app` or win: `waitress-serve --port=8000 api_app.src.app:app` 49 | - smoke test by using your browser to go to `localhost:8000/knockknock` 50 | - the easiest way to restart the app is to kill the process (`ctrl/cmd+c`) and start it again 51 | 52 | #### Docker 53 | If you have Docker installed, you can also run the app in a Docker container: 54 | - build: `docker build -f api_app/Dockerfile -t api-app .` 55 | - run: `docker run -p 80:80 api-app` 56 | - smoke test by using your browser to go to `localhost:80/knockknock` 57 | 58 | ### Advanced text editor 59 | - Any advanced text editor with the following features will do: 60 | - syntax highlighting (easier to read) 61 | - word completion (avoids typos in names of variables, functions and methods) 62 | - If you're note sure which one to use, Visual Studio Code is a good choice: https://code.visualstudio.com/ 63 | - You can find VS Code's Python plugin here: https://marketplace.visualstudio.com/items?itemName=ms-python.python 64 | - To tell VS Code to use the interpreter in your virtual environment: https://code.visualstudio.com/docs/python/environments#_select-and-activate-an-environment 65 | - To enable autosave: https://code.visualstudio.com/docs/editor/codebasics#_save-auto-save 66 | - If you want more of an IDE, PyCharm is an great option: 67 | - To tell PyCharm to use the interpreter in your virtual environment: https://www.jetbrains.com/help/pycharm/configuring-python-interpreter.html 68 | - To tell PyCharm to use pytest to run tests: https://www.jetbrains.com/help/pycharm/pytest.html#enable-pytest 69 | 70 | 71 | ## Exercises (`./exercises`) 72 | See `./exercises/README.md` for more information about the exercises. 73 | 74 | ### Reference materials 75 | - API documentation for the app: `API-docs.md` 76 | - Python cheatsheet 77 | https://github.com/ehmatthes/pcc/releases/download/v1.0.0/beginners_python_cheat_sheet_pcc.pdf 78 | - Requests: 79 | http://docs.python-requests.org/en/master/ 80 | - Pytest: 81 | https://docs.pytest.org/en/latest/contents.html and https://docs.pytest.org/en/latest/reference.html 82 | 83 | 84 | 85 | ## Extras (`./extras`) 86 | 87 | This directory contains: 88 | - next steps to extend the framework 89 | - the same test implemented using different tools, e.g. behave and tavern 90 | - a README.md with further details 91 | 92 | 93 | 94 | ## Further reading 95 | 96 | ### More Pytest 97 | - Pytest reference https://docs.pytest.org/en/latest/reference.html 98 | - Pytest Quick Start Guide - Bruno Oliveira 99 | https://www.packtpub.com/web-development/pytest-quick-start-guide 100 | - Python Testing with pytest: Simple, Rapid, Effective, and Scalable - Brian Okken 101 | https://pragprog.com/book/bopytest/python-testing-with-pytest 102 | 103 | ### More Python 104 | - Python koans: https://github.com/gregmalcolm/python_koans 105 | - Raymond Hettinger - Transforming Code into Beautiful, Idiomatic 106 | Python https://www.youtube.com/watch?v=OSGv2VnC0go 107 | - James Powell - So you want to be a Python expert? https://www.youtube.com/watch?v=cKPlPJyQrt4 108 | 109 | 110 | 111 | ## Acknowledgements 112 | - Mark Winteringham (@2bittester): restful-booker as inspiration 113 | (https://github.com/mwinteringham/restful-booker) 114 | - Eric Matthes for the Python Crash Course materials cheatsheet from the Python Crash Course 115 | (https://ehmatthes.github.io/pcc/cheatsheets/README.html) 116 | - Elizabeth Zagroba: tried it. More stuff works and is spelled correctly now. 117 | - Everyone contributing to pytest, requests, falcon 118 | -------------------------------------------------------------------------------- /api_app/Dockerfile: -------------------------------------------------------------------------------- 1 | from python:3.6-alpine 2 | 3 | EXPOSE 80 4 | 5 | RUN pip install gunicorn==20.0.4 6 | RUN pip install falcon==3.0.0 7 | 8 | COPY ./api_app/src /api_app/src 9 | 10 | CMD ["gunicorn", "-b", "0.0.0.0:80", "api_app.src.app"] 11 | -------------------------------------------------------------------------------- /api_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j19sch/building-an-api-testing-framework/47c36d76c8c7084aedd16911f310730fe4fea553/api_app/__init__.py -------------------------------------------------------------------------------- /api_app/src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j19sch/building-an-api-testing-framework/47c36d76c8c7084aedd16911f310730fe4fea553/api_app/src/__init__.py -------------------------------------------------------------------------------- /api_app/src/app.py: -------------------------------------------------------------------------------- 1 | import falcon 2 | 3 | from api_app.src import knockknock, books, token 4 | 5 | app = application = falcon.App() 6 | 7 | app.add_route('/knockknock', knockknock.Ping()) 8 | 9 | app.add_route('/books', books.Books()) 10 | app.add_route('/books/{book_id}', books.Book()) 11 | 12 | app.add_route('/token/{user}', token.Token()) 13 | -------------------------------------------------------------------------------- /api_app/src/books.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import falcon 3 | from falcon.media.validators import jsonschema 4 | from .schemas import book 5 | 6 | from .data import BOOKS 7 | from .hooks import validate_token, validate_uuid 8 | 9 | 10 | class Books(object): 11 | def __init__(self): 12 | self.books = BOOKS 13 | 14 | def on_get(self, req, resp): 15 | resp.status = falcon.HTTP_200 16 | resp.media = self.books 17 | 18 | @jsonschema.validate(book) 19 | def on_post(self, req, resp): 20 | new_book = req.media 21 | new_book["id"] = str(uuid.uuid4()) 22 | self.books.append(new_book) 23 | 24 | resp.media = {"id": new_book["id"]} 25 | resp.status = falcon.HTTP_201 26 | 27 | 28 | class Book(object): 29 | def __init__(self): 30 | self.books = BOOKS 31 | 32 | @falcon.before(validate_uuid) 33 | def on_get(self, req, resp, book_id): 34 | try: 35 | requested_book = [book for book in self.books if book['id'] == book_id][0] 36 | except IndexError: 37 | resp.status = falcon.HTTP_NOT_FOUND 38 | else: 39 | resp.status = falcon.HTTP_200 40 | resp.media = requested_book 41 | 42 | @falcon.before(validate_uuid) 43 | @falcon.before(validate_token) 44 | def on_delete(self, req, resp, book_id): 45 | if [book for book in self.books if book['id'] == book_id]: 46 | self.books[:] = [book for book in self.books if book["id"] != book_id] 47 | resp.status = falcon.HTTP_200 48 | else: 49 | resp.status = falcon.HTTP_NOT_FOUND 50 | 51 | @falcon.before(validate_uuid) 52 | @falcon.before(validate_token) 53 | @jsonschema.validate(book) 54 | def on_put(self, req, resp, book_id): 55 | updated_book = req.media 56 | updated_book['id'] = book_id 57 | 58 | if [book for book in self.books if book['id'] == book_id]: 59 | self.books[:] = [updated_book if book['id'] == book_id else book for book in self.books] 60 | resp.media = updated_book 61 | resp.status = falcon.HTTP_200 62 | else: 63 | resp.status = falcon.HTTP_NOT_FOUND 64 | -------------------------------------------------------------------------------- /api_app/src/data.py: -------------------------------------------------------------------------------- 1 | BOOKS = [ 2 | { 3 | 'id': '9b30d321-d242-444f-b2db-884d04a4d806', 4 | 'title': 'Perfect Software And Other Illusions About Testing', 5 | 'sub_title': None, 6 | 'author': 'Gerald Weinberg', 7 | 'publisher': 'Dorset House Publishing', 8 | 'year': 2008, 9 | 'pages': 182 10 | }, 11 | { 12 | 'id': '978175e6-696d-413b-a55a-a45b7c139f3f', 13 | 'title': 'Extreme Ownership', 14 | 'sub_title': 'How U.S. Navy SEALs Lead and Win', 15 | 'author': 'Jocko Willink, Leif Babin', 16 | 'publisher': 'St. Martin\'s Press', 17 | 'year': 2017, 18 | 'pages': 298 19 | }, 20 | { 21 | 'id': '8b91b84b-04e4-4496-9635-66468c2f3e41', 22 | 'title': 'Against Method', 23 | 'sub_title': None, 24 | 'author': 'Paul Feyerabend', 25 | 'publisher': 'Verso', 26 | 'year': 2010, 27 | 'pages': 296 28 | }, 29 | { 30 | 'id': 'fc6b9230-42ec-45b3-8c93-4ad491bff13c', 31 | 'title': 'Body and Soul', 32 | 'sub_title': 'Towards a Radical Intersubjectivity in Psychotherapy', 33 | 'author': 'Ellis Amdur', 34 | 'publisher': 'Edgework', 35 | 'year': 2016, 36 | 'pages': 101 37 | }, 38 | { 39 | 'id': 'd917b78e-1dc2-4f3e-87ea-245c55c6fd52', 40 | 'title': 'A Philosophy of Software Design', 41 | 'sub_title': None, 42 | 'author': 'John Ousterhout', 43 | 'publisher': 'Yaknyam Press', 44 | 'year': 2018, 45 | 'pages': 178 46 | } 47 | ] 48 | 49 | 50 | CREDS = {} 51 | -------------------------------------------------------------------------------- /api_app/src/hooks.py: -------------------------------------------------------------------------------- 1 | import falcon 2 | import uuid 3 | 4 | from .data import CREDS 5 | 6 | 7 | def validate_token(req, resp, resource, params): 8 | user = req.get_header('User') 9 | token = req.get_header('Token') 10 | 11 | if user is None or token is None: 12 | raise falcon.HTTPUnauthorized 13 | 14 | try: 15 | CREDS[user] 16 | except KeyError: 17 | raise falcon.HTTPUnauthorized 18 | else: 19 | if CREDS[user] != token: 20 | raise falcon.HTTPUnauthorized 21 | else: 22 | pass 23 | 24 | 25 | def validate_uuid(req, resp, resource, params): 26 | try: 27 | uuid.UUID(params['book_id'], version=4) 28 | except ValueError: 29 | raise falcon.HTTPBadRequest(description="Not a valid uuid.") 30 | -------------------------------------------------------------------------------- /api_app/src/knockknock.py: -------------------------------------------------------------------------------- 1 | import falcon 2 | 3 | 4 | class Ping(object): 5 | def __init__(self): 6 | pass 7 | 8 | def on_get(self, req, resp): 9 | resp.content_type = falcon.MEDIA_TEXT 10 | resp.text = "Who's there?" 11 | 12 | resp.status = falcon.HTTP_200 13 | -------------------------------------------------------------------------------- /api_app/src/schemas.py: -------------------------------------------------------------------------------- 1 | book = { 2 | "type": "object", 3 | "properties": { 4 | "title": { 5 | "type": "string" 6 | }, 7 | "sub_title": { 8 | "type": ["string", "null"], 9 | }, 10 | "author": { 11 | "type": "string" 12 | }, 13 | "publisher": { 14 | "type": "string" 15 | }, 16 | "year": { 17 | "type": "integer" 18 | }, 19 | "pages": { 20 | "type": "integer" 21 | } 22 | 23 | }, 24 | "required": ["title", "sub_title", "author", "publisher", "year", "pages"] 25 | } 26 | -------------------------------------------------------------------------------- /api_app/src/token.py: -------------------------------------------------------------------------------- 1 | import falcon 2 | import random 3 | import string 4 | from .data import CREDS 5 | 6 | 7 | class Token(object): 8 | def __init__(self): 9 | self.creds = CREDS 10 | 11 | def on_post(self, req, resp, user): 12 | self.creds[user] = "".join(random.choice(string.ascii_letters) for _ in range(15)) 13 | 14 | resp.media = {'token': self.creds[user]} 15 | resp.status = falcon.HTTP_201 16 | -------------------------------------------------------------------------------- /api_app/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j19sch/building-an-api-testing-framework/47c36d76c8c7084aedd16911f310730fe4fea553/api_app/tests/__init__.py -------------------------------------------------------------------------------- /api_app/tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | # deprecated: imp module 4 | ignore::DeprecationWarning -------------------------------------------------------------------------------- /api_app/tests/test_auth.py: -------------------------------------------------------------------------------- 1 | import falcon 2 | from falcon import testing 3 | import pytest 4 | 5 | from api_app.src.app import app 6 | 7 | 8 | @pytest.fixture 9 | def client(): 10 | return testing.TestClient(app) 11 | 12 | 13 | def test_post_auth(client): 14 | response = client.simulate_post('/token/%s' % 'user01') 15 | 16 | assert response.status == falcon.HTTP_CREATED 17 | assert 'token' in response.json.keys() 18 | -------------------------------------------------------------------------------- /api_app/tests/test_books.py: -------------------------------------------------------------------------------- 1 | import falcon 2 | from falcon import testing 3 | import pytest 4 | 5 | import uuid 6 | 7 | from api_app.src.app import app 8 | from api_app.src.data import BOOKS 9 | 10 | 11 | @pytest.fixture 12 | def client(): 13 | return testing.TestClient(app) 14 | 15 | 16 | @pytest.fixture 17 | def get_user_and_token(client): 18 | user = 'Bob' 19 | response = client.simulate_post(f'/token/{user}') 20 | token = str(response.json['token']) 21 | 22 | return user, token 23 | 24 | 25 | def test_get_books(client): 26 | expected = list(BOOKS) 27 | 28 | response = client.simulate_get('/books') 29 | 30 | assert response.status == falcon.HTTP_OK 31 | assert response.json == expected 32 | 33 | 34 | def test_get_single_book(client): 35 | expected = BOOKS[0].copy() 36 | 37 | response = client.simulate_get(f'/books/{expected["id"]}') 38 | 39 | assert response.status == falcon.HTTP_OK 40 | assert response.json == expected 41 | 42 | 43 | def test_get_single_book_invalid_uuid(client): 44 | invalid_uuid = 'invalid' 45 | response = client.simulate_get(f'/books/{invalid_uuid}') 46 | 47 | assert response.status == falcon.HTTP_BAD_REQUEST 48 | assert response.json['title'] == '400 Bad Request' 49 | assert response.json['description'] == 'Not a valid uuid.' 50 | 51 | 52 | def test_post_book(client): 53 | new_book = { 54 | "author": "Neil Gaiman", 55 | "pages": 299, 56 | "publisher": "W.W. Norton & Company", 57 | "sub_title": None, 58 | "title": "Norse Mythology", 59 | "year": 2017 60 | } 61 | 62 | response = client.simulate_post('/books', json=new_book) 63 | assert response.status == falcon.HTTP_CREATED 64 | new_book["id"] = response.json['id'] 65 | 66 | response = client.simulate_get(f'/books/{new_book["id"]}') 67 | assert response.status == falcon.HTTP_OK 68 | assert response.json == new_book 69 | 70 | 71 | def test_post_book_invalid_request(client): 72 | new_book = { 73 | "pages": 299, 74 | "publisher": "W.W. Norton & Company", 75 | "sub_title": None, 76 | "title": "Norse Mythology", 77 | "year": 2017 78 | } 79 | 80 | response = client.simulate_post('/books', json=new_book) 81 | assert response.status == falcon.HTTP_BAD_REQUEST 82 | assert response.json['title'] == "Request data failed validation" 83 | assert response.json['description'] == "'author' is a required property" 84 | 85 | 86 | def test_delete_book(client, get_user_and_token): 87 | book_to_delete = BOOKS[0].copy() 88 | user, token = get_user_and_token 89 | 90 | response = client.simulate_delete(f'/books/{book_to_delete["id"]}', headers={'User': user, 'Token': token}) 91 | assert response.status == falcon.HTTP_OK 92 | 93 | response = client.simulate_get(f'/books/{book_to_delete["id"]}') 94 | assert response.status == falcon.HTTP_NOT_FOUND 95 | 96 | 97 | def test_delete_nonexisting_book(client, get_user_and_token): 98 | user, token = get_user_and_token 99 | 100 | response = client.simulate_delete(f'/books/{uuid.uuid4()}', headers={'User': user, 'Token': token}) 101 | 102 | assert response.status == falcon.HTTP_NOT_FOUND 103 | 104 | 105 | def test_delete_book_no_auth(client): 106 | book_to_delete = BOOKS[0].copy() 107 | 108 | response = client.simulate_delete(f'/books/{book_to_delete["id"]}') 109 | assert response.status == falcon.HTTP_UNAUTHORIZED 110 | 111 | 112 | def test_delete_book_wrong_user(client, get_user_and_token): 113 | book_to_delete = BOOKS[0].copy() 114 | user, token = get_user_and_token 115 | 116 | response = client.simulate_delete(f'/books/{book_to_delete["id"]}', headers={'User': 'notMe', 'Token': token}) 117 | assert response.status == falcon.HTTP_UNAUTHORIZED 118 | 119 | 120 | def test_delete_book_wrong_token(client, get_user_and_token): 121 | book_to_delete = BOOKS[0].copy() 122 | user, token = get_user_and_token 123 | 124 | response = client.simulate_delete(f'/books/{book_to_delete["id"]}', headers={'User': user, 'Token': 'wrong'}) 125 | assert response.status == falcon.HTTP_UNAUTHORIZED 126 | 127 | 128 | def test_delete_book_invalid_uuid(client, get_user_and_token): 129 | user, token = get_user_and_token 130 | 131 | invalid_uuid = 'invalid' 132 | response = client.simulate_delete(f'/books/{invalid_uuid}', headers={'User': user, 'Token': token}) 133 | assert response.status == falcon.HTTP_BAD_REQUEST 134 | assert response.json['title'] == '400 Bad Request' 135 | assert response.json['description'] == 'Not a valid uuid.' 136 | 137 | 138 | def test_put_book(client, get_user_and_token): 139 | book_to_update = BOOKS[0].copy() 140 | book_id = book_to_update.pop('id', None) 141 | 142 | user, token = get_user_and_token 143 | 144 | for key in book_to_update: 145 | book_to_update[key] = "foobar" if key in ['title', 'author'] else book_to_update[key] 146 | 147 | response = client.simulate_put(f'/books/{book_id}', json=book_to_update, headers={'User': user, 'Token': token}) 148 | assert response.status == falcon.HTTP_OK 149 | book_to_update['id'] = book_id 150 | assert response.json == book_to_update 151 | 152 | 153 | def test_put_nonexisting_book(client, get_user_and_token): 154 | book_to_update = BOOKS[0].copy() 155 | book_to_update.pop('id', None) 156 | book_id = uuid.uuid4() 157 | 158 | user, token = get_user_and_token 159 | 160 | for key in book_to_update: 161 | book_to_update[key] = "foobar" if key in ['title', 'author'] else book_to_update[key] 162 | 163 | response = client.simulate_put(f'/books/{book_id}', json=book_to_update, headers={'User': user, 'Token': token}) 164 | assert response.status == falcon.HTTP_NOT_FOUND 165 | 166 | 167 | def test_put_book_invalid_request(client, get_user_and_token): 168 | book_to_update = BOOKS[0].copy() 169 | book_id = book_to_update.pop('id', None) 170 | book_to_update.pop('author') 171 | 172 | user, token = get_user_and_token 173 | 174 | for key in book_to_update: 175 | book_to_update[key] = "foobar" if key in ['title', 'author'] else book_to_update[key] 176 | 177 | response = client.simulate_put(f'/books/{book_id}', json=book_to_update, headers={'User': user, 'Token': token}) 178 | assert response.status == falcon.HTTP_BAD_REQUEST 179 | assert response.json['title'] == "Request data failed validation" 180 | assert response.json['description'] == "'author' is a required property" 181 | 182 | 183 | def test_put_book_invalid_uuid(client, get_user_and_token): 184 | user, token = get_user_and_token 185 | 186 | invalid_uuid = 'invalid' 187 | response = client.simulate_put(f'/books/{invalid_uuid}', json={}, headers={'User': user, 'Token': token}) 188 | assert response.status == falcon.HTTP_BAD_REQUEST 189 | assert response.json['title'] == '400 Bad Request' 190 | assert response.json['description'] == 'Not a valid uuid.' 191 | -------------------------------------------------------------------------------- /api_app/tests/test_knockknock.py: -------------------------------------------------------------------------------- 1 | import falcon 2 | from falcon import testing 3 | import pytest 4 | 5 | from api_app.src.app import app 6 | 7 | 8 | @pytest.fixture 9 | def client(): 10 | return testing.TestClient(app) 11 | 12 | 13 | def test_knockknock(client): 14 | response = client.simulate_get('/knockknock') 15 | 16 | assert response.status == falcon.HTTP_OK 17 | assert response.text == "Who's there?" 18 | -------------------------------------------------------------------------------- /exercises/README.md: -------------------------------------------------------------------------------- 1 | # Exercises 2 | 3 | Each exercise is described in a separate file. Reason for this is that each exercise builds on 4 | the previous one - often solving a problem with what you have built so far. 5 | A good practice is to ask before each exercise: what is the next thing I should do to improve the framework? 6 | 7 | Example solutions can be found in the `example_solutions` directory. 8 | 9 | Note that different solutions to each exercise are possible. The example solutions have been created 10 | to most clearly illustrate the purpose of the exercise. 11 | 12 | As you proceed through the exercises and as your framework and tests grow, you will probably 13 | need to refactor what you have so far. So you have two options: 14 | - keep editing the same file throughout the exercises; 15 | - create a new file for each exercise copy-pasting what you need from the previous exercise. 16 | 17 | **Important** 18 | - Read the exercise descriptions carefully. There's more information in them than you think. 19 | - Make sure the virtual environment you created during setup, is active when running the app 20 | and when running the code you wrote. If you need to check, there are two ways to do this: 21 | - If supported by your command-line, the prompt will start with the name of the virtual environment, i.e. `(venv)`. 22 | - Alternatively do the following: 23 | - Type `python` to start the interactive python interpreter 24 | - Type `import sys; print(sys.prefix)`, this will print a path. 25 | - Check if the path matches the directory where you installed the virtual environment. 26 | - Type `exit()` to lave the interactive python interpreter. -------------------------------------------------------------------------------- /exercises/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j19sch/building-an-api-testing-framework/47c36d76c8c7084aedd16911f310730fe4fea553/exercises/__init__.py -------------------------------------------------------------------------------- /exercises/example_solutions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j19sch/building-an-api-testing-framework/47c36d76c8c7084aedd16911f310730fe4fea553/exercises/example_solutions/__init__.py -------------------------------------------------------------------------------- /exercises/example_solutions/apiclients_ex4.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | class KnockKnock: 5 | def __init__(self): 6 | self.url = 'http://localhost:8000/knockknock' 7 | 8 | def knock(self): 9 | return requests.get(self.url) 10 | 11 | 12 | class Books: 13 | def __init__(self): 14 | self.url = 'http://localhost:8000/books' 15 | 16 | def get_all(self): 17 | return requests.get(self.url) 18 | 19 | def get_one_book(self, book_id): 20 | return requests.get(f'{self.url}/{book_id}') 21 | 22 | def post_book(self, new_book): 23 | return requests.post(self.url, json=new_book) 24 | 25 | def delete_book(self, book_id, user, token): 26 | return requests.delete(f'{self.url}/{book_id}', headers={'user': user, 'token': token}) 27 | 28 | 29 | class Token: 30 | def __init__(self): 31 | self.url = self.endpoint = 'http://localhost:8000/token' 32 | 33 | def create_token(self, username): 34 | return requests.post(f'{self.url}/{username}') 35 | -------------------------------------------------------------------------------- /exercises/example_solutions/apiclients_ex5.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | 4 | 5 | class KnockKnock: 6 | def __init__(self): 7 | self.url = 'http://localhost:8000/knockknock' 8 | 9 | def knock(self): 10 | return requests.get(self.url) 11 | 12 | 13 | class Books: 14 | def __init__(self): 15 | self.url = 'http://localhost:8000/books' 16 | 17 | def get_all(self): 18 | return requests.get(self.url) 19 | 20 | def get_one_book(self, book_id): 21 | return requests.get(f'{self.url}/{book_id}') 22 | 23 | def post_book(self, new_book): 24 | response = requests.post(self.url, json=new_book) 25 | 26 | logging.info(f"{response.request.method}: {response.request.url}") 27 | logging.info(f"headers: {response.request.headers}") 28 | logging.info(f"request body: {response.request.body}") 29 | 30 | logging.info(f"response status: {response.status_code}, elapsed: {response.elapsed.total_seconds()}s") 31 | logging.info(f"response body: {response.text}") 32 | 33 | return response 34 | 35 | def delete_book(self, book_id, user, token): 36 | return requests.delete(f'{self.url}/{book_id}', headers={'user': user, 'token': token}) 37 | 38 | 39 | class Token: 40 | def __init__(self): 41 | self.url = self.endpoint = 'http://localhost:8000/token' 42 | 43 | def create_token(self, username): 44 | return requests.post(f'{self.url}/{username}') 45 | -------------------------------------------------------------------------------- /exercises/example_solutions/apiclients_ex6.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | 4 | 5 | class ApiClient(requests.Session): 6 | def __init__(self): 7 | super().__init__() 8 | self.hooks['response'].append(self._log_details) 9 | 10 | @staticmethod 11 | def _log_details(response, *args, **kwargs): 12 | logging.info(f"{response.request.method}: {response.request.url}") 13 | logging.info(f"headers: {response.request.headers}") 14 | if response.request.body is not None: 15 | logging.info(f"request body: {response.request.body}") 16 | 17 | logging.info(f"response status: {response.status_code}, elapsed: {response.elapsed.total_seconds()}s") 18 | logging.info(f"headers: {response.headers}") 19 | if response.text != "": 20 | logging.info(f"response body: {response.text}") 21 | 22 | 23 | class KnockKnock(ApiClient): 24 | def __init__(self): 25 | super().__init__() 26 | self.url = 'http://localhost:8000/knockknock' 27 | 28 | def knock(self): 29 | return self.get(self.url) 30 | 31 | 32 | class Books(ApiClient): 33 | def __init__(self): 34 | super().__init__() 35 | self.url = 'http://localhost:8000/books' 36 | 37 | def get_all(self): 38 | return self.get(self.url) 39 | 40 | def get_one_book(self, book_id): 41 | return self.get(f'{self.url}/{book_id}') 42 | 43 | def post_book(self, new_book): 44 | return self.post(self.url, json=new_book) 45 | 46 | def delete_book(self, book_id, user, token): 47 | return self.delete(f'{self.url}/{book_id}', headers={'user': user, 'token': token}) 48 | 49 | 50 | class Token(ApiClient): 51 | def __init__(self): 52 | super().__init__() 53 | self.url = self.endpoint = 'http://localhost:8000/token' 54 | 55 | def create_token(self, username): 56 | return self.post(f'{self.url}/{username}') 57 | -------------------------------------------------------------------------------- /exercises/example_solutions/ex1_requests.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from pprint import pprint 3 | 4 | 5 | response = requests.get('http://localhost:8000/knockknock') 6 | print(response.status_code) 7 | print(response.text) 8 | print("\n") 9 | 10 | response = requests.get('http://localhost:8000/books') 11 | print(response.status_code) 12 | print(response.json()) 13 | print("\n") 14 | 15 | response = requests.get('http://localhost:8000/books/8b91b84b-04e4-4496-9635-66468c2f3e41') 16 | print(response.status_code) 17 | pprint(response.json()) 18 | print("\n") 19 | 20 | new_book = { 21 | "author": "Neil Gaiman", 22 | "pages": 299, 23 | "publisher": "W.W. Norton & Company", 24 | "sub_title": None, 25 | "title": "Norse Mythology", 26 | "year": 2017 27 | } 28 | response = requests.post('http://localhost:8000/books', json=new_book) 29 | print(response.status_code) 30 | print(response.json()) 31 | print("\n") 32 | 33 | response = requests.post('http://localhost:8000/token/alice') 34 | print(response.status_code) 35 | print(response.json()) 36 | print("\n") 37 | -------------------------------------------------------------------------------- /exercises/example_solutions/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | # deprecated: imp module 4 | ignore::DeprecationWarning -------------------------------------------------------------------------------- /exercises/example_solutions/test_ex2_pytest.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def test_knockknock(): 5 | response = requests.get('http://localhost:8000/knockknock') 6 | assert response.status_code == 200 7 | assert response.text == "Who's there?" 8 | 9 | 10 | def test_get_all_books(): 11 | response = requests.get('http://localhost:8000/books') 12 | assert response.status_code == 200 13 | books = response.json() 14 | assert books is not None 15 | assert len(books) == 5 16 | 17 | 18 | def test_get_one_book(): 19 | response = requests.get('http://localhost:8000/books/8b91b84b-04e4-4496-9635-66468c2f3e41') 20 | assert response.status_code == 200 21 | assert response.json() == { 22 | 'id': '8b91b84b-04e4-4496-9635-66468c2f3e41', 23 | 'title': 'Against Method', 24 | 'sub_title': None, 25 | 'author': 'Paul Feyerabend', 26 | 'publisher': 'Verso', 27 | 'year': 2010, 28 | 'pages': 296 29 | } 30 | 31 | 32 | def test_post_new_book(): 33 | new_book = { 34 | "author": "Neil Gaiman", 35 | "pages": 299, 36 | "publisher": "W.W. Norton & Company", 37 | "sub_title": None, 38 | "title": "Norse Mythology", 39 | "year": 2017 40 | } 41 | 42 | response = requests.post('http://localhost:8000/books', json=new_book) 43 | assert response.status_code == 201 44 | assert response.json()['id'] is not None 45 | 46 | 47 | def test_get_token_for_user(): 48 | response = requests.post('http://localhost:8000/token/alice') 49 | print(response.status_code, response.json()) 50 | assert response.status_code == 201 51 | assert response.json()['token'] is not None 52 | -------------------------------------------------------------------------------- /exercises/example_solutions/test_ex3_fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | 5 | @pytest.fixture(scope="session") 6 | def creds(): 7 | user = 'bob' 8 | response = requests.post(f'http://localhost:8000/token/{user}') 9 | token = response.json()['token'] 10 | return user, token 11 | 12 | 13 | @pytest.fixture 14 | def new_book_id(): 15 | new_book = { 16 | "author": "Neil Gaiman", 17 | "pages": 299, 18 | "publisher": "W.W. Norton & Company", 19 | "sub_title": None, 20 | "title": "Norse Mythology", 21 | "year": 2017 22 | } 23 | 24 | response = requests.post('http://localhost:8000/books', json=new_book) 25 | assert response.status_code == 201 26 | 27 | return response.json()['id'] 28 | 29 | 30 | def test_delete_book(new_book_id, creds): 31 | user, token = creds 32 | 33 | response = requests.delete(f'http://localhost:8000/books/{new_book_id}', headers={'user': user, 'token': token}) 34 | assert response.status_code == 200 35 | 36 | response = requests.get(f'http://localhost:8000/books/{new_book_id}') 37 | assert response.status_code == 404 38 | -------------------------------------------------------------------------------- /exercises/example_solutions/test_ex4_apiclients.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from . import apiclients_ex4 4 | 5 | 6 | @pytest.fixture 7 | def books_api(): 8 | return apiclients_ex4.Books() 9 | 10 | 11 | @pytest.fixture 12 | def creds(): 13 | user = 'bob' 14 | token_api = apiclients_ex4.Token() 15 | response = token_api.create_token(user) 16 | token = response.json()['token'] 17 | return user, token 18 | 19 | 20 | @pytest.fixture 21 | def new_book_id(books_api): 22 | new_book = { 23 | "author": "Neil Gaiman", 24 | "pages": 299, 25 | "publisher": "W.W. Norton & Company", 26 | "sub_title": None, 27 | "title": "Norse Mythology", 28 | "year": 2017 29 | } 30 | 31 | response = books_api.post_book(new_book) 32 | assert response.status_code == 201 33 | 34 | return response.json()['id'] 35 | 36 | 37 | def test_delete_book(books_api, creds, new_book_id): 38 | user, token = creds 39 | 40 | response = books_api.delete_book(new_book_id, user, token) 41 | assert response.status_code == 200 42 | 43 | response = books_api.get_one_book(new_book_id) 44 | assert response.status_code == 404 45 | -------------------------------------------------------------------------------- /exercises/example_solutions/test_ex5_logging_v1.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from . import apiclients_ex5 4 | 5 | 6 | @pytest.fixture 7 | def books_api(): 8 | return apiclients_ex5.Books() 9 | 10 | 11 | @pytest.fixture 12 | def creds(): 13 | user = 'bob' 14 | token_api = apiclients_ex5.Token() 15 | response = token_api.create_token(user) 16 | token = response.json()['token'] 17 | return user, token 18 | 19 | 20 | @pytest.fixture 21 | def new_book_id(books_api): 22 | new_book = { 23 | "author": "Neil Gaiman", 24 | "pages": 299, 25 | "publisher": "W.W. Norton & Company", 26 | "sub_title": None, 27 | "title": "Norse Mythology", 28 | "year": 2017 29 | } 30 | 31 | response = books_api.post_book(new_book) 32 | assert response.status_code == 201 33 | 34 | return response.json()['id'] 35 | 36 | 37 | def test_delete_book(books_api, creds, new_book_id): 38 | user, token = creds 39 | 40 | response = books_api.delete_book(new_book_id, user, token) 41 | assert response.status_code == 200 42 | 43 | response = books_api.get_one_book(new_book_id) 44 | assert response.status_code == 404 45 | -------------------------------------------------------------------------------- /exercises/example_solutions/test_ex6_logging_v2.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from . import apiclients_ex6 4 | 5 | 6 | @pytest.fixture 7 | def books_api(): 8 | return apiclients_ex6.Books() 9 | 10 | 11 | @pytest.fixture 12 | def creds(): 13 | user = 'bob' 14 | token_api = apiclients_ex6.Token() 15 | response = token_api.create_token(user) 16 | token = response.json()['token'] 17 | return user, token 18 | 19 | 20 | @pytest.fixture 21 | def new_book_id(books_api): 22 | new_book = { 23 | "author": "Neil Gaiman", 24 | "pages": 299, 25 | "publisher": "W.W. Norton & Company", 26 | "sub_title": None, 27 | "title": "Norse Mythology", 28 | "year": 2017 29 | } 30 | 31 | response = books_api.post_book(new_book) 32 | assert response.status_code == 201 33 | 34 | return response.json()['id'] 35 | 36 | 37 | def test_delete_book(books_api, creds, new_book_id): 38 | user, token = creds 39 | 40 | response = books_api.delete_book(new_book_id, user, token) 41 | assert response.status_code == 200 42 | 43 | response = books_api.get_one_book(new_book_id) 44 | assert response.status_code == 404 45 | -------------------------------------------------------------------------------- /exercises/exercise_1.md: -------------------------------------------------------------------------------- 1 | ## Exercise 1 - requests library 2 | **Goal**: become familiar with the requests library 3 | **Purpose**: interacting programmatically with an API 4 | 5 | ### Assignment 6 | Print the status code and response for the following API calls: 7 | - GET knockknock 8 | - GET books 9 | - GET one book 10 | - POST a book 11 | - POST token 12 | 13 | You can find a description of the APIs in `API-docs.md`. 14 | 15 | Don't forget to explore and generate some error responses. 16 | 17 | ### The requests library 18 | The requests library allows you to send API requests with `requests.get()`, 19 | `requests.post(url, json=)`, etc. 20 | This will return a `response` object with (among other things) the following attributes: 21 | - `response.status_code`: http status code of the response 22 | - `response.text`: the response body as plain text 23 | - `response.json()`: parses a json response body to a Python dictionary 24 | 25 | To perform the smoke test from the setup instructions in Python: 26 | ```python 27 | import requests 28 | 29 | response = requests.get('http://localhost:8000/knockknock') 30 | print(response.status_code) 31 | print(response.text) 32 | ``` 33 | 34 | So to get started, create a new file in the `exercises` folder, copy-paste the above example into it, 35 | and run it with `python .py`. 36 | 37 | Docs for the requests library: http://docs.python-requests.org/en/master/ 38 | 39 | 40 | ### Printing text to the screen 41 | To print text to the screen, use `print()`. 42 | If you need string interpolation, use f-strings: 43 | `print(f"value a is {a}, value of b is {b}")` (Note the `f` before the opening quotes.) 44 | 45 | To make printed dictionaries (and other data structures) more readable, use `pprint`: 46 | ``` 47 | from pprint import pprint 48 | pprint() 49 | ``` 50 | 51 | And if you want to combine pretty-printing with string interpolation: 52 | ``` 53 | from pprint import pformat 54 | print(f"dictionary: {pformat()}") 55 | ``` 56 | -------------------------------------------------------------------------------- /exercises/exercise_2.md: -------------------------------------------------------------------------------- 1 | ## Exercise 2 - pytest 2 | **Goal**: become familiar with pytest 3 | **Purpose**: building and running tests 4 | 5 | ### Assignment 6 | Build tests for the API calls of exercise 1. 7 | 8 | Make sure you have seen output of the following cases: 9 | - test passes, i.e. the assert evaluates to `True` 10 | - test fails, i.e. the assert evaluates to `False` 11 | - test throws error, e.g. because of a bug in the test 12 | 13 | ### Pytest 14 | Pytest will collect tests based on the following criteria: 15 | - search the current directory and all sub-directories 16 | - for files named `test_*.py` or `*_test.py` 17 | - that contain functions named `test_*` 18 | 19 | To run the tests, execute `pytest` in the directory containing your tests, 20 | or specify the path: `pytest `. 21 | 22 | Docs: https://docs.pytest.org/en/latest/getting-started.html#create-your-first-test 23 | 24 | ### Asserts 25 | You can use `assert == ` to check actual values against expected values. 26 | In general it's not needed to add an assert message, since Pytest does a great job 27 | reporting on failed tests. If you do want an assert message: `assert actual == expected, "actual did not equal expected"` 28 | or `assert actual == expected, f"value of actual: {actual} does not equal value of expected: {expected}"` 29 | 30 | ### Pytest output 31 | Pytest is rather verbose in its output when tests fail. You can see examples of failure reports 32 | here: https://docs.pytest.org/en/latest/example/reportingdemo.html 33 | 34 | To print a list of all non-passed tests at the end of the output, run pytest with the `-ra` option. 35 | -------------------------------------------------------------------------------- /exercises/exercise_3.md: -------------------------------------------------------------------------------- 1 | ## Exercise 3 - fixtures 2 | **Goal**: use fixtures for test setup and teardown 3 | **Purpose**: separate setup & teardown from the tests (separation of concerns, 4 | don't repeat yourself) 5 | 6 | ### Assignment 7 | Create a test where you use fixtures to: 8 | - create a token 9 | - create a book 10 | 11 | after which the test will: 12 | - delete that book 13 | 14 | The token is needed to successfully make the delete call. It simulates APIs that require authentication via e.g. BasicAuth 15 | or JWTs. "Simulates" because the implementation here is not secure in any way. However, it successfully requires you to 16 | provide a valid token for some of the API calls, which is good enough for these exercises. 17 | 18 | 19 | ### Fixtures 20 | Pytest allows you to define fixtures for test setup and teardown using the 21 | `@pytest.fixture` decorator. In this exercise we will only look at using fixtures 22 | to set up data. To see an example of a teardown fixture, see 23 | `test_setup_teardown_fixture.py` in `./extras/next_steps`. 24 | 25 | To create a fixture, write a function and add the `@pytest.fixture` decorator to that function. 26 | Any test function can then use the return value(s) of your fixture, by using the function's name 27 | as an argument for the test function: 28 | ```python 29 | import pytest 30 | 31 | @pytest.fixture 32 | def my_favourite_number(): 33 | return 73 34 | 35 | def test_my_favourite_number_is_73(my_favourite_number): 36 | assert my_favourite_number == 73 37 | ``` 38 | 39 | Fixtures can apply to different levels of your tests: 40 | - `function` is limited to a particular test (default) 41 | - `module` is limited to a particular file 42 | - `session` is executed once for all the tests you're running 43 | Here's how you'd modify your fixture decorator to run once per file with tests: 44 | `@pytest.fixture(scope="module")`. 45 | 46 | Note that not only tests can use fixtures, fixtures can also use other fixtures. 47 | 48 | Docs: https://docs.pytest.org/en/latest/fixture.html 49 | 50 | 51 | ### Requests library - headers 52 | To make the delete call, you will need to add headers to your request. This can be done as follows: 53 | `response = requests.delete(, headers={'user': , 'token': })` 54 | -------------------------------------------------------------------------------- /exercises/exercise_4.md: -------------------------------------------------------------------------------- 1 | ## Exercise 4 - API clients 2 | **Goal**: build an interface between the API and your tests 3 | **Purpose**: separate the test code from the API code (separation of concerns, 4 | don't repeat yourself) 5 | 6 | ### Assignment 7 | Create an API client module as an abstraction layer (or interface) between the API and your tests. You could do this 8 | using either functions or classes. However, because of something we want to do in exercise 5, you should use classes. 9 | 10 | The module should contain one class per endpoint of the API - with your tests calling the methods of that class 11 | to interact with the APIs. 12 | 13 | 14 | ### Using code from another file 15 | To be able to use code from the API client module in the file with your tests, you'll need to `import` it. To keep 16 | things as simple as possible for this exercise, you can do the following: 17 | 18 | - In the same directory as the files with your tests, create a file called `api_clients.py`. This will be the module 19 | containing the API client (see next section). 20 | - In that same directory, create an empty file called `__init__.py`. This tells Python you want to import code from 21 | this directory. 22 | - At the top of your file with tests, add `from . import api_clients`. 23 | 24 | ### Apiclient class 25 | You can define a class as follows: 26 | ```python 27 | import requests 28 | 29 | class MyClass: 30 | def __init__(self): 31 | # this code is run when instantiating the class my_class = MyClass() 32 | # one thing you can do here, is store the endpoint of the url: 33 | self.url = "http://localhost:8000/knockknock" 34 | 35 | def my_method(self): 36 | # this code is run for an instance with my_class.my_method() 37 | # so if we want to the GET of our smoketest: 38 | return requests.get(self.url) 39 | 40 | ``` 41 | 42 | And then use it in a test: 43 | ```python 44 | from . import api_clients 45 | 46 | def test_with_a_class(): 47 | my_class = api_clients.MyClass() 48 | response = my_class.my_method() 49 | 50 | ``` 51 | 52 | Docs: 53 | - https://docs.python.org/3/tutorial/classes.html 54 | - https://docs.python.org/3/tutorial/modules.html#importing-from-a-package 55 | -------------------------------------------------------------------------------- /exercises/exercise_5.md: -------------------------------------------------------------------------------- 1 | ## Exercise 5 - logging v1 2 | **Goal**: add logging to record requests and responses 3 | **Purpose**: be able to see which requests and responses are being sent 4 | 5 | ### Assignment 6 | Add logging to your API clients to capture information about requests and responses. 7 | 8 | For this assignment it is sufficient to add logging to the `POST` on `/books` in the setup of your test. 9 | In the next exercise, we will look at a more generic and more maintainable way to log requests and responses. 10 | 11 | 12 | ### Logging 13 | To create log records, you'll need to import the logging module. After that you can easily create 14 | log records of different log levels, e.g. `logging.info("all good")` or `logging.critical("run away!")` 15 | 16 | When a test results in an error or a fail, pytest will automatically output log records that were created 17 | on level `WARNING` and higher. If you want to see all log records of level `INFO` and higher (so no `DEBUG`) 18 | regardless of the test result, use `pytest --log-cli-level info`. 19 | 20 | Docs: 21 | - https://docs.python.org/3/library/logging.html 22 | - https://docs.pytest.org/en/latest/logging.html 23 | 24 | 25 | ### Requests library - response object 26 | All the HTTP methods of the requests library (`requests.get()`, `requests.post()`, etc.) return a response object. 27 | This object contains all the information we need for logging purposes: 28 | - `response.status_code` 29 | - `response.headers` 30 | - `response.text` 31 | - `response.json()` (json parsed to a Python dictionary) 32 | - `response.request.url` 33 | - `response.request.method` 34 | - `response.request.headers` 35 | - `response.request.body` 36 | 37 | Docs: http://docs.python-requests.org/en/master/api/#requests.Response 38 | 39 | ### Reports 40 | Pytest does not have a built-in formatted report beyond what you've seen 41 | in the console. The `pytest-html` library allows you to generate an HTML 42 | report. 43 | 44 | Docs: https://pypi.org/project/pytest-html/ 45 | -------------------------------------------------------------------------------- /exercises/exercise_6.md: -------------------------------------------------------------------------------- 1 | ## Exercise 6 - logging v2 2 | **Goal**: add logging to record requests and responses 3 | **Purpose**: be able to see which requests and responses are being sent 4 | 5 | ### Assignment 6 | Add logging to your API clients to capture information about requests and responses. 7 | 8 | Since adding logging to each method of each API client is a lot of work to build and maintain, 9 | an improvement on the solution of the previous exercise is to use the requests library's event hook 10 | of the `requests.Session` class. 11 | 12 | ### requests.Session hook 13 | The `requests.Session` class has a `hook` attribute containing a dictionary with only one key: `response`. The value for 14 | that key is a list of methods or functions that are executed for every response. In this exercise you need to add a 15 | method to that list to take care of the logging. 16 | 17 | This class also allows you to send requests through its `get()`, `post()`, etc. methods. We will be using these 18 | instead of what we've been doing so far, i.e. calling functions from the requests library directly. 19 | 20 | To set all of this up in the most maintainable way, we need two levels of inheritance: 21 | ``` 22 | requests.Session, the Session class defined by the requests library 23 | |- a general ApiClient class, which takes care of the logging 24 | |- specific API client classes, one for reach API endpoint (`/books`, `/token`, etc,) 25 | ``` 26 | 27 | To the general ApiClient class you need to add the following `hook` setup : 28 | ```python 29 | import requests 30 | 31 | class ApiClient(requests.Session): 32 | def __init__(self): 33 | super().__init__() 34 | self.hooks['response'].append(self._log_details) # for every response the _log_details() method will be called 35 | 36 | @staticmethod 37 | def _log_details(response, *args, **kwargs): 38 | pass # you decide what kind of logging this should do ;-) 39 | ``` 40 | 41 | If you then have your specific API client classes inherit from this general ApiClient class, you can use `self.get()` 42 | etc. to send requests, since they inherit these methods from their grandparent, the `requests.Session` class. 43 | 44 | Docs: http://docs.python-requests.org/en/master/user/advanced/#event-hooks 45 | Docs: http://docs.python-requests.org/en/master/api/#requests.Session -------------------------------------------------------------------------------- /extras/README.md: -------------------------------------------------------------------------------- 1 | # Extras 2 | 3 | ## Next steps 4 | 5 | The `next_steps` directory contains several options for extending your test framework: 6 | - `debugging.md`: explains how to use the Python debugger with pytest 7 | - `test_jsonschema.py`: validate the response with the jsonschema libary 8 | - `test_log_to_file.py`: write your test logs to a file 9 | - `test_parametrization.py`: uses pytest's parametrization to run the same test with different inputs 10 | - `test_schema_validation.py`: validate the response with the schema library 11 | - `test_setup_teardown_fixture.py`: uses a fixture to set up date, `yield`s it to the test, removes it in teardown 12 | - `test_teardown_only_fixture.py`: uses a fixture to remove a piece of data created in the test 13 | - `test_with_testdata_from_file.py`: read data from a file to use in a test 14 | 15 | If you want even more options for logging, have a look at a pytest plugin I built: pytest-instrument at 16 | https://pypi.org/project/pytest-instrument/. 17 | 18 | 19 | ## Same test, different tools 20 | The `same_test_different_tools` directory contains the same test (deleting a book) implemented 21 | using different tools: 22 | - behave: run with `behave `, i.e. `behave extras/same_test_different_tools/behave/features/` 23 | - pytest and requests: run with `pytest `, i.e. `pytest extras/same_test_different_tools/pytest/` 24 | - robot framework with robotframework-requests: run with `robot `, i.e. `robot extras/same_test_different_tools/robot-framework/` 25 | - tavern: run with `pytest `, i.e. `pytest extras/same_test_different_tools/tavern` 26 | -------------------------------------------------------------------------------- /extras/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j19sch/building-an-api-testing-framework/47c36d76c8c7084aedd16911f310730fe4fea553/extras/__init__.py -------------------------------------------------------------------------------- /extras/next_steps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j19sch/building-an-api-testing-framework/47c36d76c8c7084aedd16911f310730fe4fea553/extras/next_steps/__init__.py -------------------------------------------------------------------------------- /extras/next_steps/debugging.md: -------------------------------------------------------------------------------- 1 | # Debugging 2 | 3 | Sometimes test results and logging are not enough to determine why tests are misbehaving. Is there a problem with 4 | the test, the framework, the product, or a combination of these three? This is where debugging shines. 5 | It allows you to walk through the code line-by-line, observing what it does as it runs. 6 | 7 | 8 | ## Debugging with pytest 9 | 10 | ### On failures 11 | With `pytest --pdb` you can drop into the debugger on every failure. 12 | More info here: https://docs.pytest.org/en/latest/usage.html#dropping-to-pdb-python-debugger-on-failures 13 | 14 | ### At the start of a test 15 | With `pytest --trace` you can drop into the debugger at the start of each test. 16 | More info here: https://docs.pytest.org/en/latest/usage.html#dropping-to-pdb-python-debugger-at-the-start-of-a-test 17 | 18 | 19 | ### pdb - The Python Debugger 20 | The out-of-the-box Python debugger, very bare bones. 21 | 22 | More info: 23 | - https://docs.python.org/2.7/library/pdb.html 24 | - https://docs.python.org/3/library/pdb.html 25 | 26 | Some basic `pdb` commands: 27 | - `help` - shows available commands 28 | - `help ` - shows help text of command 29 | - `l`, `list` - lists source code (11 lines around current line) 30 | - `n`, `next` - continue execution until the next line in the current function is reached or it returns. 31 | - `s`, `step` - execute the current line, stop at the first possible occasion (either in a function that is called or in the current function). 32 | - `c`, `cont` - continue execution until breakpoint or until all code has been executed 33 | - `a`, `args` - print the argument list of the current function. 34 | - `p ` - print value of variable 35 | - `whatis ` - print type of variable 36 | 37 | The difference between `next` and `step`, is that with `next` you will remain on the same level in the code. So if you are debugging your test with `--trace`, `pdb` will stop at every line in that test, but not go deeper. If you want to delve into functions and methods called from your test, `step` allows you to step into these functions and methods. If you want to go one lever deeper still, use `step` again. 38 | 39 | 40 | ## Other debugging tools 41 | 42 | ### pdb++ 43 | A drop-in replacement for `pdb` with some usability improvements. 44 | 45 | More info: https://github.com/antocuni/pdb 46 | 47 | 48 | ### pudb 49 | A full-screen, console-based visual debugger. 50 | 51 | More info: https://github.com/inducer/pudb 52 | 53 | 54 | ### PyCharm 55 | The PyCharm IDE supports pytest, allowing you to run and debug tests from the IDE itself. 56 | 57 | More info: https://www.jetbrains.com/help/pycharm/pytest.html 58 | 59 | 60 | ### Visual Studio Code 61 | Visual Studio Code supports pytest, allowing you to run and debug tests from the IDE itself. 62 | 63 | More info: https://code.visualstudio.com/docs/python/testing#_debug-tests 64 | -------------------------------------------------------------------------------- /extras/next_steps/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | # deprecated: imp module 4 | ignore::DeprecationWarning -------------------------------------------------------------------------------- /extras/next_steps/test_jsonschema.py: -------------------------------------------------------------------------------- 1 | import jsonschema 2 | import requests 3 | 4 | # The three dictionaries below define json schema's, which are used in the tests to check the format of the responses. 5 | # Json schema docs: https://python-jsonschema.readthedocs.io/en/latest/ 6 | # 7 | # For validating responses with the Schema library, see test_schema_validation. 8 | 9 | 10 | book_definition = { 11 | "type": "object", 12 | "properties": { 13 | "id": { 14 | "type": "string" 15 | }, 16 | "title": { 17 | "type": "string" 18 | }, 19 | "sub_title": { 20 | "type": ["string", "null"], 21 | }, 22 | "author": { 23 | "type": "string" 24 | }, 25 | "publisher": { 26 | "type": "string" 27 | }, 28 | "year": { 29 | "type": "integer" 30 | }, 31 | "pages": { 32 | "type": "integer" 33 | } 34 | }, 35 | "required": ["title", "sub_title", "author", "publisher", "year", "pages"], 36 | "additionalProperties": False 37 | } 38 | 39 | schema_book = book_definition 40 | 41 | schema_books = { 42 | "type": "array", 43 | "items": book_definition 44 | } 45 | 46 | 47 | def test_get_book(): 48 | response = requests.get('http://localhost:8000/books/8b91b84b-04e4-4496-9635-66468c2f3e41') 49 | 50 | assert response.status_code == 200 51 | jsonschema.validate(response.json(), schema_book) 52 | 53 | 54 | def test_get_books(): 55 | response = requests.get('http://localhost:8000/books') 56 | 57 | assert response.status_code == 200 58 | jsonschema.validate(response.json(), schema_books) 59 | -------------------------------------------------------------------------------- /extras/next_steps/test_log_to_file.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import pytest 4 | import requests 5 | 6 | 7 | # The logging setup below differs in two areas from the one from exercise 6: 8 | # - logging.getLogger() is used to set the name of the log node. This makes it easier to see by which 9 | # API the log records are being generated. 10 | # - A handler of the type FileHandler is added to the logger. There are different kinds of handlers in Python; 11 | # a FileHandler will write log records to a file. 12 | # 13 | # Docs: https://docs.python.org/3.6/library/logging.html 14 | 15 | 16 | class ApiClient(requests.Session): 17 | def __init__(self, api_name): 18 | super().__init__() 19 | self.hooks['response'].append(self._log_details) 20 | 21 | self.logger = logging.getLogger(api_name) # sets the name of the log node to api_name 22 | self.logger.setLevel("INFO") 23 | 24 | if not self.logger.handlers: # prevents multiple handlers resulting in duplicate log lines 25 | file_handler = logging.FileHandler(f'./{datetime.datetime.now().strftime("%Y%m%dT%H%M%S")}.log', mode='a') 26 | file_handler.setLevel(logging.INFO) 27 | 28 | formatter = logging.Formatter('%(asctime)s %(levelname)s - %(name)s - %(message)s', "%H:%M:%S") 29 | file_handler.setFormatter(formatter) 30 | self.logger.addHandler(file_handler) 31 | 32 | def _log_details(self, r, *args, **kwargs): 33 | self.logger.info(f"{r.request.method}: {r.request.url}") 34 | self.logger.info(f"headers: {r.request.headers}") 35 | if r.request.body is not None: 36 | self.logger.info(f"request body: {r.request.body}") 37 | 38 | self.logger.info(f"response status: {r.status_code}, elapsed: {r.elapsed.total_seconds()}s") 39 | self.logger.info(f"headers: {r.headers}") 40 | if r.text != "": 41 | self.logger.info(f"response body: {r.text}") 42 | 43 | 44 | class BooksApi(ApiClient): 45 | def __init__(self): 46 | super().__init__(self.__class__.__name__) 47 | self.url = 'http://localhost:8000/books' 48 | 49 | def get_all(self): 50 | return self.get(self.url) 51 | 52 | def get_one_book(self, book_id): 53 | return self.get(f'{self.url}/{book_id}') 54 | 55 | def post_book(self, new_book): 56 | return self.post(self.url, json=new_book) 57 | 58 | def delete_book(self, book_id, user, token): 59 | return self.delete(f'{self.url}/{book_id}', headers={'user': user, 'token': token}) 60 | 61 | 62 | class TokenApi(ApiClient): 63 | def __init__(self): 64 | super().__init__(self.__class__.__name__) 65 | self.url = self.endpoint = 'http://localhost:8000/token' 66 | 67 | def get_token(self, username): 68 | return self.post(f'{self.url}/{username}') 69 | 70 | 71 | @pytest.fixture 72 | def books_api(): 73 | return BooksApi() 74 | 75 | 76 | @pytest.fixture 77 | def creds(): 78 | user = 'bob' 79 | token_api = TokenApi() 80 | response = token_api.get_token(user) 81 | token = response.json()['token'] 82 | return user, token 83 | 84 | 85 | @pytest.fixture 86 | def new_book_id(books_api): 87 | new_book = { 88 | "author": "Neil Gaiman", 89 | "pages": 299, 90 | "publisher": "W.W. Norton & Company", 91 | "sub_title": None, 92 | "title": "Norse Mythology", 93 | "year": 2017 94 | } 95 | 96 | response = books_api.post_book(new_book) 97 | assert response.status_code == 201 98 | 99 | return response.json()['id'] 100 | 101 | 102 | def test_delete_book(books_api, creds, new_book_id): 103 | user, token = creds 104 | 105 | response = books_api.delete_book(new_book_id, user, token) 106 | assert response.status_code == 200 107 | 108 | response = books_api.get_one_book(new_book_id) 109 | assert response.status_code == 404 110 | -------------------------------------------------------------------------------- /extras/next_steps/test_parametrization.py: -------------------------------------------------------------------------------- 1 | import pytest # 2 | import requests 3 | 4 | 5 | # The @pytest.mark.parametrize decorator allows you to run the same test with different data. 6 | # The example below illustrates the simples usage: different inputs expected to result in the same output. 7 | # Since the decorator accepts multiple arguments, you can also for instance provide pairs of expected input 8 | # and expected output. This allows you to test these inputs and outputs without having to duplicate code. 9 | # Finally, by stacking parametrize decorators you can test all combinations of the arguments in the separate decorators. 10 | # 11 | # Docs: https://docs.pytest.org/en/latest/parametrize.html 12 | 13 | 14 | @pytest.mark.parametrize("invalid_book_id", [ 15 | "1", 16 | "aaaa", 17 | "11d399cb-5a44-430c-bb9d-51fa3dab986", # last character missing 18 | "h1d399cb-5a44-430c-bb9d-51fa3dab9864" # h at start makes uuid invalid 19 | ]) 20 | def test_get_invalid_book_id(invalid_book_id): 21 | response = requests.get(f'http://localhost:8000/books/{invalid_book_id}') 22 | assert response.status_code == 400 23 | assert response.json()['title'] == '400 Bad Request' 24 | assert response.json()['description'] == 'Not a valid uuid.' 25 | -------------------------------------------------------------------------------- /extras/next_steps/test_schema_validation.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | import requests 4 | from schema import And, Or, Schema 5 | 6 | 7 | # The response validations in these two tests are done with the Schema libary: https://pypi.org/project/schema/ 8 | # The book dictionary below is not written for optimal coverage, but to show different options with the library. 9 | # 10 | # For validating responses against a jsonschema, see test_jsonschema. 11 | 12 | 13 | def validate_uuid_v4(uuidv4): 14 | try: 15 | UUID(uuidv4, version=4) 16 | except ValueError: 17 | return False 18 | else: 19 | return True 20 | 21 | 22 | book = { 23 | 'id': And(str, validate_uuid_v4), 24 | 'title': str, 25 | 'sub_title': Or(None, str), 26 | 'author': And(str, lambda s: len(s.strip()) > 0), 27 | 'publisher': str, 28 | 'year': int, 29 | 'pages': And(int, lambda i: i > 0) 30 | } 31 | 32 | book_schema = Schema(book) 33 | books_schema = Schema([book]) 34 | 35 | 36 | def test_get_book(): 37 | response = requests.get('http://localhost:8000/books/8b91b84b-04e4-4496-9635-66468c2f3e41') 38 | 39 | assert response.status_code == 200 40 | book_schema.validate(response.json()) 41 | 42 | 43 | def test_get_books(): 44 | response = requests.get('http://localhost:8000/books') 45 | 46 | assert response.status_code == 200 47 | books_schema.validate(response.json()) 48 | -------------------------------------------------------------------------------- /extras/next_steps/test_setup_teardown_fixture.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | 5 | # The fixture below will create a new book, then yield the new_book dictionary to the test. 6 | # Once the test has run, the remainder of the function is executed, removing the book again. 7 | # This will happen regardless of the outcome of the test itself (pass, fail, error). 8 | # 9 | # Docs: https://docs.pytest.org/en/latest/fixture.html#fixture-finalization-executing-teardown-code 10 | 11 | 12 | @pytest.fixture 13 | def new_book(): 14 | new_book = { 15 | "author": "Neil Gaiman", 16 | "pages": 299, 17 | "publisher": "W.W. Norton & Company", 18 | "sub_title": None, 19 | "title": "Norse Mythology", 20 | "year": 2017 21 | } 22 | 23 | response = requests.post('http://localhost:8000/books', json=new_book) 24 | assert response.status_code == 201 25 | 26 | new_book['id'] = response.json()['id'] 27 | 28 | yield new_book 29 | 30 | user = 'bob' 31 | response = requests.post(f'http://localhost:8000/token/{user}') 32 | token = response.json()['token'] 33 | response = requests.delete(f'http://localhost:8000/books/{new_book["id"]}', 34 | headers={'user': user, 'token': token}) 35 | assert response.status_code == 200 36 | 37 | 38 | def test_get_one_book(new_book): 39 | response = requests.get(f'http://localhost:8000/books/{new_book["id"]}') 40 | assert response.status_code == 200 41 | assert response.json() == new_book 42 | -------------------------------------------------------------------------------- /extras/next_steps/test_teardown_only_fixture.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | 5 | # A teardown-only fixture requires a little creativity with pytest's fixtures. The trick is to 6 | # make the fixture yield a list and have the test add the things you want to teardown to that 7 | # list. After the test completes, the fixture will run the code after the yield. 8 | # 9 | # To make sure the teardown happens, it's best to update the fixture's list as soon as possible. 10 | # If for example you do it at the end of the test, any failing assert means the list does not get 11 | # updated and the teardown is not executed for the item that would be added at the end of the test. 12 | 13 | 14 | @pytest.fixture 15 | def books_to_delete(): 16 | books_to_delete = [] 17 | yield books_to_delete 18 | 19 | for book in books_to_delete: 20 | user = 'bob' 21 | response = requests.post(f'http://localhost:8000/token/{user}') 22 | token = response.json()['token'] 23 | response = requests.delete(f'http://localhost:8000/books/{book}', 24 | headers={'user': user, 'token': token}) 25 | assert response.status_code == 200 26 | 27 | 28 | def test_create_a_book(books_to_delete): 29 | new_book = { 30 | "author": "Neil Gaiman", 31 | "pages": 299, 32 | "publisher": "W.W. Norton & Company", 33 | "sub_title": None, 34 | "title": "Norse Mythology", 35 | "year": 2017 36 | } 37 | 38 | response = requests.post('http://localhost:8000/books', json=new_book) 39 | assert response.status_code == 201 40 | 41 | books_to_delete.append(response.json()['id']) 42 | 43 | # ToDo: add additional asserts/validatrions here 44 | -------------------------------------------------------------------------------- /extras/next_steps/test_with_testdata_from_file.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | import yaml # package name when installing is pyyaml 4 | 5 | 6 | # The fixture below will open the testdata.yml file and parse it with pyyaml. The result is a Python dictionary 7 | # which is used in the test to create a book. 8 | # Using the `with` keyword when dealing with files, is a good practice: it makes sure that the file is properly 9 | # closed, even if exceptions are raised. 10 | # 11 | # PyYAML docs: https://pyyaml.org/wiki/PyYAMLDocumentation 12 | # Python docs on files: https://docs.python.org/2/tutorial/inputoutput.html#reading-and-writing-files and 13 | # https://docs.python.org/3/tutorial/inputoutput.html#reading-and-writing-files and 14 | 15 | 16 | @pytest.fixture 17 | def test_data(): 18 | with open('./extras/next_steps/testdata.yml') as testdata: 19 | return yaml.full_load(testdata) 20 | 21 | 22 | def test_something(test_data): 23 | response = requests.post('http://localhost:8000/books', json=test_data['books']['book_1']) 24 | assert response.status_code == 201 25 | assert response.json()['id'] is not None 26 | -------------------------------------------------------------------------------- /extras/next_steps/testdata.yml: -------------------------------------------------------------------------------- 1 | books: 2 | book_1: 3 | author: Nicole Forsgren, Jez Humble, Gene Kim 4 | pages: 257 5 | publisher: IT Revolution 6 | sub_title: Building and scaling high performing technology organizations 7 | title: Accelerate 8 | year: 2018 -------------------------------------------------------------------------------- /extras/same_test_different_tools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j19sch/building-an-api-testing-framework/47c36d76c8c7084aedd16911f310730fe4fea553/extras/same_test_different_tools/__init__.py -------------------------------------------------------------------------------- /extras/same_test_different_tools/behave/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j19sch/building-an-api-testing-framework/47c36d76c8c7084aedd16911f310730fe4fea553/extras/same_test_different_tools/behave/__init__.py -------------------------------------------------------------------------------- /extras/same_test_different_tools/behave/features/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j19sch/building-an-api-testing-framework/47c36d76c8c7084aedd16911f310730fe4fea553/extras/same_test_different_tools/behave/features/__init__.py -------------------------------------------------------------------------------- /extras/same_test_different_tools/behave/features/example.feature: -------------------------------------------------------------------------------- 1 | # -- FILE: features/example.feature 2 | Feature: Delete 3 | 4 | Scenario: Deleting a book 5 | Given we got all the books 6 | And we have valid credentials 7 | When we delete one book 8 | Then we get a 200 response 9 | And the book is no longer present -------------------------------------------------------------------------------- /extras/same_test_different_tools/behave/features/steps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j19sch/building-an-api-testing-framework/47c36d76c8c7084aedd16911f310730fe4fea553/extras/same_test_different_tools/behave/features/steps/__init__.py -------------------------------------------------------------------------------- /extras/same_test_different_tools/behave/features/steps/example_steps.py: -------------------------------------------------------------------------------- 1 | # -- FILE: features/steps/example_steps.py 2 | from behave import given, when, then 3 | import requests 4 | 5 | 6 | @given('we got all the books') 7 | def step_impl(context): 8 | response = requests.get("http://localhost:8000/books") 9 | context.books = response.json() 10 | 11 | 12 | @given('we have valid credentials') 13 | def step_impl(context): 14 | context.user = 'user' 15 | response = requests.post(f"http://localhost:8000/token/{context.user}") 16 | context.token = response.json()['token'] 17 | 18 | 19 | @when('we delete one book') 20 | def step_impl(context): 21 | context.response = requests.delete(f"http://localhost:8000/books/{context.books[0]['id']}", 22 | headers={'User': context.user, 'Token': context.token}) 23 | 24 | 25 | @then('we get a 200 response') 26 | def step_impl(context): 27 | assert context.response.status_code == 200, context.response.status_code 28 | 29 | 30 | @then('the book is no longer present') 31 | def step_impl(context): 32 | response = requests.get(f"http://localhost:8000/books/{context.books[0]['id']}") 33 | assert response.status_code == 404 34 | -------------------------------------------------------------------------------- /extras/same_test_different_tools/pytest/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j19sch/building-an-api-testing-framework/47c36d76c8c7084aedd16911f310730fe4fea553/extras/same_test_different_tools/pytest/__init__.py -------------------------------------------------------------------------------- /extras/same_test_different_tools/pytest/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | # deprecated: imp module 4 | ignore::DeprecationWarning -------------------------------------------------------------------------------- /extras/same_test_different_tools/pytest/test_delete_book.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | 5 | @pytest.fixture() 6 | def books(): 7 | books = requests.get('http://localhost:8000/books').json() 8 | return books 9 | 10 | 11 | @pytest.fixture() 12 | def creds(): 13 | user = 'user' 14 | token = requests.post('http://localhost:8000/token/user').json()['token'] 15 | return user, token 16 | 17 | 18 | def test_delete_book(books, creds): 19 | book_to_delete = books[0] 20 | user, token = creds 21 | 22 | response = requests.delete(f'http://localhost:8000/books/{book_to_delete["id"]}', 23 | headers={'user': user, 'token': token}) 24 | assert response.status_code == 200 25 | 26 | response = requests.get(f'http://localhost:8000/books/{book_to_delete["id"]}') 27 | assert response.status_code == 404 28 | -------------------------------------------------------------------------------- /extras/same_test_different_tools/robot-framework/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j19sch/building-an-api-testing-framework/47c36d76c8c7084aedd16911f310730fe4fea553/extras/same_test_different_tools/robot-framework/__init__.py -------------------------------------------------------------------------------- /extras/same_test_different_tools/robot-framework/delete_book.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library Collections 3 | Library RequestsLibrary 4 | 5 | 6 | *** Variables *** 7 | ${user}= bob 8 | 9 | 10 | *** Test Cases *** 11 | Delete book 12 | Create Session ApiApp http://localhost:8000 13 | 14 | ${resp}= GET On Session ApiApp /books 15 | Should Be Equal As Strings ${resp.status_code} 200 16 | Set Test Variable ${book_id} ${resp.json()[0]['id']} 17 | 18 | 19 | ${resp}= POST On Session ApiApp /token/${user} data=None 20 | Set Test Variable ${token} ${resp.json()['token']} 21 | Should Be Equal As Strings ${resp.status_code} 201 22 | 23 | ${headers}= Create Dictionary user=${user} token=${token} 24 | ${resp}= DELETE On Session ApiApp /books/${book_id} headers=${headers} 25 | Should Be Equal As Strings ${resp.status_code} 200 26 | 27 | ${resp}= GET On Session ApiApp /books/${book_id} expected_status=404 28 | Should Be Equal As Strings ${resp.status_code} 404 -------------------------------------------------------------------------------- /extras/same_test_different_tools/tavern/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j19sch/building-an-api-testing-framework/47c36d76c8c7084aedd16911f310730fe4fea553/extras/same_test_different_tools/tavern/__init__.py -------------------------------------------------------------------------------- /extras/same_test_different_tools/tavern/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | # deprecated: imp module, tavern uses private pytest class or function 4 | ignore::DeprecationWarning 5 | -------------------------------------------------------------------------------- /extras/same_test_different_tools/tavern/test_delete.tavern.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | test_name: Delete a book 3 | 4 | stages: 5 | - name: get all the books 6 | request: 7 | url: http://localhost:8000/books 8 | method: GET 9 | response: 10 | status_code: 200 11 | save: 12 | json: 13 | book: '[0] | id' 14 | 15 | - name: get a token 16 | request: 17 | url: http://localhost:8000/token/user 18 | method: POST 19 | response: 20 | status_code: 201 21 | save: 22 | json: 23 | token: token 24 | 25 | - name: delete a book 26 | request: 27 | url: "http://localhost:8000/books/{book}" 28 | method: DELETE 29 | headers: 30 | user: "user" 31 | token: "{token}" 32 | response: 33 | status_code: 200 34 | 35 | - name: check book is gone 36 | request: 37 | url: "http://localhost:8000/books/{book}" 38 | method: GET 39 | response: 40 | status_code: 404 41 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # needed for exercises 2 | falcon==3.0.0 3 | pytest 4 | requests 5 | 6 | # also needed for exercises, uncomment the line releveant to your OS: 7 | # gunciorn==20.0.4 # (linux, mac) 8 | # waitress==2.0.0 # (windows) 9 | 10 | # additionally needed for extras/next_steps: 11 | jsonschema 12 | pyyaml 13 | schema 14 | 15 | 16 | # additionally needed for extras/same_test_different_tools: 17 | behave 18 | robotframework-requests 19 | tavern --------------------------------------------------------------------------------