├── tests ├── resources │ ├── test_invalid.py │ ├── certificates │ │ ├── dump-file.p12 │ │ ├── dump-file.pem │ │ ├── badssl.com-client.p12 │ │ ├── badssl.com-client.pem │ │ └── badssl.com-client-wrong.pem │ ├── data │ │ ├── source2.csv │ │ ├── delimiter_tab.csv │ │ ├── encoding_utf8.csv │ │ ├── unquoted_utf8.csv │ │ ├── delimiter_semicolon.csv │ │ ├── quoted_utf8.csv │ │ ├── source1.csv │ │ ├── source0.csv │ │ ├── quoted_utf16.csv │ │ ├── encoding_utf16.csv │ │ └── unquoted_utf16.csv │ ├── test_setup_errors.py │ ├── test_dummy.py │ ├── test_two_transactions.py │ ├── action_plugin_template.txt │ ├── test_csv_records.py │ ├── test_single_transaction.py │ ├── test_two_readers.py │ ├── test_thread_reader.py │ ├── test_transactions.py │ ├── test_smart_transactions.py │ └── setup_teardown_graceful.py └── unit │ ├── test_cookies.py │ ├── test_api_example.py │ ├── __init__.py │ ├── test_body_data.py │ ├── test_recorder.py │ ├── test_methods.py │ ├── test_utilities.py │ ├── test_action_plugins.py │ ├── test_pytest_plugin.py │ ├── test_assertions.py │ ├── test_transactions.py │ ├── test_ssl_requests.py │ ├── test_samples.py │ ├── test_csv.py │ └── test_loadgen.py ├── setup.cfg ├── apiritif ├── __main__.py ├── __init__.py ├── action_plugins.py ├── utils.py ├── thread.py ├── pytest_plugin.py ├── utilities.py ├── store.py ├── ssl_adapter.py ├── csv.py ├── samples.py ├── loadgen.py └── http.py ├── release.sh ├── requirements.txt ├── .travis.yml ├── Jenkinsfile ├── .gitignore ├── setup.py ├── Changelog.md ├── LICENSE └── README.md /tests/resources/test_invalid.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 -------------------------------------------------------------------------------- /tests/resources/certificates/dump-file.p12: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/resources/certificates/dump-file.pem: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/resources/data/source2.csv: -------------------------------------------------------------------------------- 1 | one 2 | two 3 | three 4 | -------------------------------------------------------------------------------- /tests/resources/data/delimiter_tab.csv: -------------------------------------------------------------------------------- 1 | ac1 bc1 cc1 2 | 1 2 3 3 | -------------------------------------------------------------------------------- /tests/resources/data/encoding_utf8.csv: -------------------------------------------------------------------------------- 1 | ac1,bc1,cc1 2 | 1,2,3 3 | -------------------------------------------------------------------------------- /tests/resources/data/unquoted_utf8.csv: -------------------------------------------------------------------------------- 1 | ac1,bc1,cc1 2 | 1,2,3 3 | -------------------------------------------------------------------------------- /tests/resources/data/delimiter_semicolon.csv: -------------------------------------------------------------------------------- 1 | ac1;bc1;cc1 2 | 1;2;3 3 | -------------------------------------------------------------------------------- /tests/resources/data/quoted_utf8.csv: -------------------------------------------------------------------------------- 1 | "ac1","bc1","cc1" 2 | "1","2","3" 3 | -------------------------------------------------------------------------------- /apiritif/__main__.py: -------------------------------------------------------------------------------- 1 | from .loadgen import main 2 | 3 | 4 | if __name__ == '__main__': 5 | main() 6 | -------------------------------------------------------------------------------- /tests/resources/data/source1.csv: -------------------------------------------------------------------------------- 1 | ze-00 2 | on-11 3 | tu-22 4 | th-33 5 | fo-44 6 | fi-55 7 | si-66 8 | se-77 -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | rm -rf ./dist 4 | python setup.py sdist bdist_wheel 5 | python -m twine upload dist/* 6 | -------------------------------------------------------------------------------- /tests/resources/data/source0.csv: -------------------------------------------------------------------------------- 1 | name,pass 2 | "u,ser0",000 3 | "user1",1 4 | user2,"2" 5 | user3,3 6 | user4,4 7 | user5,5 -------------------------------------------------------------------------------- /tests/resources/data/quoted_utf16.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Blazemeter/apiritif/master/tests/resources/data/quoted_utf16.csv -------------------------------------------------------------------------------- /tests/resources/data/encoding_utf16.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Blazemeter/apiritif/master/tests/resources/data/encoding_utf16.csv -------------------------------------------------------------------------------- /tests/resources/data/unquoted_utf16.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Blazemeter/apiritif/master/tests/resources/data/unquoted_utf16.csv -------------------------------------------------------------------------------- /tests/resources/certificates/badssl.com-client.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Blazemeter/apiritif/master/tests/resources/certificates/badssl.com-client.p12 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | nose2 2 | pytest 3 | requests>=2.24.0 4 | jsonpath-ng 5 | lxml 6 | unicodecsv 7 | cssselect 8 | chardet 9 | pyopenssl 10 | codecov 11 | -------------------------------------------------------------------------------- /tests/unit/test_cookies.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from apiritif import http 4 | 5 | 6 | class TestCookies(TestCase): 7 | def test_cookies(self): 8 | response = http.get('http://httpbin.org/cookies/set?name=value', cookies={"foo": "bar"}) 9 | response.assert_ok() 10 | -------------------------------------------------------------------------------- /tests/resources/test_setup_errors.py: -------------------------------------------------------------------------------- 1 | import math 2 | from unittest import TestCase 3 | 4 | import apiritif 5 | 6 | 7 | def setUpModule(): 8 | raise BaseException 9 | 10 | 11 | class TestSimple(TestCase): 12 | def test_case1(self): 13 | #apiritif.http.get("http://localhost:8003") 14 | with apiritif.transaction("tran name"): 15 | for x in range(1000, 10000): 16 | y = math.sqrt(x) 17 | 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: focal 3 | branches: # this prevents undesired branch builds for PRs 4 | only: 5 | - master 6 | 7 | os: linux 8 | 9 | python: 10 | - "3.7" 11 | - "3.8" 12 | - "3.9" 13 | - "3.10" 14 | 15 | install: 16 | - pip install -r requirements.txt 17 | - pip install codecov nose-exclude nose-timer "pluggy>=1.0" 18 | script: coverage run --source=apiritif -m nose2 -s tests/unit -v 19 | after_success: 20 | - codecov 21 | -------------------------------------------------------------------------------- /tests/unit/test_api_example.py: -------------------------------------------------------------------------------- 1 | from apiritif import http 2 | from unittest import TestCase 3 | 4 | target = http.target("http://blazedemo.com") 5 | target.use_cookies(False) 6 | target.auto_assert_ok(False) 7 | 8 | 9 | class TestSimple(TestCase): 10 | def test_blazedemo_index(self): 11 | response = target.get("/") 12 | response.assert_ok() 13 | 14 | def test_blazedemo_not_found(self): 15 | response = target.get("/not-found") 16 | response.assert_failed() 17 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from urllib3 import disable_warnings 4 | 5 | from apiritif.loadgen import ApiritifPlugin 6 | 7 | disable_warnings() 8 | 9 | RESOURCES_DIR = os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(__file__)) + "/../resources/")) 10 | 11 | 12 | class Recorder(ApiritifPlugin): 13 | configSection = 'recorder-plugin' 14 | alwaysOn = True 15 | 16 | def __init__(self): 17 | super().__init__() 18 | self.session.stop_reason = "" 19 | 20 | -------------------------------------------------------------------------------- /tests/resources/test_dummy.py: -------------------------------------------------------------------------------- 1 | import math 2 | import time 3 | from unittest import TestCase 4 | 5 | import apiritif 6 | 7 | 8 | class TestSimple(TestCase): 9 | def test_case1(self): 10 | #apiritif.http.get("http://localhost:8003") 11 | with apiritif.transaction("tran name"): 12 | for x in range(1000, 10000): 13 | y = math.sqrt(x) 14 | 15 | def test_case2(self): 16 | #apiritif.http.get("http://apc-gw:8080") 17 | for x in range(1, 10): 18 | y = math.sqrt(x) 19 | -------------------------------------------------------------------------------- /tests/unit/test_body_data.py: -------------------------------------------------------------------------------- 1 | from apiritif import http 2 | from unittest import TestCase 3 | 4 | 5 | class TestRequests(TestCase): 6 | def test_body_string(self): 7 | http.post('http://blazedemo.com/', data='MY PERFECT BODY') 8 | 9 | def test_body_json(self): 10 | http.post('http://blazedemo.com/', json={'foo': 'bar'}) 11 | 12 | def test_body_files(self): 13 | http.post('http://blazedemo.com/', files=[('inp_file', ("filename", 'file-contents'))]) 14 | 15 | def test_url_params(self): 16 | http.get('http://blazedemo.com/', params={'foo': 'bar'}) 17 | -------------------------------------------------------------------------------- /tests/resources/test_two_transactions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | import string 4 | import sys 5 | import time 6 | import unittest 7 | 8 | import apiritif 9 | 10 | 11 | class TestTwoTransactions(unittest.TestCase): 12 | 13 | def first(self): 14 | with apiritif.smart_transaction('1st'): 15 | response = apiritif.http.get('https://blazedemo.com/') 16 | response.assert_ok() 17 | 18 | def second(self): 19 | with apiritif.smart_transaction('2nd'): 20 | response = apiritif.http.get('https://blazedemo.com/vacation.html') 21 | 22 | def test_simple(self): 23 | self.first() 24 | self.second() 25 | -------------------------------------------------------------------------------- /tests/resources/action_plugin_template.txt: -------------------------------------------------------------------------------- 1 | from apiritif.action_plugins import BaseActionHandler, ActionHandlerFactory 2 | 3 | 4 | @ActionHandlerFactory.register('local') 5 | class BZMActionHandler(BaseActionHandler): 6 | 7 | def __init__(self, **kwargs): 8 | super().__init__(**kwargs) 9 | self.started = False 10 | self.ended = False 11 | self.actions = [] 12 | 13 | def startup(self): 14 | self.started = True 15 | 16 | def finalize(self): 17 | self.ended = True 18 | 19 | def handle(self, session_id, action_type, action): 20 | self.actions.append((session_id, action_type, action)) 21 | print(f'BZM: {session_id}, {action_type}, {action}') 22 | -------------------------------------------------------------------------------- /tests/resources/test_csv_records.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | import time 3 | import unittest 4 | import apiritif 5 | import os 6 | 7 | reader_1 = apiritif.CSVReaderPerThread(os.path.join(os.path.dirname(__file__), "data/source2.csv"), 8 | fieldnames=['name'], loop=False, quoted=True, delimiter=',') 9 | 10 | 11 | class TestStopByCSVRecords(unittest.TestCase): 12 | 13 | def setUp(self): 14 | self.vars = {} 15 | reader_1.read_vars() 16 | self.vars.update(reader_1.get_vars()) 17 | 18 | def test_stop_by_csv(self): 19 | name = f"{apiritif.thread.get_index()}:{self.vars['name']}" 20 | with apiritif.smart_transaction(name): 21 | time.sleep(0.1) # time for initialization and feeding next VU 22 | -------------------------------------------------------------------------------- /tests/resources/test_single_transaction.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | import string 4 | import sys 5 | import time 6 | import unittest 7 | 8 | import apiritif 9 | 10 | 11 | class TestAPIRequests(unittest.TestCase): 12 | 13 | def test_requests(self): 14 | with apiritif.transaction('blazedemo 123'): 15 | response = apiritif.http.get('https://api.demoblaze.com/entries', allow_redirects=True) 16 | response.assert_jsonpath('$.LastEvaluatedKey.id', expected_value='9') 17 | time.sleep(0.75) 18 | 19 | with apiritif.transaction('blazedemo 456'): 20 | response = apiritif.http.get('https://api.demoblaze.com/entries', allow_redirects=True) 21 | response.assert_jsonpath("$['LastEvaluatedKey']['id']", expected_value='9') 22 | time.sleep(0.75) 23 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | @Library('jenkins_library') _ 2 | pipeline { 3 | agent { 4 | docker { 5 | label 'generalNodes' 6 | image 'us.gcr.io/verdant-bulwark-278/jenkins-docker-agent:master.latest' 7 | } 8 | } 9 | stages { 10 | stage('Install') { 11 | steps { 12 | venv "pip install -r requirements.txt" 13 | venv "pip install codecov nose-exclude nose-timer \"pluggy>=1.0\"" 14 | } 15 | } 16 | stage('Code coverage') { 17 | steps { 18 | venv "coverage run --source=apiritif -m nose2 -s tests/unit -v" 19 | } 20 | } 21 | } 22 | post { 23 | success { 24 | venv "codecov" 25 | } 26 | always { 27 | cleanWs() 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /apiritif/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a toplevel package of Apiritif tool 3 | Copyright 2017 BlazeMeter Inc. 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | from .csv import CSVReaderPerThread 16 | from .thread import put_into_thread_store, get_from_thread_store, external_handler, get_stage, set_stage 17 | from .thread import get_transaction_handlers, set_transaction_handlers, get_iteration 18 | from .http import http, transaction, transaction_logged, smart_transaction, recorder 19 | from .http import Event, TransactionStarted, TransactionEnded, Request, Assertion, AssertionFailure 20 | from .utilities import * 21 | from .utils import headers_as_text, assert_regexp, assert_not_regexp, log 22 | -------------------------------------------------------------------------------- /tests/resources/test_two_readers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | 5 | import apiritif 6 | from apiritif.thread import get_index 7 | from apiritif.csv import CSVReaderPerThread 8 | 9 | reader0 = CSVReaderPerThread(os.path.join(os.path.dirname(__file__), "data/source0.csv"), quoted=True) 10 | reader1 = CSVReaderPerThread(os.path.join(os.path.dirname(__file__), "data/source1.csv"), 11 | fieldnames=["name+", "pass+"], 12 | delimiter="-") 13 | 14 | 15 | def log_it(name, data): 16 | variables = ":".join((data["name"], data["pass"], data["name+"], data["pass+"])) 17 | log_line = "%s-%s. %s\n" % (get_index(), name, variables) 18 | with apiritif.transaction(log_line): # write log_line into report file for checking purposes 19 | pass 20 | 21 | 22 | def setUpModule(): # setup_module 23 | reader0.read_vars() 24 | reader1.read_vars() 25 | 26 | 27 | class Test1(unittest.TestCase): 28 | def setUp(self): 29 | self.vars = {} 30 | self.vars.update(reader0.get_vars()) 31 | self.vars.update(reader1.get_vars()) 32 | 33 | def test_0(self): 34 | log_it("0", self.vars) 35 | reader1.read_vars() 36 | 37 | def test_1(self): 38 | log_it("1", self.vars) 39 | -------------------------------------------------------------------------------- /tests/resources/test_thread_reader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | 5 | import apiritif 6 | from apiritif.thread import get_index 7 | 8 | reader_1 = apiritif.CSVReaderPerThread(os.path.join(os.path.dirname(__file__), "data/source0.csv")) 9 | 10 | 11 | def log_it(name, target, data): 12 | log_line = "%s[%s]-%s. %s:%s:%s\n" % (get_index(), target, name, data["name"], data["pass"], data["age"]) 13 | with apiritif.transaction(log_line): # write log_line into report file for checking purposes 14 | pass 15 | 16 | 17 | def setUpModule(): # setup_module 18 | target = str(get_index()) 19 | 20 | vars = { 21 | 'name': 'nobody', 22 | 'age': 'a' 23 | } 24 | reader_1.read_vars() 25 | vars.update(reader_1.get_vars()) 26 | 27 | apiritif.put_into_thread_store(vars, target) 28 | 29 | 30 | # class Test0(unittest.TestCase): 31 | # def test_00(self): 32 | # log_it("00", reader_1.get_vars()) 33 | 34 | 35 | class Test1(unittest.TestCase): 36 | def setUp(self): 37 | self.vars, self.target = apiritif.get_from_thread_store() 38 | 39 | def test_10(self): 40 | log_it("10", self.target, self.vars) 41 | self.vars["name"] += "+" 42 | 43 | def test_11(self): 44 | log_it("11", self.target, self.vars) 45 | -------------------------------------------------------------------------------- /tests/resources/test_transactions.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import apiritif 4 | 5 | 6 | class TestTransactions(TestCase): 7 | def test_1_single_transaction(self): 8 | with apiritif.transaction("single-transaction"): 9 | pass 10 | 11 | def test_2_two_transactions(self): 12 | with apiritif.transaction("transaction-1"): 13 | pass 14 | with apiritif.transaction("transaction-2"): 15 | pass 16 | 17 | def test_3_nested_transactions(self): 18 | with apiritif.transaction("outer"): 19 | with apiritif.transaction("inner"): 20 | pass 21 | 22 | def test_4_no_transactions(self): 23 | pass 24 | 25 | def test_5_apiritif_assertions(self): 26 | apiritif.http.get("http://blazedemo.com/").assert_ok() 27 | 28 | def test_6_apiritif_assertions_failed(self): 29 | apiritif.http.get("http://blazedemo.com/").assert_failed() # this assertion intentionally fails 30 | 31 | def test_7_failed_request(self): 32 | apiritif.http.get("http://notexists") 33 | 34 | def test_8_assertion_trace_problem(self): 35 | resp = apiritif.http.get("http://blazedemo.com/") 36 | resp.assert_regex_in_body(".*") 37 | resp.assert_regex_in_body(".+") 38 | resp.assert_regex_in_body("no way this exists in body") 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | .idea/ 91 | /*.iml 92 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2017 BlazeMeter Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | from setuptools import setup 18 | 19 | from apiritif.utils import VERSION 20 | 21 | with open("requirements.txt") as reqs_file: 22 | requirements = [package for package in reqs_file.read().strip().split("\n")] 23 | 24 | setup( 25 | name="apiritif", 26 | packages=['apiritif'], 27 | version=VERSION, 28 | description='Python framework for API testing', 29 | long_description=open('README.md').read(), 30 | long_description_content_type='text/markdown', 31 | license='Apache 2.0', 32 | platform='any', 33 | author='Dmitri Pribysh', 34 | author_email='pribysh@blazemeter.com', 35 | url='https://github.com/Blazemeter/apiritif', 36 | download_url='https://github.com/Blazemeter/apiritif', 37 | docs_url='https://github.com/Blazemeter/apiritif', 38 | install_requires=requirements, 39 | entry_points={ 40 | 'pytest11': [ 41 | 'pytest_apiritif = apiritif.pytest_plugin', 42 | ] 43 | }, 44 | ) 45 | -------------------------------------------------------------------------------- /tests/resources/test_smart_transactions.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | import unittest 3 | import apiritif 4 | from apiritif import get_transaction_handlers, set_transaction_handlers 5 | 6 | 7 | def add_dummy_handlers(): 8 | handlers = get_transaction_handlers() 9 | handlers["enter"].append(_enter_handler) 10 | handlers["exit"].append(_exit_handler) 11 | set_transaction_handlers(handlers) 12 | 13 | 14 | def _enter_handler(): 15 | pass 16 | 17 | 18 | def _exit_handler(): 19 | pass 20 | 21 | 22 | class Driver(object): 23 | def get(self, addr): 24 | pass 25 | 26 | def quit(self): 27 | pass 28 | 29 | 30 | class TestSmartTransactions(unittest.TestCase): 31 | def setUp(self): 32 | self.driver = None 33 | self.driver = Driver() 34 | add_dummy_handlers() 35 | self.vars = { 36 | 37 | } 38 | 39 | apiritif.put_into_thread_store( 40 | driver=self.driver, 41 | func_mode=False) # don't stop after failed test case 42 | 43 | def _1_t1(self): 44 | with apiritif.smart_transaction('t1'): 45 | self.driver.get('addr1') 46 | 47 | def _2_t2(self): 48 | with apiritif.smart_transaction('t2'): 49 | self.driver.get('addr2') 50 | 51 | def _3_t3(self): 52 | with apiritif.smart_transaction('t3'): 53 | self.driver.get('addr3') 54 | 55 | def test_smartTransactions(self): 56 | self._1_t1() 57 | self._2_t2() 58 | #self._3_t3() 59 | 60 | def tearDown(self): 61 | if self.driver: 62 | self.driver.quit() 63 | -------------------------------------------------------------------------------- /tests/unit/test_recorder.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | import sys 4 | 5 | from unittest import TestCase 6 | from apiritif.http import _EventRecorder, Event 7 | 8 | 9 | class EventGenerator(threading.Thread): 10 | def __init__(self, recorder, index, events_count): 11 | self.recorder = recorder 12 | self.index = index 13 | self.events_count = events_count 14 | self.events = [Event(response=self.index * (i + 1)) for i in range(self.events_count)] 15 | 16 | super(EventGenerator, self).__init__(target=self._record_events) 17 | 18 | def _record_events(self): 19 | for event in self.events: 20 | self.recorder.record_event(event) 21 | time.sleep(0.1) 22 | 23 | self.result_events = self.recorder.pop_events(from_ts=-1, to_ts=sys.maxsize) 24 | 25 | 26 | class TestRecorder(TestCase): 27 | 28 | # _EventRecorder class have to store separate events for separate thread. 29 | # Here each fake thread (EventGenerator) generate an event for common recorder. 30 | # Then each thread read this event from this recorder with some delay. 31 | # As the result written and read events should be the same. 32 | def test_recorder_events_per_thread(self): 33 | recorder = _EventRecorder() 34 | event_generators = [EventGenerator(recorder, i, 3) for i in range(5)] 35 | 36 | for generator in event_generators: 37 | generator.start() 38 | for generator in event_generators: 39 | generator.join() 40 | for generator in event_generators: 41 | self.assertEqual(generator.events, generator.result_events) 42 | -------------------------------------------------------------------------------- /tests/unit/test_methods.py: -------------------------------------------------------------------------------- 1 | from apiritif import http 2 | from unittest import TestCase 3 | 4 | 5 | class TestHTTPMethods(TestCase): 6 | def test_get(self): 7 | http.get('https://blazedemo.com/?tag=get') 8 | 9 | def test_post(self): 10 | http.post('https://blazedemo.com/?tag=post') 11 | 12 | def test_put(self): 13 | http.put('https://blazedemo.com/?tag=put') 14 | 15 | def test_patch(self): 16 | http.patch('https://blazedemo.com/?tag=patch') 17 | 18 | def test_head(self): 19 | http.head('https://blazedemo.com/?tag=head') 20 | 21 | def test_delete(self): 22 | http.delete('https://blazedemo.com/?tag=delete') 23 | 24 | def test_options(self): 25 | http.options('https://blazedemo.com/echo.php?echo=options') 26 | 27 | 28 | class TestTargetMethods(TestCase): 29 | def setUp(self): 30 | self.target = http.target('https://blazedemo.com', auto_assert_ok=False) 31 | 32 | def test_get(self): 33 | self.target.get('/echo.php?echo=get').assert_ok() 34 | 35 | def test_post(self): 36 | self.target.post('/echo.php?echo=post').assert_ok() 37 | 38 | def test_put(self): 39 | self.target.put('/echo.php?echo=put').assert_ok() 40 | 41 | def test_patch(self): 42 | self.target.patch('/echo.php?echo=patch').assert_ok() 43 | 44 | def test_delete(self): 45 | self.target.delete('/echo.php?echo=delete').assert_ok() 46 | 47 | def test_head(self): 48 | self.target.head('/echo.php?echo=head').assert_ok() 49 | 50 | def test_options(self): 51 | self.target.options('/echo.php?echo=options').assert_ok() 52 | 53 | def test_connect(self): 54 | self.target.connect('/echo.php?echo=connect') 55 | -------------------------------------------------------------------------------- /tests/unit/test_utilities.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import datetime 3 | 4 | import re 5 | 6 | from apiritif import utilities 7 | 8 | 9 | class TestUtilities(unittest.TestCase): 10 | def test_random_uniform(self): 11 | for _ in range(1000): 12 | random = utilities.random_uniform(1, 10) 13 | self.assertTrue(1 <= random < 10) 14 | 15 | def test_random_normal(self): 16 | for _ in range(1000): 17 | random = utilities.random_gauss(0, 1) 18 | self.assertTrue(-5 <= random < 5) 19 | 20 | def test_random_string(self): 21 | for _ in range(1000): 22 | random_str = utilities.random_string(10) 23 | self.assertEqual(10, len(random_str)) 24 | self.assertIsInstance(random_str, str) 25 | 26 | def test_random_string_chars(self): 27 | hex_chars = "0123456789abcdef" 28 | for _ in range(1000): 29 | random_hex = utilities.random_string(5, chars=hex_chars) 30 | for char in random_hex: 31 | self.assertIn(char, random_hex) 32 | 33 | def test_format_date(self): 34 | timestamp = datetime(2010, 12, 19, 20, 5, 30) 35 | formatted = utilities.format_date("dd/MM/yyyy HH:mm:ss", timestamp) 36 | self.assertEqual("19/12/2010 20:05:30", formatted) 37 | 38 | def test_format_date_epoch(self): 39 | timestamp = datetime.fromtimestamp(1292789130) 40 | formatted = utilities.format_date(datetime_obj=timestamp) 41 | self.assertEqual("1292789130000", formatted) 42 | 43 | def test_base64_encode(self): 44 | self.assertEqual(utilities.base64_encode('foobar'), 'Zm9vYmFy') 45 | 46 | def test_base64_decode(self): 47 | self.assertEqual(utilities.base64_decode('Zm9vYmFy'), 'foobar') 48 | 49 | def test_encode_url(self): 50 | self.assertEqual(utilities.encode_url('Foo Bar'), 'Foo+Bar') 51 | 52 | def test_uuid(self): 53 | uuid = utilities.uuid() 54 | match = re.match(r'^[0-9a-z\-]+$', uuid) 55 | self.assertIsNotNone(match) 56 | -------------------------------------------------------------------------------- /apiritif/action_plugins.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from abc import ABCMeta, abstractmethod 4 | from importlib import import_module 5 | from pathlib import Path 6 | from pkgutil import iter_modules 7 | 8 | from apiritif.utils import log 9 | 10 | PLUGINS_PATH = 'PLUGINS_PATH' 11 | 12 | 13 | def import_plugins(): 14 | path = os.environ.get(PLUGINS_PATH, None) 15 | if not path: 16 | log.debug('Plugins PATH not found, continue without plugins') 17 | return 18 | 19 | # add plugins path to PYTHONPATH 20 | sys.path.append(path) 21 | 22 | package = Path(path).resolve().name 23 | log.info(f'Plugins package {package}') 24 | 25 | # modules listing in the root package 26 | for (_, module_name, _) in iter_modules([path]): 27 | log.info(f'Importing module {module_name}') 28 | import_module(module_name) 29 | 30 | 31 | class BaseActionHandler(metaclass=ABCMeta): 32 | 33 | YAML_ACTION_START = 'yaml_action_start' 34 | YAML_ACTION_END = 'yaml_action_end' 35 | TEST_CASE_START = 'test_case_start' 36 | TEST_CASE_STOP = 'test_case_stop' 37 | 38 | def __init__(self, **kwargs): 39 | pass 40 | 41 | @abstractmethod 42 | def startup(self): 43 | pass 44 | 45 | @abstractmethod 46 | def handle(self, session_id, action_type, action): 47 | pass 48 | 49 | @abstractmethod 50 | def finalize(self): 51 | pass 52 | 53 | 54 | class ActionHandlerFactory: 55 | registry = {} 56 | 57 | @classmethod 58 | def register(cls, name): 59 | def inner_wrapper(wrapped_class): 60 | cls.registry[name] = wrapped_class 61 | return wrapped_class 62 | 63 | return inner_wrapper 64 | 65 | @classmethod 66 | def create_handler(cls, name, **kwargs): 67 | if name not in cls.registry: 68 | log.warning('Handler %s does not exist in the registry', name) 69 | return 70 | 71 | exec_class = cls.registry[name] 72 | return exec_class(**kwargs) 73 | 74 | @classmethod 75 | def create_all(cls, **kwargs): 76 | return [cls.create_handler(name, **kwargs) for name in cls.registry] 77 | -------------------------------------------------------------------------------- /apiritif/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2017 BlazeMeter Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | import os 17 | import sys 18 | import re 19 | import logging 20 | import traceback 21 | 22 | VERSION = "1.1.3" 23 | 24 | log = logging.getLogger('apiritif') 25 | 26 | 27 | def get_trace(error): 28 | if sys.version > '3': 29 | # noinspection PyArgumentList 30 | exct, excv, trace = error 31 | if isinstance(excv, str): 32 | excv = exct(excv) 33 | lines = traceback.format_exception(exct, excv, trace, chain=True) 34 | else: 35 | lines = traceback.format_exception(*error) 36 | return ''.join(lines).rstrip() 37 | 38 | 39 | def graceful(): 40 | graceful_file_name = os.environ.get('GRACEFUL') 41 | graceful_flag = graceful_file_name and os.path.exists(graceful_file_name) 42 | return graceful_flag 43 | 44 | class NormalShutdown(BaseException): 45 | pass 46 | 47 | 48 | def headers_as_text(headers_dict): 49 | return "\n".join("%s: %s" % (key, value) for key, value in headers_dict.items()) 50 | 51 | 52 | def shorten(string, upto, end_with="..."): 53 | return string[:upto - len(end_with)] + end_with if len(string) > upto else string 54 | 55 | 56 | def assert_regexp(regex, text, match=False, msg=None): 57 | if match: 58 | if re.match(regex, text) is None: 59 | msg = msg or "Regex %r didn't match expected value: %r" % (regex, shorten(text, 100)) 60 | raise AssertionError(msg) 61 | else: 62 | if not re.findall(regex, text): 63 | msg = msg or "Regex %r didn't find anything in text %r" % (regex, shorten(text, 100)) 64 | raise AssertionError(msg) 65 | 66 | 67 | def assert_not_regexp(regex, text, match=False, msg=None): 68 | if match: 69 | if re.match(regex, text) is not None: 70 | msg = msg or "Regex %r unexpectedly matched expected value: %r" % (regex, shorten(text, 100)) 71 | raise AssertionError(msg) 72 | else: 73 | if re.findall(regex, text): 74 | msg = msg or "Regex %r unexpectedly found something in text %r" % (regex, shorten(text, 100)) 75 | raise AssertionError(msg) 76 | -------------------------------------------------------------------------------- /tests/unit/test_action_plugins.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import TestCase 3 | import tempfile 4 | import shutil 5 | 6 | import apiritif 7 | from apiritif import thread 8 | 9 | from apiritif.action_plugins import PLUGINS_PATH, import_plugins, ActionHandlerFactory, BaseActionHandler 10 | from apiritif.loadgen import Worker, Params 11 | from tests.unit import RESOURCES_DIR 12 | from tests.unit.test_loadgen import dummy_tests 13 | 14 | 15 | class TestRequests(TestCase): 16 | 17 | def setUp(self): 18 | # create temp python package 19 | self.temp_dir = tempfile.TemporaryDirectory() 20 | plugin_dir = os.path.join(self.temp_dir.name, 'plugins') 21 | os.mkdir(plugin_dir) 22 | 23 | # create __init__.py file 24 | init_file = os.path.join(plugin_dir, '../__init__.py') 25 | open(init_file, 'a').close() 26 | 27 | # create plugin file 28 | template_path = os.path.join(RESOURCES_DIR, "action_plugin_template.txt") 29 | action_plugin = os.path.join(plugin_dir, 'bzm_logger.py') 30 | shutil.copyfile(template_path, action_plugin) 31 | 32 | # add path to env vars 33 | os.environ[PLUGINS_PATH] = plugin_dir 34 | import_plugins() 35 | 36 | def tearDown(self): 37 | if os.environ.get(PLUGINS_PATH): 38 | del os.environ[PLUGINS_PATH] 39 | 40 | def test_flow(self): 41 | plugins = ActionHandlerFactory.create_all() 42 | self.assertEquals(1, len(plugins)) 43 | plugin = plugins.pop(0) 44 | plugin.startup() 45 | plugin.handle('session_id', BaseActionHandler.YAML_ACTION_START, 'data') 46 | plugin.finalize() 47 | 48 | self.assertTrue(plugin.started) 49 | self.assertTrue(plugin.ended) 50 | self.assertEquals( 51 | ('session_id', BaseActionHandler.YAML_ACTION_START, 'data'), 52 | plugin.actions.pop() 53 | ) 54 | 55 | def test_external_handler(self): 56 | plugins = ActionHandlerFactory.create_all() 57 | apiritif.put_into_thread_store(action_handlers=plugins) 58 | apiritif.external_handler('session_id', BaseActionHandler.YAML_ACTION_START, 'data') 59 | plugin = plugins.pop(0) 60 | self.assertEquals( 61 | ('session_id', BaseActionHandler.YAML_ACTION_START, 'data'), 62 | plugin.actions.pop() 63 | ) 64 | 65 | def test_loadgen(self): 66 | params = Params() 67 | params.iterations = 1 68 | params.concurrency = 1 69 | params.report = 'log.ldjson' 70 | params.tests = dummy_tests 71 | worker = Worker(params) 72 | worker.run_nose(params) 73 | action_handlers = thread.get_from_thread_store('action_handlers') 74 | plugin = action_handlers.pop(0) 75 | self.assertTrue(plugin.started) 76 | self.assertTrue(plugin.ended) 77 | -------------------------------------------------------------------------------- /tests/unit/test_pytest_plugin.py: -------------------------------------------------------------------------------- 1 | import json 2 | import tempfile 3 | from collections import namedtuple 4 | from contextlib import contextmanager 5 | from unittest import TestCase 6 | 7 | from _pytest.config import PytestPluginManager 8 | from _pytest.config.argparsing import Parser 9 | from _pytest.nodes import Node 10 | from _pytest.reports import TestReport 11 | from _pytest.runner import CallInfo 12 | from pluggy._result import _Result 13 | 14 | import apiritif 15 | from apiritif import http 16 | from apiritif.pytest_plugin import pytest_addoption, pytest_configure, pytest_unconfigure, ApiritifPytestPlugin 17 | 18 | ctype = namedtuple("config", ["option", "pluginmanager", "getoption"]) 19 | otype = namedtuple("option", ["apiritif_trace", "apiritif_trace_detail"]) 20 | 21 | 22 | @contextmanager 23 | def fake_process(trace_fname): 24 | config = ctype(otype(trace_fname, 4), PytestPluginManager(), lambda x, y: 0) 25 | plugin = ApiritifPytestPlugin(config) 26 | next(plugin.pytest_runtest_setup(None)) 27 | 28 | yield 29 | 30 | node = Node._create("test", nodeid="tst", config=config, session="some") 31 | node._report_sections = [] 32 | node.location = [] 33 | node.user_properties = [] 34 | call = CallInfo.from_call(lambda: 1, 'call') 35 | report = TestReport.from_item_and_call(node, call) 36 | result = _Result(report, None) 37 | gen = plugin.pytest_runtest_makereport(node, call) 38 | next(gen) 39 | try: 40 | gen.send(result) 41 | except StopIteration: 42 | pass 43 | 44 | plugin.pytest_sessionfinish(None) 45 | 46 | 47 | class TestHTTPMethods(TestCase): 48 | def test_addoption(self): 49 | parser = Parser() 50 | pytest_addoption(parser) 51 | 52 | def test_configure_none(self): 53 | config = ctype(otype(None, 1), PytestPluginManager(), lambda x, y: 0) 54 | pytest_configure(config) 55 | pytest_unconfigure(config) 56 | 57 | def test_configure_some(self): 58 | config = ctype(otype("somefile", 1), PytestPluginManager(), lambda x, y: 0) 59 | pytest_configure(config) 60 | pytest_unconfigure(config) 61 | 62 | def test_flow_mindetail(self): 63 | tmp = tempfile.NamedTemporaryFile() 64 | tmp.close() 65 | 66 | with fake_process(tmp.name): 67 | with apiritif.transaction("tran"): 68 | pass 69 | 70 | with open(tmp.name) as fp: 71 | data = json.load(fp) 72 | 73 | self.assertNotEqual({}, data) 74 | 75 | def test_flow_maxdetail(self): 76 | tmp = tempfile.NamedTemporaryFile() 77 | tmp.close() 78 | 79 | with fake_process(tmp.name): 80 | with apiritif.transaction("tran") as tran: 81 | tran.set_request(bytes("test", 'utf8')) 82 | 83 | http.post('http://httpbin.org/post', data=bytes([0xa0, 1, 2, 3]), 84 | headers={'Content-Type': 'application/octet-stream'}) 85 | 86 | with open(tmp.name) as fp: 87 | data = json.load(fp) 88 | 89 | self.assertNotEqual({}, data) 90 | -------------------------------------------------------------------------------- /tests/resources/setup_teardown_graceful.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | import logging 4 | import random 5 | import string 6 | import sys 7 | import unittest 8 | from time import time, sleep 9 | import os 10 | import apiritif 11 | 12 | 13 | def write(line): 14 | test_result = apiritif.get_from_thread_store('test_result') 15 | test_result.append(line) 16 | apiritif.put_into_thread_store(test_result=test_result) 17 | 18 | 19 | class TestSc1(unittest.TestCase): 20 | 21 | def setUp(self): 22 | self.vars = {} 23 | timeout = 2.0 24 | self.graceful_flag = os.environ.get('GRACEFUL') 25 | apiritif.put_into_thread_store(test_result=[]) 26 | if self.graceful_flag and os.path.exists(self.graceful_flag): 27 | os.remove(self.graceful_flag) 28 | 29 | apiritif.put_into_thread_store(timeout=timeout, func_mode=True, scenario_name='sc1') 30 | 31 | def tearDown(self): 32 | if self.graceful_flag and os.path.exists(self.graceful_flag): 33 | os.remove(self.graceful_flag) 34 | 35 | def _1_httpsblazedemocomsetup1(self): 36 | with apiritif.smart_transaction('https://blazedemo.com/setup1'): 37 | write('1. setup1') 38 | response = apiritif.http.get('https://blazedemo.com/setup2', timeout=2.0) 39 | 40 | def _1_httpsblazedemocomsetup2(self): 41 | with apiritif.smart_transaction('https://blazedemo.com/setup2'): 42 | write('2. setup2') 43 | response = apiritif.http.get('https://blazedemo.com/setup2', timeout=2.0) 44 | 45 | def _2_httpsblazedemocommain1(self): 46 | with apiritif.smart_transaction('https://blazedemo.com/main1'): 47 | write('3. main1') 48 | response = apiritif.http.get('https://blazedemo.com/main1', timeout=2.0) 49 | 50 | def _2_httpsblazedemocommain2(self): 51 | with apiritif.smart_transaction('https://bad_url.com/main2'): 52 | write('4. main2') 53 | with open(self.graceful_flag, 'a+') as _f: 54 | _f.write('') 55 | response = apiritif.http.get('https://blazedemo.com/main2', timeout=2.0) 56 | 57 | def _2_httpsblazedemocommain3(self): 58 | with apiritif.smart_transaction('https://blazedemo.com/main3'): 59 | write('XXX. main3') 60 | response = apiritif.http.get('https://blazedemo.com/main3', timeout=2.0) 61 | 62 | def _3_httpsblazedemocomteardown1(self): 63 | with apiritif.smart_transaction('https://blazedemo.com/teardown1'): 64 | write('5. teardown1') 65 | response = apiritif.http.get('https://blazedemo.com/teardown1', timeout=2.0) 66 | 67 | def _3_httpsblazedemocomteardown2(self): 68 | with apiritif.smart_transaction('https://blazedemo.com/teardown2'): 69 | write('6. teardown2') 70 | response = apiritif.http.get('https://blazedemo.com/teardown2', timeout=2.0) 71 | 72 | def test_sc1(self): 73 | try: 74 | self._1_httpsblazedemocomsetup1() 75 | self._1_httpsblazedemocomsetup2() 76 | self._2_httpsblazedemocommain1() 77 | self._2_httpsblazedemocommain2() 78 | self._2_httpsblazedemocommain3() 79 | finally: 80 | apiritif.set_stage("teardown") 81 | self._3_httpsblazedemocomteardown1() 82 | self._3_httpsblazedemocomteardown2() 83 | -------------------------------------------------------------------------------- /apiritif/thread.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright 2019 BlazeMeter Inc. 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | """ 17 | from threading import local 18 | 19 | from apiritif.action_plugins import BaseActionHandler 20 | 21 | _total = 1 22 | _thread_local = local() 23 | 24 | 25 | def set_total(total): 26 | global _total 27 | _total = total 28 | 29 | 30 | def get_total(): 31 | global _total 32 | return _total 33 | 34 | 35 | def set_index(index): 36 | _thread_local.index = index 37 | 38 | 39 | def get_index(): 40 | index = getattr(_thread_local, "index", 0) 41 | return index 42 | 43 | 44 | def set_iteration(iteration): 45 | _thread_local.iteration = iteration 46 | 47 | 48 | def get_iteration(): 49 | iteration = getattr(_thread_local, "iteration", 0) 50 | return iteration 51 | 52 | 53 | def get_stage(): 54 | return get_from_thread_store("stage") 55 | 56 | 57 | def set_stage(stage): 58 | put_into_thread_store(stage=stage) 59 | 60 | 61 | def put_into_thread_store(*args, **kwargs): 62 | if args: 63 | _thread_local.args = args 64 | if kwargs: 65 | current_kwargs = getattr(_thread_local, "kwargs", {}) 66 | current_kwargs.update(kwargs) 67 | _thread_local.kwargs = current_kwargs 68 | 69 | 70 | def get_from_thread_store(names=None): 71 | if names and hasattr(_thread_local, "kwargs"): 72 | only_one = False 73 | if isinstance(names, str): 74 | names = [names] 75 | only_one = True 76 | kwargs = [_thread_local.kwargs.get(key) for key in names] 77 | if only_one: 78 | return kwargs[0] 79 | else: 80 | return kwargs 81 | 82 | elif hasattr(_thread_local, "args"): 83 | return _thread_local.args 84 | 85 | 86 | def get_transaction_handlers(): 87 | transaction_handlers = get_from_thread_store('transaction_handlers') 88 | return transaction_handlers 89 | 90 | 91 | def clean_transaction_handlers(): 92 | handlers = {'enter': [], 'exit': []} 93 | _thread_local.kwargs["transaction_handlers"] = handlers 94 | 95 | 96 | def set_transaction_handlers(handlers): 97 | put_into_thread_store(transaction_handlers=handlers) 98 | 99 | 100 | def external_handler(session_id, action_type, action): 101 | for handler in get_action_handlers(): 102 | if isinstance(handler, BaseActionHandler): 103 | handler.handle(session_id, action_type, action) 104 | 105 | 106 | def get_action_handlers(): 107 | return get_from_thread_store("action_handlers") 108 | 109 | 110 | # Deprecated 111 | def external_log(log_line): 112 | pass 113 | 114 | 115 | # Deprecated 116 | def set_logging_handlers(handlers): 117 | pass 118 | 119 | 120 | # Deprecated 121 | def get_logging_handlers(): 122 | pass 123 | 124 | 125 | # Deprecated 126 | def add_logging_handlers(methods=None): 127 | pass 128 | -------------------------------------------------------------------------------- /tests/resources/certificates/badssl.com-client.pem: -------------------------------------------------------------------------------- 1 | Bag Attributes 2 | localKeyID: 77 23 D2 3A C0 2C 87 E2 AD 98 3F 06 68 F2 54 33 B6 05 0E FE 3 | subject=/C=US/ST=California/L=San Francisco/O=BadSSL/CN=BadSSL Client Certificate 4 | issuer=/C=US/ST=California/L=San Francisco/O=BadSSL/CN=BadSSL Client Root Certificate Authority 5 | -----BEGIN CERTIFICATE----- 6 | MIIEnTCCAoWgAwIBAgIJAPYAapdmy98xMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNV 7 | BAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNp 8 | c2NvMQ8wDQYDVQQKDAZCYWRTU0wxMTAvBgNVBAMMKEJhZFNTTCBDbGllbnQgUm9v 9 | dCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMjExMjA0MDAwODE5WhcNMjMxMjA0 10 | MDAwODE5WjBvMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQG 11 | A1UEBwwNU2FuIEZyYW5jaXNjbzEPMA0GA1UECgwGQmFkU1NMMSIwIAYDVQQDDBlC 12 | YWRTU0wgQ2xpZW50IENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A 13 | MIIBCgKCAQEAxzdfEeseTs/rukjly6MSLHM+Rh0enA3Ai4Mj2sdl31x3SbPoen08 14 | utVhjPmlxIUdkiMG4+ffe7N+JtDLG75CaxZp9CxytX7kywooRBJsRnQhmQPca8MR 15 | WAJBIz+w/L+3AFkTIqWBfyT+1VO8TVKPkEpGdLDovZOmzZAASi9/sj+j6gM7AaCi 16 | DeZTf2ES66abA5pOp60Q6OEdwg/vCUJfarhKDpi9tj3P6qToy9Y4DiBUhOct4MG8 17 | w5XwmKAC+Vfm8tb7tMiUoU0yvKKOcL6YXBXxB2kPcOYxYNobXavfVBEdwSrjQ7i/ 18 | s3o6hkGQlm9F7JPEuVgbl/Jdwa64OYIqjQIDAQABoy0wKzAJBgNVHRMEAjAAMBEG 19 | CWCGSAGG+EIBAQQEAwIHgDALBgNVHQ8EBAMCBeAwDQYJKoZIhvcNAQELBQADggIB 20 | ABlLNovFvSrULgLvJmKX/boSWQOhWE0HDX6bVKyTs48gf7y3DXSOD+bHkBNHL0he 21 | m4HRFSarj+x389oiPEti5i12Ng9OLLHwSHK+7AfnrkhLHA8ML3NWw0GBr5DgdsIv 22 | 7MJdGIrXPQwTN5j++ICyY588TfGHH8vU5qb5PrSqClLZSSHU05FTr/Dc1B8hKjjl 23 | d/FKOidLo1YDLFUjaB9x1mZPUic/C489lyPfWqPqoMRd5i/XShST5FPvfGuKRd5q 24 | XKDkrn+GaQ/4iDDdCgekDCCPhOwuulavNxBDjShwZt1TeUrZNSM3U4GeZfyrVBIu 25 | Tr+gBK4IkD9d/vP7sa2NQszF0wRQt3m1wvSWxPz91eH+MQU1dNPzg1hnQgKKIrUC 26 | NTab/CAmSQfKC1thR15sPg5bE0kwJd1AJ1AqTrYxI0VITUV8Gka3tSAp3aKZ2LBg 27 | gYHLI2Rv9jXe5Yx5Dckf3l+YSFp/3dSDkFOgEuZm2FfZl4vNBR+coohpB9+2jRWL 28 | K+4fIkCJba+Y2cEd5usJE18MTH9FU/JKDwzC+eO9SNLFUw3zGUsSwgZsBHP6kiQN 29 | suia9q4M5f+68kzM4+0NU8HwwyzZEtmTBhktKHijExixdvjlMAZ8hAOsFifsevI0 30 | 02dUYvtxoHaeXh4jpYHVNnsIf/74uLagiPHtVf7+9UZV 31 | -----END CERTIFICATE----- 32 | Bag Attributes 33 | localKeyID: 77 23 D2 3A C0 2C 87 E2 AD 98 3F 06 68 F2 54 33 B6 05 0E FE 34 | Key Attributes: 35 | -----BEGIN ENCRYPTED PRIVATE KEY----- 36 | MIIFDjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQI+UxRzaMlG20CAggA 37 | MBQGCCqGSIb3DQMHBAgwKqY1HKSrfwSCBMjFCuZXyx8U2OJSkNdSlu9Dcp8gYzjo 38 | t3qArDdw2py8dxIk90j2deYBMrO+VnvXARmqfd2IYKyGIhDE5uLf8CEzZiVDxfm9 39 | 6h8Tcf3Z7mLyuzcye445+jQTJ6tz9zDVlpOgHMluBBHE18c2JogBZIMsp/IvAoqQ 40 | 93aseno6hMUMhDJBWpd7onaCiYQ07PJ/Q3Bx/Vp/Nmoqg/5Goss4AekxGm6ZoLPA 41 | VDPw4qfSSYhS2S4Hxh7rTn8a2n0bwmYc0ZEfvowlTiE0twHt149n6z/5WqUUHbY5 42 | P3Lu2j+y1nEl5hpipPyZjVZ6kGC73g4XgMvErFk4I3WHq9+KBjPr6PW9JshH+sFc 43 | Nn/w38rZtNaOj4dLkDb8q/WVkOinh2idRRAlMI9amYgbtqHR0aWlVBeBKFapm9Ja 44 | 2PE6IZIRFRaJquqydc2u/DzNOYNNFA7VL/Ao8KGVgq0dfOtK0NXDGndtVnABy6sm 45 | 5JR2Xl3R9CMAR0hfPBG+7+8TcKEpTbA4t+OnQ0yd2LlxuqSwcX7gzfztnkcF2N2z 46 | gDc2LODrl/pO5LM/QmzPpwO86Pv4a1xTe33uSmPCv5SQZIHFiJgoGb2/0Prd8GU4 47 | DUgwh/b+1E96JUZIFXnkBBJVgfTO1q6t3S+nyQFZqm2oVaQlq1LqLsfBWNrspzOy 48 | BMFpiP8d715F3Dn6BuGe8cpa8z3uk2N47sfb6y+ACBiD2Ix1muw8CnzXOr1q3z7A 49 | Ha9akGXUYbs33GeDRJFHGP69/2PGn19mIxd/uGcIiTuln7nWJM1Mwx2neDdCw6nk 50 | p7NpUtyIbEX3Az5Tn93rkqn9QfWjwmk8DaKdhW+LXutvf7fx/l66srTF/AJDLPP3 51 | llr1hsurquy3sAfAgqVwHYZADDn9V/BA64fiiX+Xqk9povRPUlM6Yr+ZC+okbIwG 52 | FIJvB1qISPO2fbipdaEpaJ63rFLX8ZFScgyoT4Rimj+q2zRZHE8YPsX0lKyMrnmk 53 | weSyVfmiOM0tfc8XuPgdePaTe7nz+q0w+4C6Vp/tyKsDHtpKWFjT7/zP0iF/isRp 54 | stTjXMVg6/gzNcxLoWzz0GPZEdTADPUv4SFfxbuMAfiUsVdAOydpDA2IgiSLr3Ed 55 | Wfx4CoLv1AWd2U4CxHGZjiEeBn5iv+qUow+k0WnV/Ireg+FAT5J6dcnljpUcp1K/ 56 | QEgCaqiMppl3Ws/V0xIKIgk4YoNdNLROLbX+eYqpztdZh98QTGxGen543LkZU/eH 57 | kQYBmZX6/wOyP90G8lM+2XG7PX1TMTXMOwSpmq6v8cXZhjGxtOz5/4OvGVKvgLCW 58 | A83tB6IUm5zGuICboFefHUnL5e7+cnYJo82yp2SpPuPQcnC2HEVppYxa99NNEPl2 59 | fAPwtlTiYUeKNF8rPdx8bF4bwmY3R9z1uRIsa0jbIOMXl7lyAYyGIZDbheH7B4qO 60 | FQ05hmV8XyD5Atvhg7k26GXP6izC+s6Redf+gRRlV6maisdZasCU4oEEpm/T856e 61 | yqmP34nrN1DeBK3L/3riOgXBQU/nV3no+gRxWLjXkgq4RcKq+cv6qDR8gKFO+Z1f 62 | ySnyvm+uj7+8zvpa3klXwbwtdsKVs1xO8SLVY3/45x8ofyFDR72RO0XIvg2KIz7a 63 | U34= 64 | -----END ENCRYPTED PRIVATE KEY----- 65 | -------------------------------------------------------------------------------- /tests/resources/certificates/badssl.com-client-wrong.pem: -------------------------------------------------------------------------------- 1 | Bag Attributes 2 | localKeyID: 77 23 D2 3A C0 2C 87 E2 AD 98 3F 06 68 F2 54 33 B6 05 0E FE 3 | subject=/C=US/ST=California/L=San Francisco/O=BadSSL/CN=BadSSL Client Certificate 4 | issuer=/C=US/ST=California/L=San Francisco/O=BadSSL/CN=BadSSL Client Root Certificate Authority 5 | -----BEGIN CERTIFICATE----- 6 | MIIEnTCCAoWgAwIBAgIJAPYAapdmy98xMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNV 7 | BAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNp 8 | c2NvMQ8wDQYDVQQKDAZCYWRTU0wxMTAvBgNVBAMMKEJhZFNTTCBDbGllbnQgUm9v 9 | dCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMjExMjA0MDAwODE5WhcNMjMxMjA0 10 | MDAwODE5WjBvMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQG 11 | A1UEBwwNU2FuIEZyYW5jaXNjbzEPMA0GA1UECgwGQmFkU1NMMSIwIAYDVQQDDBlC 12 | YWRTU0wgQ2xpZW50IENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A 13 | MIIBCgKCAQEAxzdfEeseTs/rukjly6MSLHM+Rh0enA3Ai4Mj2sdl31x3SbPoen08 14 | utVhjPmlxIUdkiMG4+ffe7N+JtDLG75CaxZp9CxytX7kywooRBJsRnQhmQPca8MR 15 | WAJBIz+w/L+3AFkTIqWBfyT+1VO8TVKPkEpGdLDovZOmzZAASi9/sj+j6gM7AaCi 16 | DeZTf2ES66abA5pOp60Q6OEdwg/vCUJfarhKDpi9tj3P6qToy9Y4DiBUhOct4MG8 17 | w5XwmKAC+Vfm8tb7tMiUoU0yvKKOcL6YXBXxB2kPcOYxYNobXavfVBEdwSrjQ7i/ 18 | s3o6hkGQlm9F7JPEuVgbl/Jdwa64OYIqjQIDAQABoy0wKzAJBgNVHRMEAjAAMBEG 19 | CWCGSAGG+EIBAQQEAwIHgDALBgNVHQ8EBAMCBeAwDQYJKoZIhvcNAQELBQADggIB 20 | ABlLNovFvSrULgLvJmKX/boSWQOhWE0HDX6bVKyTs48gf7y3DXSOD+bHkBNHL0he 21 | m4HRFSarj+x389oiPEti5i12Ng9OLLHwSHK+7AfnrkhLHA8ML3NWw0GBr5DgdsIv 22 | 7MJdGIrXPQwTN5j++ICyY588TfGHH8vU5qb5PrSqClLZSSHU05FTr/Dc1B8hKjjl 23 | d/FKOidLo1YDLFUjaB9x1mZPUic/C489lyPfWqPqoMRd5i/XShST5FPvfGuKRd5q 24 | XKDkrn+GaQ/4iDDdCgekDCCPhOwuulavNxBDjShwZt1TeUrZNSM3U4GeZfyrVBIu 25 | Tr+gBK4IkD9d/vP7sa2NQszF0wRQt3m1wvSWxPz91eH+MQU1dNPzg1hnQgKKIrUC 26 | NTab/CAmSQfKC1thR15sPg5bE0kwJd1AJ1AqTrYxI0VITUV8Gka3tSAp3aKZ2LBg 27 | gYHLI2Rv9jXe5Yx5Dckf3l+YSFp/3dSDkFOgEuZm2FfZl4vNBR+coohpB9+2jRWL 28 | K+4fIkCJba+Y2cEd5usJE18MTH9FU/JKDwzC+eO9SNLFUw3zGUsSwgZsBHP6kiQN 29 | suia9q4M5f+68kzM4+0NU8HwwyzZEtmTBhktKHijExixdvjlMAZ0hAOsFifsevI0 30 | 02dUYvtxoHaeXh4jpYHVNnsIf/74uLagiPHtVf7+9UZV 31 | -----END CERTIFICATE----- 32 | Bag Attributes 33 | localKeyID: 77 23 D2 3A C0 2C 87 E2 AD 98 3F 06 68 F2 54 33 B6 05 0E FE 34 | Key Attributes: 35 | -----BEGIN ENCRYPTED PRIVATE KEY----- 36 | MIIFDjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQI+UxRzaMlG20CAggA 37 | MBQGCCqGSIb3DQMHBAgwKqY1HKSrfwSCBMjFCuZXyx8U2OJSkNdSlu9Dcp8gYzjo 38 | t3qArDdw2py8dxIk90j2deYBMrO+VnvXARmqfd2IYKyGIhDE5uLf8CEzZiVDxfm9 39 | 6h8Tcf3Z7mLyuzcye445+jQTJ6tz9zDVlpOgHMluBBHE18c2JogBZIMsp/IvAoqQ 40 | 93aseno6hMUMhDJBWpd7onaCiYQ07PJ/Q3Bx/Vp/Nmoqg/5Goss4AekxGm6ZoLPA 41 | VDPw4qfSSYhS2S4Hxh7rTn8a2n0bwmYc0ZEfvowlTiE0twHt149n6z/5WqUUHbY5 42 | P3Lu2j+y1nEl5hpipPyZjVZ6kGC73g4XgMvErFk4I3WHq9+KBjPr6PW9JshH+sFc 43 | Nn/w38rZtNaOj4dLkDb8q/WVkOinh2idRRAlMI9amYgbtqHR0aWlVBeBKFapm9Ja 44 | 2PE6IZIRFRaJquqydc2u/DzNOYNNFA7VL/Ao8KGVgq0dfOtK0NXDGndtVnABy6sm 45 | 5JR2Xl3R9CMAR0hfPBG+7+8TcKEpTbA4t+OnQ0yd2LlxuqSwcX7gzfztnkcF2N2z 46 | gDc2LODrl/pO5LM/QmzPpwO86Pv4a1xTe33uSmPCv5SQZIHFiJgoGb2/0Prd8GU4 47 | DUgwh/b+1E96JUZIFXnkBBJVgfTO1q6t3S+nyQFZqm2oVaQlq1LqLsfBWNrspzOy 48 | BMFpiP8d715F3Dn6BuGe8cpa8z3uk2N47sfb6y+ACBiD2Ix1muw8CnzXOr1q3z7A 49 | Ha9akGXUYbs33GeDRJFHGP69/2PGn19mIxd/uGcIiTuln7nWJM1Mwx2neDdCw6nk 50 | p7NpUtyIbEX3Az5Tn93rkqn9QfWjwmk8DaKdhW+LXutvf7fx/l66srTF/AJDLPP3 51 | llr1hsurquy3sAfAgqVwHYZADDn9V/BA64fiiX+Xqk9povRPUlM6Yr+ZC+okbIwG 52 | FIJvB1qISPO2fbipdaEpaJ63rFLX8ZFScgyoT4Rimj+q2zRZHE8YPsX0lKyMrnmk 53 | weSyVfmiOM0tfc8XuPgdePaTe7nz+q0w+4C6Vp/tyKsDHtpKWFjT7/zP0iF/isRp 54 | stTjXMVg6/gzNcxLoWzz0GPZEdTADPUv4SFfxbuMAfiUsVdAOydpDA2IgiSLr3Ed 55 | Wfx4CoLv1AWd2U4CxHGZjiEeBn5iv+qUow+k0WnV/Ireg+FAT5J6dcnljpUcp1K/ 56 | QEgCaqiMppl3Ws/V0xIKIgk4YoNdNLROLbX+eYqpztdZh98QTGxGen543LkZU/eH 57 | kQYBmZX6/wOyP90G8lM+2XG7PX1TMTXMOwSpmq6v8cXZhjGxtOz5/4OvGVKvgLCW 58 | A83tB6IUm5zGuICboFefHUnL5e7+cnYJo82yp2SpPuPQcnC2HEVppYxa99NNEPl2 59 | fAPwtlTiYUeKNF8rPdx8bF4bwmY3R9z1uRIsa0jbIOMXl7lyAYyGIZDbheH7B4qO 60 | FQ05hmV8XyD5Atvhg7k26GXP6izC+s6Redf+gRRlV6maisdZasCU4oEEpm/T856e 61 | yqmP34nrN1DeBK3L/3riOgXBQU/nV3no+gRxWLjXkgq4RcKq+cv6qDR8gKFO+Z1f 62 | ySnyvm+uj7+8zvpa3klXwbwtdsKVs1xO8SLVY3/45x8ofyFDR72RO0XIvg2KIz7a 63 | U34= 64 | -----END ENCRYPTED PRIVATE KEY----- 65 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.1.3 22 Feb 2022 4 | - fix exception handling 5 | - redesign plugin hooks (to nose2 style) 6 | 7 | ## 1.1.2 26 Jan 2022 8 | - fix logging 9 | 10 | ## 1.1.1 26 Jan 2022 11 | - fix empty result handling 12 | 13 | ## 1.1.0 26 Jan 2022 14 | - migrate to Nose2 15 | - fix recording parsing errors 16 | - fix deprecated params 17 | 18 | ## 1.0.0 27 Sep 2021 19 | - add Nose Flow Control 20 | - add GRACEFUL shutdown feature 21 | - add version option 22 | 23 | ## 0.9.8 06 Jul 2021 24 | - fix handlers interface 25 | - add 'graceful shutdown' option 26 | 27 | ## 0.9.7 15 Jun 2021 28 | - add external handlers feature 29 | 30 | ## 0.9.6 15 jan 2021 31 | - support client-side certificates 32 | - improve error trace info 33 | - fix problem of binary POST tracing 34 | - detect delimiter in csv files automatically 35 | 36 | ## 0.9.5 01 sep 2020 37 | - add quoting auto detection feature 38 | - add ability to log actions externally 39 | - extend trace context 40 | 41 | ## 0.9.4 17 Jul 2020 42 | - improve csv encoding 43 | - fix assertion trace for multi-asserts 44 | - add 'assert_status_code_in' assertion 45 | - migrate onto modern jsonpath_ng 46 | - add lxml for cssselect 47 | - add CSSSelect assertion 48 | 49 | ## 0.9.3 03 May 2020 50 | - fix cookies processing 51 | - fix threads closing 52 | 53 | ## 0.9.2 24 Feb 2020 54 | - generalize handler interface 55 | 56 | ## 0.9.1 17 Jan 2020 57 | - use zero iterations as infinite 58 | 59 | ## 0.9.0 29 Oct 2019 60 | - add smart_transaction block 61 | - add transaction handlers ability 62 | 63 | ## 0.8.2 13 Aug 2019 64 | - fix setup errors logging 65 | 66 | ## 0.8.1 22 feb 2018 67 | - fix threading flow, provide api for using thread-specific storage 68 | 69 | ## 0.8.0 19 feb 2018 70 | - add CSV readers (by @greyfenrir) 71 | 72 | ## 0.7.0 20 Dec 2018 73 | - extend transaction API to provide a way to set start/end time 74 | - introduce `python -m apiritif` launcher 75 | 76 | ## 0.6.7 8 Aug 2018 77 | - fix unicode-related crash for LDJSON-based report 78 | - be more defensive against possible multiprocessing errors, prevent crashing 79 | 80 | ## 0.6.6 7 Aug 2018 81 | - unicode-related fixings for CSV reports 82 | - support CONNECT and OPTIONS methods 83 | - fixup multiprocessing crash 84 | 85 | ## 0.6.5 22 May 2018 86 | - record transactions start/end in logs for Taurus 87 | 88 | ## 0.6.4 17 may 2018 89 | - record iteration beginning/end in logs for Taurus 90 | 91 | ## 0.6.3 25 apr 2018 92 | - correct sample writing in load testing mode 93 | 94 | ## 0.6.2 25 apr 2018 95 | - use correct `latency`/`responseTime` fields format in sample's extras 96 | - write test `path` (used to identify parts of test) to be used by Taurus 97 | 98 | ## 0.6.1 2 mar 2018 99 | - correcting release 100 | 101 | ## 0.6 2 mar 2018 102 | 103 | - add utility functions: `encode_url()`, `base64_encode()`, `base64_decode()` and `uuid()` 104 | - fix sample handling (statuses, error messages, assertions) for nested transactions 105 | - add `assertions` field to samples 106 | - fix `responseMessage` JTL field writing in load testing mode 107 | 108 | ## 0.5 10 nov 2017 109 | 110 | - add utility functions: `format_date()`, `random_uniform()`, `random_gauss()` and `random_string()` 111 | - add loadgen utility 112 | 113 | 114 | ## 0.3 3 may 2017 115 | 116 | - allow attaching data and status to `transaction` from code 117 | 118 | 119 | ## 0.2 29 apr 2017 120 | 121 | - fix package requirement 122 | - refactor HTTPResponse class to contain data fields 123 | 124 | 125 | ## 0.1 25 apr 2017 126 | 127 | - extract as standalone project from Taurus 128 | 129 | 130 | # Roadmap / Ideas 131 | 132 | - have field in response for parsed JSON 133 | - handle file upload requests - give sugar 134 | - complete endpoint concept with path component 135 | - support arbitrary python code pieces 136 | -------------------------------------------------------------------------------- /apiritif/pytest_plugin.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | import traceback 4 | 5 | import pytest 6 | 7 | import apiritif 8 | from apiritif.samples import ApiritifSampleExtractor, Sample 9 | 10 | 11 | def pytest_addoption(parser): 12 | group = parser.getgroup('apiritif', 'reporting per-testcase Apiritif traces') 13 | group.addoption('--apiritif-trace', help='target path to save trace JSON') 14 | group.addoption('--apiritif-trace-detail', type=int, default=1, help='detail level for Apiritif trace (1-3)') 15 | 16 | 17 | def pytest_configure(config): 18 | if not config.option.apiritif_trace: 19 | return 20 | plugin = ApiritifPytestPlugin(config) 21 | config.pluginmanager.register(plugin, plugin.__class__.__name__) 22 | 23 | 24 | def pytest_unconfigure(config): 25 | name = ApiritifPytestPlugin.__name__ 26 | if config.pluginmanager.has_plugin(name): 27 | plugin = config.pluginmanager.get_plugin(name) 28 | config.pluginmanager.unregister(plugin) 29 | 30 | 31 | class ApiritifPytestPlugin(object): 32 | def __init__(self, config=None) -> None: 33 | super().__init__() 34 | self._result_file = None 35 | self._detail_level = 1 36 | if config: 37 | self._result_file = config.option.apiritif_trace 38 | self._detail_level = config.option.apiritif_trace_detail 39 | self._trace_map = {} 40 | 41 | @pytest.hookimpl(hookwrapper=True) 42 | def pytest_runtest_setup(self, item): 43 | self._pop_events() # clean it, just in case 44 | yield 45 | 46 | @pytest.hookimpl(hookwrapper=True) 47 | def pytest_runtest_makereport(self, item, call): 48 | report = yield 49 | if call.when == 'call': 50 | self._trace_map[item.nodeid] = self._get_subsamples(call, report.get_result(), item) 51 | 52 | def _get_subsamples(self, call, report, item): 53 | recording = self._pop_events() 54 | sample = Sample() 55 | sample.test_case = item.name 56 | if item.parent: 57 | sample.test_suite = item.parent.name 58 | sample.duration = report.duration 59 | sample.status = 'PASSED' if report.passed else 'FAILED' 60 | if call.excinfo: 61 | sample.error_msg = str(call.excinfo.value) 62 | sample.error_trace = traceback.format_tb(call.excinfo.tb) 63 | 64 | extr = ApiritifSampleExtractor() 65 | trace = extr.parse_recording(recording, sample) 66 | toplevel_sample = trace[0].to_dict() 67 | self._filter([toplevel_sample]) 68 | return toplevel_sample 69 | 70 | @pytest.hookimpl(tryfirst=True) 71 | def pytest_sessionfinish(self, session): 72 | with open(self._result_file, 'w') as fp: 73 | json.dump(self._trace_map, fp, indent=True) 74 | 75 | def _pop_events(self): 76 | return apiritif.recorder.pop_events(from_ts=-1, to_ts=sys.maxsize) 77 | 78 | def _filter(self, items): 79 | for item in items: 80 | self._filter(item['subsamples']) 81 | if self._detail_level >= 4: 82 | if isinstance(item['extras'].get('requestBody'), bytes): 83 | try: 84 | # optimistic 85 | item['extras']['requestBody'] = item['extras']['requestBody'].decode('utf-8') 86 | except UnicodeError: 87 | # worst case it is just byte sequence 88 | item['extras']['requestBody'] = item['extras']['requestBody'].decode('latin-1') 89 | 90 | if self._detail_level <= 3: 91 | item['extras'].pop('requestCookiesRaw', None) 92 | item['extras'].pop('requestCookies', None) 93 | item['extras'].pop('requestBody', None) 94 | item['extras'].pop('responseBody', None) 95 | item['extras'].pop('requestHeaders', None) 96 | item['extras'].pop('responseHeaders', None) 97 | 98 | if self._detail_level <= 2: 99 | item.pop('extras', None) 100 | item.pop('subsamples', None) 101 | item.pop('assertions', None) 102 | -------------------------------------------------------------------------------- /apiritif/utilities.py: -------------------------------------------------------------------------------- 1 | """ 2 | Public utility functions for Apiritif 3 | 4 | Copyright 2017 BlazeMeter Inc. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | """ 18 | import base64 19 | import random 20 | import re 21 | import string 22 | import sys 23 | from datetime import datetime 24 | from uuid import uuid4 25 | 26 | if sys.version_info < (3, 0): 27 | from urllib import quote_plus 28 | else: 29 | from urllib.parse import quote_plus 30 | 31 | 32 | def random_uniform(start, stop=None): 33 | return random.randrange(start, stop=stop) 34 | 35 | 36 | def random_gauss(sigma, mu): 37 | return random.gauss(sigma, mu) 38 | 39 | 40 | def random_string(size, chars=string.printable): 41 | return "".join(random.choice(chars) for _ in range(size)) 42 | 43 | 44 | class SimpleDateFormat(object): 45 | def __init__(self, format): 46 | self.format = format 47 | 48 | @staticmethod 49 | def _replacer(match): 50 | what = match.group(0) 51 | if what.startswith("y") or what.startswith("Y"): 52 | if len(what) < 4: 53 | return "%y" 54 | else: 55 | return "%Y" 56 | elif what.startswith("M"): 57 | return "%m" 58 | elif what.startswith("d"): 59 | return "%d" 60 | elif what.startswith("h"): 61 | return "%I" 62 | elif what.startswith("H"): 63 | return "%H" 64 | elif what.startswith("m"): 65 | return "%M" 66 | elif what.startswith("s"): 67 | return "%S" 68 | elif what.startswith("S"): 69 | return what 70 | elif what.startswith("E"): 71 | if len("E") <= 3: 72 | return "%a" 73 | else: 74 | return "%A" 75 | elif what.startswith("D"): 76 | return "%j" 77 | elif what.startswith("w"): 78 | return "%U" 79 | elif what.startswith("a"): 80 | return "%p" 81 | elif what.startswith("z"): 82 | return "%z" 83 | elif what.startswith("Z"): 84 | return "%Z" 85 | 86 | def format_datetime(self, datetime): 87 | letters = "yYMdhHmsSEDwazZ" # TODO: moar 88 | regex = "(" + "|".join(letter + "+" for letter in letters) + ")" 89 | strftime_fmt = re.sub(regex, self._replacer, self.format) 90 | return datetime.strftime(strftime_fmt) 91 | 92 | 93 | def format_date(format_string=None, datetime_obj=None): 94 | """ 95 | Format a datetime object with Java SimpleDateFormat's-like string. 96 | 97 | If datetime_obj is not given - use current datetime. 98 | If format_string is not given - return number of millisecond since epoch. 99 | 100 | :param format_string: 101 | :param datetime_obj: 102 | :return: 103 | :rtype string 104 | """ 105 | datetime_obj = datetime_obj or datetime.now() 106 | if format_string is None: 107 | seconds = int(datetime_obj.strftime("%s")) 108 | milliseconds = datetime_obj.microsecond // 1000 109 | return str(seconds * 1000 + milliseconds) 110 | else: 111 | formatter = SimpleDateFormat(format_string) 112 | return formatter.format_datetime(datetime_obj) 113 | 114 | 115 | def base64_decode(encoded): 116 | decoded = base64.b64decode(encoded) 117 | return decoded.decode() 118 | 119 | 120 | def base64_encode(plaintext): 121 | if not isinstance(plaintext, bytes): 122 | plaintext = plaintext.encode() 123 | encoded = base64.b64encode(plaintext) 124 | return encoded.decode() 125 | 126 | 127 | def encode_url(chars): 128 | return quote_plus(chars) 129 | 130 | 131 | def uuid(): 132 | return str(uuid4()) 133 | -------------------------------------------------------------------------------- /tests/unit/test_assertions.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from apiritif import http 4 | 5 | 6 | class TestRequests(unittest.TestCase): 7 | def test_assert_regex(self): 8 | response = http.get('http://blazedemo.com/') 9 | response.assert_ok() 10 | response.assert_status_code(200) 11 | response.assert_regex_in_body('Welcome to the Simple Travel Agency!') 12 | 13 | def test_assert_xpath(self): 14 | response = http.get('http://blazedemo.com/') 15 | response.assert_ok() 16 | response.assert_xpath('//head/title', parser_type='html', validate=False) 17 | response.assert_not_xpath('//yo/my/man', parser_type='html', validate=False) 18 | 19 | def test_assert_cssselect(self): 20 | response = http.get('http://blazedemo.com/') 21 | response.assert_ok() 22 | response.assert_cssselect('head title') 23 | response.assert_not_cssselect('yo my man') 24 | 25 | def test_assert_jsonpath(self): 26 | response = http.get('https://jsonplaceholder.typicode.com/users') 27 | response.assert_ok() 28 | response.assert_jsonpath('$.[0].username', expected_value='Bret') 29 | response.assert_not_jsonpath("$.foo.bar") 30 | 31 | def test_assert_ok(self): 32 | response = http.get('http://blazedemo.com/') 33 | response.assert_ok() 34 | 35 | def test_assert_failed(self): 36 | response = http.get('http://blazedemo.com/not-found') 37 | response.assert_failed() 38 | 39 | def test_assert_2xx(self): 40 | response = http.get('http://blazedemo.com/') 41 | response.assert_2xx() 42 | 43 | def test_assert_3xx(self): 44 | response = http.get('https://httpbin.org/status/301', allow_redirects=False) 45 | response.assert_3xx() 46 | 47 | def test_assert_4xx(self): 48 | response = http.get('http://blazedemo.com/not-found') 49 | response.assert_4xx() 50 | 51 | def test_assert_5xx(self): 52 | response = http.get('https://httpbin.org/status/500') 53 | response.assert_5xx() 54 | 55 | def test_assert_status_code(self): 56 | response = http.get('http://blazedemo.com/') 57 | response.assert_status_code(200) 58 | 59 | def test_assert_status_code_in(self): 60 | response = http.get('http://blazedemo.com/') 61 | response.assert_status_code_in((302, 200)) 62 | 63 | def test_assert_not_status_code(self): 64 | response = http.get('http://blazedemo.com/not-found') 65 | response.assert_not_status_code(200) 66 | 67 | def test_assert_in_body(self): 68 | response = http.get('http://blazedemo.com/') 69 | response.assert_in_body("Welcome") 70 | 71 | def test_assert_not_in_body(self): 72 | response = http.get('http://blazedemo.com/') 73 | response.assert_not_in_body("Willcommen!") 74 | 75 | def test_assert_regex_in_body(self): 76 | response = http.get('http://blazedemo.com/') 77 | response.assert_regex_in_body("Welcome to the Simple .+ Agency") 78 | 79 | def test_assert_regex_not_in_body(self): 80 | response = http.get('http://blazedemo.com/not-found') 81 | response.assert_regex_not_in_body("Nope") 82 | 83 | def test_assert_has_header(self): 84 | response = http.get('http://blazedemo.com/') 85 | response.assert_has_header("Content-Type") 86 | 87 | def test_assert_header_value(self): 88 | response = http.get('http://blazedemo.com/not-found') 89 | response.assert_header_value("Content-Type", "text/html; charset=UTF-8") 90 | 91 | def test_assert_in_headers(self): 92 | response = http.get('http://blazedemo.com/') 93 | response.assert_in_headers("content-type: text/html") 94 | 95 | def test_assert_not_in_headers(self): 96 | response = http.get('http://blazedemo.com/') 97 | response.assert_not_in_headers("Content-Type: application/html") 98 | 99 | def test_assert_regex_in_headers(self): 100 | response = http.get('http://blazedemo.com/') 101 | response.assert_regex_in_headers(r"content-type: .+") 102 | 103 | def test_assert_regex_not_in_headers(self): 104 | response = http.get('http://blazedemo.com/') 105 | response.assert_regex_not_in_headers(r"Content-Type: application/.+") 106 | -------------------------------------------------------------------------------- /apiritif/store.py: -------------------------------------------------------------------------------- 1 | # temporary exchanger for ApiritifPlugin stuff 2 | import time 3 | import traceback 4 | 5 | import apiritif 6 | from apiritif.samples import ApiritifSampleExtractor, Sample, PathComponent 7 | from apiritif.utils import get_trace 8 | 9 | writer = None 10 | 11 | 12 | class SampleController(object): 13 | def __init__(self, log, session): 14 | self.current_sample = None # todo: recreate it from plugin's template every transaction 15 | self.success_count = None 16 | self.log = log 17 | self.test_count = 0 18 | self.success_count = 0 19 | self.apiritif_extractor = ApiritifSampleExtractor() 20 | self.tran_mode = False # it's regular test (without smart transaction) by default 21 | self.start_time = None 22 | self.end_time = None 23 | self.test_info = {} 24 | self.session = session 25 | 26 | def startTest(self): 27 | self.current_sample = Sample( 28 | test_case=self.test_info["test_case"], 29 | test_suite=self.test_info["suite_name"], 30 | start_time=time.time(), 31 | status="SKIPPED") 32 | self.current_sample.extras.update({ 33 | "full_name": self.test_info["test_fqn"], 34 | "description": self.test_info["description"] 35 | }) 36 | if "." in self.test_info["class_method"]: # TestClass.test_method 37 | class_name, method_name = self.test_info["class_method"].split('.')[:2] 38 | self.current_sample.path.extend([ 39 | PathComponent("class", class_name), 40 | PathComponent("method", method_name)]) 41 | else: # test_func 42 | self.current_sample.path.append(PathComponent("func", self.test_info["class_method"])) 43 | 44 | self.log.debug("Test method path: %r", self.current_sample.path) 45 | self.test_count += 1 46 | self.set_start_time() 47 | 48 | def set_start_time(self): 49 | self.start_time = time.time() 50 | 51 | def addError(self, assertion_name, error_msg, error_trace, is_transaction=False): 52 | if self.tran_mode == is_transaction: 53 | self.current_sample.add_assertion(assertion_name, {"args": [], "kwargs": {}}) 54 | self.current_sample.set_assertion_failed(assertion_name, error_msg, error_trace) 55 | 56 | def addFailure(self, error, is_transaction=False): 57 | if self.tran_mode == is_transaction: 58 | assertion_name = error[0].__name__ 59 | error_msg = str(error[1]).split('\n')[0] 60 | error_trace = get_trace(error) 61 | self.current_sample.add_assertion(assertion_name, {"args": [], "kwargs": {}}) 62 | self.current_sample.set_assertion_failed(assertion_name, error_msg, error_trace) 63 | 64 | def addSuccess(self, is_transaction=False): 65 | if self.tran_mode == is_transaction: 66 | self.current_sample.status = "PASSED" 67 | self.success_count += 1 68 | 69 | def stopTest(self, is_transaction=False): 70 | if self.tran_mode == is_transaction and not self.session.stop_reason: 71 | self.end_time = time.time() 72 | self.current_sample.duration = self.end_time - self.current_sample.start_time 73 | 74 | samples_processed = self._process_apiritif_samples(self.current_sample) 75 | if not samples_processed: 76 | self._process_sample(self.current_sample) 77 | 78 | self.current_sample = None 79 | 80 | def _process_apiritif_samples(self, sample): 81 | samples = [] 82 | 83 | # get list of events 84 | recording = apiritif.recorder.pop_events(from_ts=self.start_time, to_ts=self.end_time) 85 | 86 | try: 87 | if recording: 88 | # convert requests (events) to samples 89 | samples = self.apiritif_extractor.parse_recording(recording, sample) 90 | except BaseException as exc: 91 | self.log.debug("Couldn't parse recording: %s", traceback.format_exc()) 92 | self.log.warning("Couldn't parse recording: %s", exc) 93 | 94 | for sample in samples: 95 | self._process_sample(sample) # just write to disk 96 | 97 | return len(samples) 98 | 99 | def _process_sample(self, sample): 100 | writer.add(sample, self.test_count, self.success_count) 101 | -------------------------------------------------------------------------------- /apiritif/ssl_adapter.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a toplevel package of Apiritif tool 3 | 4 | Copyright 2021 BlazeMeter Inc. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | """ 18 | import os 19 | from OpenSSL import crypto 20 | from datetime import datetime 21 | from requests.adapters import HTTPAdapter 22 | from urllib3.contrib.pyopenssl import PyOpenSSLContext 23 | 24 | try: 25 | from ssl import PROTOCOL_TLS as ssl_protocol 26 | except ImportError: 27 | from ssl import PROTOCOL_SSLv23 as ssl_protocol 28 | 29 | 30 | class SSLAdapter(HTTPAdapter): 31 | def __init__(self, *args, **kwargs): 32 | certificate_file_path = kwargs.pop('certificate_file_path', None) 33 | passphrase = kwargs.pop('passphrase', None) 34 | 35 | pkcs12_obj = CertificateReader.create_pkcs12_obj(certificate_file_path, passphrase) 36 | self.ssl_context = CertificateReader.create_ssl_context(pkcs12_obj) 37 | 38 | super(SSLAdapter, self).__init__(*args, **kwargs) 39 | 40 | def init_poolmanager(self, *args, **kwargs): 41 | if self.ssl_context: 42 | kwargs['ssl_context'] = self.ssl_context 43 | return super(SSLAdapter, self).init_poolmanager(*args, **kwargs) 44 | 45 | def proxy_manager_for(self, *args, **kwargs): 46 | if self.ssl_context: 47 | kwargs['ssl_context'] = self.ssl_context 48 | return super(SSLAdapter, self).proxy_manager_for(*args, **kwargs) 49 | 50 | 51 | class CertificateReader: 52 | @staticmethod 53 | def create_pkcs12_obj(certificate_file_path, passphrase): 54 | """ 55 | :param certificate_file_path: str 56 | :param passphrase: str 57 | :return: pkcs12_obj 58 | :rtype: OpenSSL.crypto.PKCS12 59 | """ 60 | 61 | certificate_password = passphrase.encode('utf8') 62 | with open(certificate_file_path, 'rb') as pkcs12_file: 63 | certificate_data = pkcs12_file.read() 64 | 65 | if os.path.splitext(certificate_file_path)[-1].lower() == '.pem': 66 | pkcs12_obj = CertificateReader._create_openssl_cert_from_pem(certificate_data, certificate_password) 67 | else: 68 | pkcs12_obj = CertificateReader._create_openssl_cert_from_pkcs12(certificate_data, certificate_password) 69 | 70 | return pkcs12_obj 71 | 72 | @staticmethod 73 | def create_ssl_context(pkcs12_cert): 74 | """ 75 | :param pkcs12_cert: OpenSSL.crypto.PKCS12 76 | :return: context 77 | :rtype: urllib3.contrib.pyopenssl.PyOpenSSLContext 78 | """ 79 | 80 | cert = pkcs12_cert.get_certificate() 81 | CertificateReader._check_cert_not_expired(cert) 82 | 83 | context = PyOpenSSLContext(ssl_protocol) 84 | context._ctx.use_certificate(cert) 85 | 86 | ca_certs = pkcs12_cert.get_ca_certificates() 87 | if ca_certs: 88 | for ca_cert in ca_certs: 89 | CertificateReader._check_cert_not_expired(ca_cert) 90 | context._ctx.add_extra_chain_cert(ca_cert) 91 | 92 | context._ctx.use_privatekey(pkcs12_cert.get_privatekey()) 93 | 94 | return context 95 | 96 | @staticmethod 97 | def _check_cert_not_expired(cert): 98 | cert_not_after = datetime.strptime(cert.get_notAfter().decode('ascii'), '%Y%m%d%H%M%SZ') 99 | if cert_not_after < datetime.utcnow(): 100 | raise ValueError('SSL certificate expired') 101 | 102 | @staticmethod 103 | def _create_openssl_cert_from_pem(certificate_data, certificate_password): 104 | cert = crypto.load_certificate(crypto.FILETYPE_PEM, certificate_data) 105 | key = crypto.load_privatekey(crypto.FILETYPE_PEM, certificate_data, passphrase=certificate_password) 106 | 107 | pkcs = crypto.PKCS12() 108 | pkcs.set_privatekey(key) 109 | pkcs.set_certificate(cert) 110 | return pkcs 111 | 112 | @staticmethod 113 | def _create_openssl_cert_from_pkcs12(certificate_data, certificate_password): 114 | return crypto.load_pkcs12(certificate_data, certificate_password) 115 | -------------------------------------------------------------------------------- /tests/unit/test_transactions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | import unittest 4 | import threading 5 | import apiritif 6 | 7 | from apiritif import http, transaction, transaction_logged, smart_transaction 8 | 9 | target = http.target('https://httpbin.org') 10 | target.keep_alive(True) 11 | target.auto_assert_ok(False) 12 | target.use_cookies(True) 13 | 14 | 15 | class TestRequests(unittest.TestCase): 16 | # will produce test-case sample with one sub-sample 17 | def test_1_single_request(self): 18 | target.get('/') 19 | 20 | # will produce test-case sample with two sub-samples 21 | def test_2_multiple_requests(self): 22 | target.get('/') 23 | target.get('/2') 24 | 25 | # won't produce test-case sample, only transaction 26 | def test_3_toplevel_transaction(self): 27 | with transaction("Transaction"): 28 | target.get('/') 29 | target.get('/2') 30 | 31 | # won't produce test-case sample, only "Tran Name" 32 | # will also will skip "GET /" request, as it's not in the transaction. 33 | def test_4_mixed_transaction(self): 34 | target.get('/') 35 | with transaction("Transaction"): 36 | target.get('/2') 37 | 38 | # won't produce test-case sample, two separate ones 39 | def test_5_multiple_transactions(self): 40 | with transaction("Transaction 1"): 41 | target.get('/') 42 | target.get('/2') 43 | 44 | with transaction("Transaction 2"): 45 | target.get('/') 46 | target.get('/2') 47 | 48 | def test_6_transaction_obj(self): 49 | tran = transaction("Label") 50 | tran.start() 51 | time.sleep(0.5) 52 | tran.finish() 53 | 54 | def test_7_transaction_fail(self): 55 | with transaction("Label") as tran: 56 | tran.fail("Something went wrong") 57 | 58 | def test_8_transaction_attach(self): 59 | with transaction("Label") as tran: 60 | user_input = "YO" 61 | tran.set_request("Request body") 62 | tran.set_response("Response body") 63 | tran.set_response_code(201) 64 | tran.attach_extra("user", user_input) 65 | 66 | def test_9_transaction_logged(self): 67 | with transaction_logged("Label") as tran: 68 | logging.warning("TODO: capture logging to assert for result") 69 | 70 | 71 | class ControllerMock(object): 72 | class CurrentSampleMock: 73 | def __init__(self, index): 74 | self.test_case = 'TestCase %d' % index 75 | self.test_suite = 'TestSuite %d' % index 76 | 77 | def __init__(self, index): 78 | self.tran_mode = True 79 | self.test_info = {} 80 | self.current_sample = self.CurrentSampleMock(index) 81 | 82 | def set_start_time(self): 83 | pass 84 | 85 | def startTest(self): 86 | pass 87 | 88 | def stopTest(self, is_transaction): 89 | pass 90 | 91 | def addError(self, name, msg, trace, is_transaction): 92 | pass 93 | 94 | 95 | class TransactionThread(threading.Thread): 96 | def __init__(self, index): 97 | self.index = index 98 | self.driver = 'Driver %d' % self.index 99 | self.controller = ControllerMock(self.index) 100 | 101 | self.thread_name = 'Transaction %d' % self.index 102 | self.exception_message = 'Thread %d failed' % self.index 103 | 104 | super(TransactionThread, self).__init__(target=self._run_transaction) 105 | 106 | def _run_transaction(self): 107 | apiritif.put_into_thread_store(driver=self.driver, func_mode=False, controller=self.controller) 108 | apiritif.set_transaction_handlers({'enter': [self._enter_handler], 'exit': [self._exit_handler]}) 109 | 110 | tran = smart_transaction(self.thread_name) 111 | with tran: 112 | self.transaction_driver = tran.driver 113 | self.transaction_controller = tran.controller 114 | raise Exception(self.exception_message) 115 | 116 | self.message_from_thread_store = apiritif.get_from_thread_store('message') 117 | 118 | def _enter_handler(self): 119 | pass 120 | 121 | def _exit_handler(self): 122 | pass 123 | 124 | 125 | class TestMultiThreadTransaction(unittest.TestCase): 126 | 127 | # Transaction data should be different for each thread. 128 | # Here TransactionThread class puts all transaction data into thread store. 129 | # Then we save all thread data from real transaction data to our mock. 130 | # As the result written and saved data should be the same. 131 | def test_Transaction_data_per_thread(self): 132 | transactions = [TransactionThread(i) for i in range(5)] 133 | 134 | for tran in transactions: 135 | tran.start() 136 | for tran in transactions: 137 | tran.join() 138 | for tran in transactions: 139 | self.assertEqual(tran.transaction_controller, tran.controller) 140 | self.assertEqual(tran.transaction_driver, tran.driver) 141 | self.assertEqual(tran.message_from_thread_store, tran.exception_message) 142 | -------------------------------------------------------------------------------- /apiritif/csv.py: -------------------------------------------------------------------------------- 1 | """ 2 | Data feeders for Apiritif. 3 | 4 | Copyright 2018 BlazeMeter Inc. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | """ 18 | import re 19 | import threading 20 | 21 | import csv 22 | from io import open 23 | from itertools import cycle, islice 24 | from chardet.universaldetector import UniversalDetector 25 | 26 | import apiritif.thread as thread 27 | from apiritif.utils import NormalShutdown 28 | 29 | thread_data = threading.local() 30 | 31 | 32 | class Reader(object): 33 | def read_vars(self): 34 | pass 35 | 36 | def get_vars(self): 37 | pass 38 | 39 | def close(self): 40 | pass 41 | 42 | 43 | class CSVReaderPerThread(Reader): # processes multi-thread specific 44 | def __init__(self, filename, fieldnames=None, delimiter=None, loop=True, quoted=None, encoding=None): 45 | self.filename = filename 46 | self.fieldnames = fieldnames 47 | self.delimiter = delimiter 48 | self.loop = loop 49 | self.quoted = quoted 50 | self.encoding = encoding 51 | 52 | def _get_csv_reader(self, create=False): 53 | csv_readers = getattr(thread_data, "csv_readers", None) 54 | if not csv_readers: 55 | thread_data.csv_readers = {} 56 | 57 | csv_reader = thread_data.csv_readers.get(id(self)) 58 | if not csv_reader and create: 59 | csv_reader = CSVReader( 60 | filename=self.filename, 61 | fieldnames=self.fieldnames, 62 | step=thread.get_total(), 63 | first=thread.get_index(), 64 | delimiter=self.delimiter, 65 | loop=self.loop, 66 | quoted=self.quoted, 67 | encoding=self.encoding) 68 | 69 | thread_data.csv_readers[id(self)] = csv_reader 70 | 71 | return csv_reader 72 | 73 | def read_vars(self): 74 | self._get_csv_reader(create=True).read_vars() 75 | 76 | def close(self): 77 | csv_reader = self._get_csv_reader() 78 | if csv_reader: 79 | del thread_data.csv_readers[id(self)] 80 | csv_reader.close() 81 | 82 | def get_vars(self): 83 | csv_reader = self._get_csv_reader() 84 | if csv_reader: 85 | return csv_reader.get_vars() 86 | else: 87 | return {} 88 | 89 | 90 | class CSVReader(Reader): 91 | def __init__(self, filename, step=1, first=0, fieldnames=None, delimiter=None, loop=True, quoted=None, 92 | encoding=None): 93 | self.step = step 94 | self.first = first 95 | self.csv = {} 96 | format_params = {} 97 | if not encoding and quoted is None: 98 | with open(filename, 'rb') as bin_fds: 99 | if not encoding: 100 | detector = UniversalDetector() 101 | for line in bin_fds.readlines(): 102 | detector.feed(line) 103 | if detector.done: 104 | break 105 | detector.close() 106 | encoding = detector.result['encoding'] 107 | bin_fds.seek(0) 108 | 109 | if quoted is None: 110 | header = bin_fds.readline() 111 | header = header[:-1].decode(encoding=encoding) 112 | match = re.match(r'.*["\']\w+["\'](.["\']\w+["\'])+', header) 113 | quoted = True if match is not None else False 114 | bin_fds.seek(0) 115 | format_params["quoting"] = csv.QUOTE_MINIMAL if quoted else csv.QUOTE_NONE 116 | 117 | self.fds = open(filename, 'r', encoding=encoding) 118 | 119 | if not delimiter: 120 | dialect = csv.Sniffer().sniff(self.fds.read()) 121 | self.fds.seek(0) 122 | delimiter = dialect.delimiter 123 | format_params["delimiter"] = delimiter 124 | 125 | self._reader = csv.DictReader(self.fds, fieldnames=fieldnames, **format_params) 126 | if loop: 127 | self._reader = cycle(self._reader) 128 | 129 | def close(self): 130 | if self.fds is not None: 131 | self.fds.close() 132 | self._reader = None 133 | 134 | def read_vars(self): 135 | if not self._reader: 136 | return # todo: exception? 137 | 138 | try: 139 | if not self.csv: # first element 140 | self.csv = next(islice(self._reader, self.first, self.first + 1)) 141 | else: # next one 142 | self.csv = next(islice(self._reader, self.step - 1, self.step)) 143 | except StopIteration: 144 | stop_reason = "Data source is exhausted: %s" % self.fds.name 145 | raise NormalShutdown(stop_reason) # Just send it up 146 | 147 | def get_vars(self): 148 | return self.csv 149 | -------------------------------------------------------------------------------- /tests/unit/test_ssl_requests.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import OpenSSL 4 | from unittest import TestCase 5 | from apiritif.http import http 6 | from apiritif import ssl_adapter 7 | 8 | from tests.unit import RESOURCES_DIR 9 | 10 | 11 | class CryptoMock: 12 | class PKCS12: 13 | class CertMock: 14 | def __init__(self, cert): 15 | self.certificate = cert 16 | 17 | def get_notAfter(self): 18 | return b'30211126001957Z' 19 | 20 | def __init__(self): 21 | self.privatekey = None 22 | self.certificate = None 23 | 24 | def set_privatekey(self, key): 25 | self.privatekey = key 26 | 27 | def set_certificate(self, cert): 28 | self.certificate = cert 29 | 30 | def get_certificate(self): 31 | return self.CertMock(self.certificate) 32 | 33 | def get_ca_certificates(self): 34 | return [] 35 | 36 | def get_privatekey(self): 37 | return self.privatekey 38 | 39 | def __init__(self): 40 | self.FILETYPE_PEM = 'pem' 41 | self.load_pkcs12_called = 0 42 | self.load_certificate_called = 0 43 | self.load_privatekey_called = 0 44 | 45 | def load_certificate(self, filetype, data): 46 | self.load_certificate_called += 1 47 | 48 | return 'certificate' 49 | 50 | def load_privatekey(self, filetype, data, passphrase): 51 | self.load_privatekey_called += 1 52 | 53 | return 'privatekey' 54 | 55 | def load_pkcs12(self, data, password): 56 | self.load_pkcs12_called += 1 57 | 58 | pkcs12 = self.PKCS12() 59 | pkcs12.certificate = 'certificate' 60 | pkcs12.privatekey = 'privatekey' 61 | return pkcs12 62 | 63 | 64 | class PyOpenSSLContextMock: 65 | class ContextMock: 66 | def __init__(self): 67 | self.certificate = None 68 | self.privatekey = None 69 | self.chain_certificates = [] 70 | 71 | def use_certificate(self, cert): 72 | self.certificate = cert 73 | 74 | def add_extra_chain_cert(self, cert): 75 | self.chain_certificates.append(cert) 76 | 77 | def use_privatekey(self, key): 78 | self.privatekey = key 79 | 80 | def __init__(self, ssl_protocol): 81 | self._ctx = self.ContextMock() 82 | self.ssl_protocol = ssl_protocol 83 | 84 | 85 | class TestSSLAdapter(TestCase): 86 | def setUp(self): 87 | self.real_crypto = ssl_adapter.crypto 88 | self.real_PyOpenSSLContext = ssl_adapter.PyOpenSSLContext 89 | ssl_adapter.crypto = CryptoMock() 90 | ssl_adapter.PyOpenSSLContext = PyOpenSSLContextMock 91 | 92 | def tearDown(self): 93 | ssl_adapter.crypto = self.real_crypto 94 | ssl_adapter.PyOpenSSLContext = self.real_PyOpenSSLContext 95 | 96 | def test_adapter_with_p12_cert(self): 97 | certificate_file_path = os.path.join(RESOURCES_DIR, "certificates/dump-file.p12") 98 | adapter = ssl_adapter.SSLAdapter(certificate_file_path=certificate_file_path, passphrase='pass') 99 | 100 | self.assertEqual('privatekey', adapter.ssl_context._ctx.privatekey) 101 | self.assertEqual('certificate', adapter.ssl_context._ctx.certificate.certificate) 102 | self.assertEqual(1, ssl_adapter.crypto.load_pkcs12_called) 103 | self.assertEqual(0, ssl_adapter.crypto.load_certificate_called) 104 | self.assertEqual(0, ssl_adapter.crypto.load_privatekey_called) 105 | 106 | def test_adapter_with_pem_cert(self): 107 | certificate_file_path = os.path.join(RESOURCES_DIR, "certificates/dump-file.pem") 108 | adapter = ssl_adapter.SSLAdapter(certificate_file_path=certificate_file_path, passphrase='pass') 109 | 110 | self.assertEqual('privatekey', adapter.ssl_context._ctx.privatekey) 111 | self.assertEqual('certificate', adapter.ssl_context._ctx.certificate.certificate) 112 | self.assertEqual(0, ssl_adapter.crypto.load_pkcs12_called) 113 | self.assertEqual(1, ssl_adapter.crypto.load_certificate_called) 114 | self.assertEqual(1, ssl_adapter.crypto.load_privatekey_called) 115 | 116 | 117 | # TODO: This class contains integration tests. Need to be removed in future 118 | class TestSSL(TestCase): 119 | def setUp(self): 120 | self.host = 'client.badssl.com' 121 | self.request_url = 'https://client.badssl.com/' 122 | self.certificate_file_pem = os.path.join(RESOURCES_DIR, "certificates/badssl.com-client.pem") 123 | self.certificate_file_p12 = os.path.join(RESOURCES_DIR, "certificates/badssl.com-client.p12") 124 | self.passphrase = 'badssl.com' 125 | 126 | def test_get_with_encrypted_p12_certificate(self): 127 | encrypted_cert = (self.certificate_file_p12, self.passphrase) 128 | response = http.get(self.request_url, encrypted_cert=encrypted_cert) 129 | self.assertEqual(200, response.status_code) 130 | 131 | def test_get_with_encrypted_pem_certificate(self): 132 | encrypted_cert = (self.certificate_file_pem, self.passphrase) 133 | response = http.get(self.request_url, encrypted_cert=encrypted_cert) 134 | self.assertEqual(200, response.status_code) 135 | 136 | def test_get_with_incorrect_certificate(self): 137 | certificate_file_pem_incorrect = os.path.join(RESOURCES_DIR, "certificates/badssl.com-client-wrong.pem") 138 | encrypted_cert = (certificate_file_pem_incorrect, self.passphrase) 139 | response = http.get(self.request_url, encrypted_cert=encrypted_cert) 140 | self.assertEqual(400, response.status_code) 141 | 142 | def test_get_with_incorrect_secret(self): 143 | wrong_certificate_secret = 'you shall not pass' 144 | encrypted_cert = (self.certificate_file_pem, wrong_certificate_secret) 145 | self.assertRaises(OpenSSL.crypto.Error, http.get, self.request_url, encrypted_cert=encrypted_cert) 146 | 147 | def test_get_with_ssl_and_wrong_url(self): 148 | cert_missing_url = 'https://client-cert-missing.badssl.com/' 149 | encrypted_cert = (self.certificate_file_p12, self.passphrase) 150 | response = http.get(cert_missing_url, encrypted_cert=encrypted_cert) 151 | self.assertEqual(400, response.status_code) 152 | -------------------------------------------------------------------------------- /tests/unit/test_samples.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import TestCase 3 | 4 | import nose2 5 | 6 | from apiritif import store 7 | from . import Recorder # required for nose2. unittest.cfg loads this plugin from here 8 | from tests.unit import RESOURCES_DIR 9 | 10 | 11 | class CachingWriter(object): 12 | def __init__(self): 13 | self.samples = [] 14 | 15 | def add(self, sample, test_count, success_count): 16 | self.samples.append(sample) 17 | 18 | 19 | class TestSamples(TestCase): 20 | def test_transactions(self): 21 | test_file = os.path.join(RESOURCES_DIR, "test_transactions.py") 22 | self.assertTrue(os.path.exists(test_file)) 23 | store.writer = CachingWriter() 24 | nose2.discover(argv=[__file__, "tests.resources.test_transactions", '-v'], module="None", exit=False, 25 | plugins=["tests.unit.test_samples"]) 26 | samples = store.writer.samples 27 | self.assertEqual(len(samples), 8) 28 | 29 | single = samples[0] 30 | self.assertEqual(single.test_suite, 'TestTransactions') 31 | self.assertEqual(single.test_case, 'test_1_single_transaction') 32 | tran = single.subsamples[0] 33 | self.assertEqual('test_1_single_transaction', tran.test_suite) 34 | self.assertEqual('single-transaction', tran.test_case) 35 | self.assertEqual(tran.status, "PASSED") 36 | 37 | two_trans = samples[1] 38 | self.assertEqual(two_trans.test_case, 'test_2_two_transactions') 39 | self.assertEqual(len(two_trans.subsamples), 2) 40 | first, second = two_trans.subsamples 41 | self.assertEqual(first.status, "PASSED") 42 | self.assertEqual(first.test_suite, 'test_2_two_transactions') 43 | self.assertEqual(first.test_case, 'transaction-1') 44 | self.assertEqual(second.status, "PASSED") 45 | self.assertEqual(second.test_suite, 'test_2_two_transactions') 46 | self.assertEqual(second.test_case, 'transaction-2') 47 | 48 | nested = samples[2] 49 | middle = nested.subsamples[0] 50 | self.assertEqual('test_3_nested_transactions.outer', middle.test_suite + '.' + middle.test_case) 51 | self.assertEqual(middle.status, "PASSED") 52 | inner = middle.subsamples[0] 53 | self.assertEqual('outer.inner', inner.test_suite + '.' + inner.test_case) 54 | self.assertEqual(inner.status, "PASSED") 55 | 56 | no_tran = samples[3] 57 | self.assertEqual(no_tran.status, "PASSED") 58 | self.assertEqual(no_tran.test_suite, "TestTransactions") 59 | self.assertEqual(no_tran.test_case, "test_4_no_transactions") 60 | 61 | with_assert = samples[4] 62 | self.assertEqual(with_assert.status, "PASSED") 63 | self.assertEqual(len(with_assert.subsamples), 1) 64 | request = with_assert.subsamples[0] 65 | self.assertEqual(request.test_suite, "test_5_apiritif_assertions") 66 | self.assertEqual(request.test_case, "http://blazedemo.com/") 67 | self.assertEqual(len(request.assertions), 1) 68 | assertion = request.assertions[0] 69 | self.assertEqual(assertion.name, "assert_ok") 70 | self.assertEqual(assertion.failed, False) 71 | 72 | assert_failed = samples[5] 73 | self.assertEqual(assert_failed.status, "FAILED") 74 | request = assert_failed.subsamples[0] 75 | self.assertEqual(request.test_suite, "test_6_apiritif_assertions_failed") 76 | self.assertEqual(request.test_case, "http://blazedemo.com/") 77 | self.assertEqual(len(request.assertions), 1) 78 | assertion = request.assertions[0] 79 | self.assertEqual(assertion.name, "assert_failed") 80 | self.assertEqual(assertion.failed, True) 81 | self.assertEqual(assertion.error_message, "Request to https://blazedemo.com/ didn't fail (200)") 82 | 83 | assert_failed_req = samples[6] 84 | self.assertEqual(assert_failed_req.status, "FAILED") 85 | request = assert_failed_req.subsamples[0] 86 | self.assertEqual(request.test_suite, "test_7_failed_request") 87 | self.assertEqual(request.test_case, "http://notexists") 88 | self.assertEqual(len(request.assertions), 0) 89 | 90 | # checks if series of assertions is recorded into trace correctly 91 | assert_seq_problem = samples[7] 92 | self.assertEqual(assert_seq_problem.status, "FAILED") 93 | request = assert_seq_problem.subsamples[0] 94 | self.assertEqual(request.test_suite, "test_8_assertion_trace_problem") 95 | self.assertEqual(len(request.assertions), 3) 96 | self.assertFalse(request.assertions[0].failed) 97 | self.assertFalse(request.assertions[1].failed) 98 | self.assertTrue(request.assertions[2].failed) 99 | 100 | def test_label_single_transaction(self): 101 | test_file = os.path.join(RESOURCES_DIR, "test_single_transaction.py") 102 | self.assertTrue(os.path.exists(test_file)) 103 | store.writer = CachingWriter() 104 | nose2.discover(argv=[__file__, "tests.resources.test_single_transaction", '-v'], module="None", exit=False, 105 | plugins=["tests.unit.test_samples"]) 106 | samples = store.writer.samples 107 | self.assertEqual(len(samples), 1) 108 | 109 | toplevel = samples[0] 110 | self.assertEqual(2, len(toplevel.subsamples)) 111 | first, second = toplevel.subsamples 112 | 113 | self.assertEqual(first.test_suite, "test_requests") 114 | self.assertEqual(first.test_case, "blazedemo 123") 115 | self.assertEqual(1, len(first.subsamples)) 116 | first_req = first.subsamples[0] 117 | self.assertEqual(first_req.test_suite, "blazedemo 123") 118 | self.assertEqual(first_req.test_case, 'https://api.demoblaze.com/entries') 119 | 120 | self.assertEqual(second.test_suite, "test_requests") 121 | self.assertEqual(second.test_case, "blazedemo 456") 122 | self.assertEqual(1, len(second.subsamples)) 123 | second_req = second.subsamples[0] 124 | self.assertEqual(second_req.test_suite, "blazedemo 456") 125 | self.assertEqual(second_req.test_case, 'https://api.demoblaze.com/entries') 126 | 127 | def test_two_transactions(self): 128 | test_file = os.path.join(RESOURCES_DIR, "test_two_transactions.py") 129 | self.assertTrue(os.path.exists(test_file)) 130 | store.writer = CachingWriter() 131 | nose2.discover(argv=[__file__, "tests.resources.test_two_transactions", '-v'], module="None", exit=False, 132 | plugins=["tests.unit.test_samples"]) 133 | samples = store.writer.samples 134 | self.assertEqual(len(samples), 2) 135 | first, second = samples 136 | 137 | self.assertEqual(first.test_case, "test_simple") 138 | self.assertEqual(1, len(first.subsamples)) 139 | self.assertEqual(first.subsamples[0].test_case, "1st") 140 | self.assertEqual(first.subsamples[0].subsamples[0].test_case, 'https://blazedemo.com/') 141 | self.assertEqual(first.subsamples[0].subsamples[0].assertions[0].name, 'assert_ok') 142 | 143 | self.assertEqual(second.test_case, "test_simple") 144 | self.assertEqual(1, len(second.subsamples)) 145 | self.assertEqual(second.subsamples[0].test_case, "2nd") 146 | self.assertEqual(second.subsamples[0].subsamples[0].test_case, 'https://blazedemo.com/vacation.html') 147 | -------------------------------------------------------------------------------- /tests/unit/test_csv.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | from unittest import TestCase 5 | 6 | from apiritif import thread 7 | from apiritif.loadgen import Params, Supervisor 8 | from apiritif.csv import CSVReaderPerThread, thread_data 9 | from apiritif.utils import NormalShutdown 10 | from tests.unit import RESOURCES_DIR 11 | 12 | 13 | class TestCSV(TestCase): 14 | def setUp(self): 15 | thread_data.csv_readers = {} 16 | thread.set_total(1) # set standard concurrency to 1 for all tests 17 | 18 | def test_threads_and_processes(self): 19 | """ check if threads and processes can divide csv fairly """ 20 | script = os.path.join(RESOURCES_DIR, "test_thread_reader.py") 21 | outfile = tempfile.NamedTemporaryFile() 22 | report = outfile.name + "-%s.csv" 23 | outfile.close() 24 | params = Params() 25 | params.concurrency = 4 26 | params.iterations = 2 27 | params.report = report 28 | params.tests = [script] 29 | params.worker_count = 2 30 | 31 | sup = Supervisor(params) 32 | sup.start() 33 | sup.join() 34 | 35 | content = [] 36 | for i in range(params.worker_count): 37 | with open(report % i) as f: 38 | content.extend(f.readlines()[1::2]) 39 | 40 | threads = {"0": [], "1": [], "2": [], "3": []} 41 | content = [item[item.index('"') + 1:].strip() for item in content] 42 | for item in content: 43 | self.assertEqual(item[0], item[2]) # thread equals target 44 | self.assertEqual("a", item[-1]) # age is the same 45 | if item[6] == "0": 46 | self.assertEqual(-1, item.find('+')) 47 | else: 48 | self.assertNotEqual(-1, item.find('+')) # name value is modified 49 | threads[item[0]].append(item[9:-2]) 50 | 51 | # format: :, quoting ignored 52 | target = { 53 | '0': ['""u:ser0""', '""u+:ser0""', 'user4:4', 'user4+:4'], 54 | '1': ['""user1"":1', '""user1""+:1', 'user5:5', 'user5+:5'], 55 | '2': ['user2:""2""', 'user2+:""2""', '""u:ser0""', '""u+:ser0""'], 56 | '3': ['user3:3', 'user3+:3', '""user1"":1', '""user1""+:1']} 57 | 58 | self.assertEqual(threads, target) 59 | 60 | def test_two_readers(self): 61 | """ check different reading speed, fieldnames and separators """ 62 | script = os.path.join(RESOURCES_DIR, "test_two_readers.py") 63 | outfile = tempfile.NamedTemporaryFile() 64 | report = outfile.name + "-%s.csv" 65 | outfile.close() 66 | params = Params() 67 | params.concurrency = 2 68 | params.iterations = 3 69 | params.report = report 70 | params.tests = [script] 71 | params.worker_count = 1 72 | 73 | sup = Supervisor(params) 74 | sup.start() 75 | sup.join() 76 | 77 | content = [] 78 | for i in range(params.worker_count): 79 | with open(report % i) as f: 80 | content.extend(f.readlines()[1::2]) 81 | 82 | threads = {"0": [], "1": []} 83 | content = [item[item.index('"') + 1:].strip() for item in content] 84 | for item in content: 85 | threads[item[0]].append(item[2:]) 86 | 87 | target = { # reader1 runs two times faster 88 | "0": ["0. u,ser0:000:ze:00", "1. u,ser0:000:tu:22", "0. user2:2:fo:44", 89 | "1. user2:2:si:66", "0. user4:4:ze:00", "1. user4:4:tu:22"], 90 | "1": ["0. user1:1:on:11", "1. user1:1:th:33", "0. user3:3:fi:55", 91 | "1. user3:3:se:77", "0. user5:5:on:11", "1. user5:5:th:33"]} 92 | 93 | self.assertEqual(threads, target) 94 | 95 | def test_reader_without_loop(self): 96 | """ check different reading speed, fieldnames and separators """ 97 | reader = CSVReaderPerThread(os.path.join(RESOURCES_DIR, "data/source0.csv"), loop=False) 98 | data = [] 99 | try: 100 | for i in range(20): 101 | reader.read_vars() 102 | data.append(reader.get_vars()) 103 | except NormalShutdown: 104 | self.assertEqual(6, len(data)) 105 | return 106 | 107 | self.fail() 108 | 109 | def test_shared_csv(self): 110 | concurrency = 2 111 | script = os.path.join(RESOURCES_DIR, "test_csv_records.py") 112 | outfile = tempfile.NamedTemporaryFile() 113 | report = outfile.name + "-%s.csv" 114 | outfile.close() 115 | params = Params() 116 | params.concurrency = concurrency 117 | params.iterations = 6 118 | params.report = report 119 | params.tests = [script] 120 | params.worker_count = 1 121 | 122 | sup = Supervisor(params) 123 | sup.start() 124 | sup.join() 125 | 126 | content = [] 127 | for i in range(params.worker_count): 128 | with open(report % i) as f: 129 | content.extend(f.readlines()[1:]) 130 | content = [item.split(",")[3] for item in content] 131 | 132 | with open(os.path.join(RESOURCES_DIR, "data/source2.csv")) as csv: 133 | target_data = csv.readlines() 134 | target_data = [line.strip() for line in target_data] 135 | 136 | target_vus = [str(vu) for vu in range(concurrency)] 137 | real_vus = [record.split(':')[0] for record in content] 138 | self.assertEqual(set(target_vus), set(real_vus)) # all VUs participated 139 | 140 | real_data = [record.split(':')[1] for record in content] 141 | self.assertEqual(set(target_data), set(real_data)) # all data has been read 142 | self.assertEqual(len(target_data), len(real_data)) 143 | 144 | def test_apiritif_no_loop_multiple_records(self): 145 | script = os.path.join(RESOURCES_DIR, "test_csv_records.py") 146 | outfile = tempfile.NamedTemporaryFile() 147 | report = outfile.name + "-%s.csv" 148 | outfile.close() 149 | params = Params() 150 | params.concurrency = 5 # more than records in csv 151 | params.iterations = 10 152 | params.report = report 153 | params.tests = [script] 154 | params.worker_count = 1 155 | 156 | sup = Supervisor(params) 157 | sup.start() 158 | sup.join() 159 | 160 | content = [] 161 | for i in range(params.worker_count): 162 | with open(report % i) as f: 163 | content.extend(f.readlines()[1:]) 164 | content = [item.split(",")[6] for item in content] 165 | 166 | with open(os.path.join(RESOURCES_DIR, "data/source2.csv")) as csv: 167 | self.assertEqual(len(content), len(csv.readlines())) # equals record number in csv 168 | 169 | for line in content: 170 | self.assertTrue("true" in line) 171 | 172 | def test_csv_encoding(self): 173 | reader_utf8 = CSVReaderPerThread(os.path.join(RESOURCES_DIR, "data/encoding_utf8.csv"), loop=False) 174 | reader_utf16 = CSVReaderPerThread(os.path.join(RESOURCES_DIR, "data/encoding_utf16.csv"), loop=False) 175 | data_utf8, data_utf16 = [], [] 176 | 177 | reader_utf8.read_vars() 178 | data_utf8.append(reader_utf8.get_vars()) 179 | 180 | reader_utf16.read_vars() 181 | data_utf16.append(reader_utf16.get_vars()) 182 | 183 | self.assertEqual(data_utf8, data_utf16) 184 | 185 | def test_csv_quoted(self): 186 | readers = [ 187 | CSVReaderPerThread(os.path.join(RESOURCES_DIR, "data/quoted_utf8.csv"), loop=False), 188 | CSVReaderPerThread(os.path.join(RESOURCES_DIR, "data/quoted_utf16.csv"), loop=False), 189 | CSVReaderPerThread(os.path.join(RESOURCES_DIR, "data/unquoted_utf8.csv"), loop=False), 190 | CSVReaderPerThread(os.path.join(RESOURCES_DIR, "data/unquoted_utf16.csv"), loop=False)] 191 | readers_data = [] 192 | 193 | for reader in readers: 194 | reader.read_vars() 195 | readers_data.append(reader.get_vars()) 196 | 197 | result = {'ac1': '1', 'bc1': '2', 'cc1': '3'} 198 | for data in readers_data: 199 | self.assertEqual(data, result) 200 | 201 | def test_csv_delimiter(self): 202 | readers = [ 203 | CSVReaderPerThread(os.path.join(RESOURCES_DIR, "data/encoding_utf8.csv"), loop=False), 204 | CSVReaderPerThread(os.path.join(RESOURCES_DIR, "data/delimiter_tab.csv"), loop=False), 205 | CSVReaderPerThread(os.path.join(RESOURCES_DIR, "data/delimiter_semicolon.csv"), loop=False)] 206 | readers_data = [] 207 | 208 | for reader in readers: 209 | reader.read_vars() 210 | readers_data.append(reader.get_vars()) 211 | 212 | result = {'ac1': '1', 'bc1': '2', 'cc1': '3'} 213 | for data in readers_data: 214 | self.assertEqual(data, result) 215 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apiritif 2 | 3 | Apiritif is a number of utilities aimed to simplify the process of maintaining API tests. 4 | Apiritif tests fully based on python nose tests. This library can help you to develop and run your existing tests. 5 | In order to create any valid tests for Apiritif you can read [nose test documentation](https://nose.readthedocs.io/en/latest/testing.html). 6 | 7 | Check Apiritif version with the following command: 8 | ``` 9 | python -m apiritif -- version 10 | ``` 11 | 12 | Here described some features of Apiritif which can help you to create tests more easily. 13 | 14 | ## Overview 15 | 16 | ## HTTP Requests 17 | 18 | Apiritif allows to use simple `requests`-like API for making HTTP requests. 19 | 20 | ```python 21 | from apiritif import http 22 | 23 | response = http.get("http://example.com") 24 | response.assert_ok() # will raise AssertionError if request wasn't successful 25 | ``` 26 | 27 | `http` object provides the following methods: 28 | ```python 29 | from apiritif import http 30 | 31 | http.get("http://api.example.com/posts") 32 | http.post("http://api.example.com/posts") 33 | http.put("http://api.example.com/posts/1") 34 | http.patch("http://api.example.com/posts/1") 35 | http.delete("http://api.example.com/posts/1") 36 | http.head("http://api.example.com/posts") 37 | ``` 38 | 39 | All methods (`get`, `post`, `put`, `patch`, `delete`, `head`) support the following arguments: 40 | ```python 41 | def get(address, # URL for the request 42 | params=None, # URL params dict 43 | headers=None, # HTTP headers 44 | cookies=None, # request cookies 45 | data=None, # raw request data 46 | json=None, # attach JSON object as request body 47 | encrypted_cert=None, # certificate to use with request 48 | allow_redirects=True, # automatically follow HTTP redirects 49 | timeout=30) # request timeout, by default it's 30 seconds 50 | ``` 51 | 52 | ##### Certificate usage 53 | Currently `http` supports `pem` and `pkcs12` certificates. 54 | Here is an example of certificate usage: 55 | ```python 56 | http.get("http://api.example.com/posts", encrypted_cert=('./cert.pem', 'passphrase')) 57 | ``` 58 | First parameter is path to certificate, second is the passphrase certificate encrypted with. 59 | 60 | ## HTTP Targets 61 | 62 | Target is an object that captures resource name of the URL (protocol, domain, port) 63 | and allows to set some settings applied to all requests made for a target. 64 | 65 | 66 | ```python 67 | from apiritif import http 68 | 69 | qa_env = http.target("http://192.160.0.2") 70 | qa_env.get("/api/v4/user") 71 | qa_env.get("/api/v4/user") 72 | ``` 73 | 74 | Target constructor supports the following options: 75 | ```python 76 | target = apiritif.http.target( 77 | address, # target base address 78 | base_path=None, # base path prepended to all paths (e.g. '/api/v2') 79 | use_cookies=True, # use cookies 80 | additional_headers=None, # additional headers for all requests 81 | keep_alive=True, # reuse opened HTTP connection 82 | auto_assert_ok=True, # automatically invoke 'assert_ok' after each request 83 | ) 84 | ``` 85 | 86 | 87 | ## Assertions 88 | 89 | Apiritif responses provide a lot of useful assertions that can be used on responses. 90 | 91 | Here's the list of assertions that can be used: 92 | ```python 93 | response = http.get("http://example.com/") 94 | 95 | # assert that request succeeded (status code is 2xx or 3xx) 96 | response.assert_ok() 97 | # assert that request has failed 98 | response.assert_failed() 99 | 100 | # status code based assertions 101 | response.assert_2xx() 102 | response.assert_3xx() 103 | response.assert_4xx() 104 | response.assert_5xx() 105 | response.assert_status_code(code) 106 | response.assert_not_status_code(code) 107 | response.assert_status_code_in(codes) 108 | 109 | # content-based assertions 110 | 111 | # assert that response body contains a string 112 | response.assert_in_body(member) 113 | 114 | # assert that response body doesn't contain a string 115 | response.assert_not_in_body(member) 116 | 117 | # search (or match) response body with a regex 118 | response.assert_regex_in_body(regex, match=False) 119 | response.assert_regex_not_in_body(regex, match=False) 120 | 121 | # assert that response has header 122 | response.assert_has_header(header) 123 | 124 | # assert that response has header with given value 125 | response.assert_header_value(header, value) 126 | 127 | # assert that response's headers contains a string 128 | response.assert_in_headers(member) 129 | response.assert_not_in_headers(member) 130 | 131 | # search (or match) response body with a regex 132 | response.assert_regex_in_headers(member) 133 | response.assert_regex_not_in_headers(member) 134 | 135 | # assert that response body matches JSONPath query 136 | response.assert_jsonpath(jsonpath_query, expected_value=None) 137 | response.assert_not_jsonpath(jsonpath_query) 138 | 139 | # assert that response body matches XPath query 140 | response.assert_xpath(xpath_query, parser_type='html', validate=False) 141 | response.assert_not_xpath(xpath_query, parser_type='html', validate=False) 142 | 143 | # assert that HTML response body contains CSS selector item 144 | response.assert_cssselect(selector, expected_value=None, attribute=None) 145 | response.assert_not_cssselect(selector, expected_value=None, attribute=None) 146 | 147 | ``` 148 | 149 | Note that assertions can be chained, so the following construction is entirely valid: 150 | ```python 151 | 152 | response = http.get("http://example.com/") 153 | response.assert_ok().assert_in_body("Example") 154 | ``` 155 | 156 | ## Transactions 157 | 158 | Apiritif allows to group multiple requests or actions into a transaction using a `transaction` context manager. 159 | For example when we have test action like bellow we want to execute requests according to concrete user as a separate piece. 160 | Also we want to process test for `users/all` page even if something wrong with previous actions. 161 | 162 | ```python 163 | def test_with_login(): 164 | user_credentials = data_mock.get_my_user() 165 | http.get("https://blazedemo.com/user/login?id="+user_credentials.id).assert_ok() 166 | http.get("https://blazedemo.com/user/id/personalPage").assert_ok() 167 | http.get("https://blazedemo.com/user/id/getPersonalData").assert_ok() 168 | 169 | http.get("https://blazedemo.com/users/all").assert_ok() 170 | ``` 171 | 172 | Here where we can use transaction in order to wrap login process in one block. 173 | 174 | ```python 175 | def test_with_login(): 176 | with apiritif.transaction('Login'): 177 | user_credentials = data_mock.get_my_user() 178 | http.get("https://blazedemo.com/user/login?id="+user_credentials.id).assert_ok() 179 | http.get("https://blazedemo.com/user/id/personalPage").assert_ok() 180 | http.get("https://blazedemo.com/user/id/getPersonalData").assert_ok() 181 | 182 | http.get("https://blazedemo.com/users/all").assert_ok() 183 | ``` 184 | At the same time requests to `users/all` page will be executed outside of transaction even if something inside transaction fails. 185 | 186 | Transaction defines the name for the block of code. This name with execution results of this particular block will be displayed in the output report. 187 | 188 | #### Smart transactions 189 | 190 | `smart_transaction` is advanced option for test flow control (stop or continue after failed test method). 191 | Let see another test method example: 192 | 193 | ```python 194 | class Tests(TestCase): 195 | def test_available_pages(): 196 | http.get("https://blazedemo.com/").assert_ok() 197 | http.get("https://blazedemo.com/users").assert_ok() 198 | 199 | http.get("https://blazedemo.com/users/search").assert_ok() 200 | http.get("https://blazedemo.com/users/count").assert_ok() 201 | http.get("https://blazedemo.com/users/login").assert_ok() 202 | 203 | http.get("https://blazedemo.com/contactUs").assert_ok() 204 | http.get("https://blazedemo.com/copyright").assert_ok() 205 | ``` 206 | In this case we have multiple requests divided into blocks. I do not want to test pages under `users` space if it is not available. 207 | For this purpose we can use `smart_transaction`. 208 | 209 | ```python 210 | class Tests(TestCase): 211 | def setUp(self): 212 | apiritif.put_into_thread_store(func_mode=True) 213 | 214 | def test_available_pages(): 215 | http.get("https://blazedemo.com/").assert_ok() 216 | 217 | with apiritif.smart_transaction('Availability check'): 218 | http.get("https://blazedemo.com/users").assert_ok() 219 | 220 | with apiritif.smart_transaction('Test users pages'): 221 | http.get("https://blazedemo.com/users/search").assert_ok() 222 | http.get("https://blazedemo.com/users/count").assert_ok() 223 | http.get("https://blazedemo.com/users/login").assert_ok() 224 | 225 | http.get("https://blazedemo.com/contactUs").assert_ok() 226 | http.get("https://blazedemo.com/copyright").assert_ok() 227 | ``` 228 | Now this two blocks are wrapped into `smart_transaction` which would help with error test flow handling and logging. 229 | 230 | Also each transaction defines the name for the block of code and will be displayed in the output report. 231 | 232 | Now about `apiritif.put_into_thread_store(func_mode=True)`, this is test execution mode for apiritif. 233 | We can execute all of the transactions in test no matter what or stop after first failed transaction. 234 | This flag tells to apiritif "Stop execution if some transaction failed". `False` says "Run till the end in any case". 235 | 236 | ##### Nose Flow Control 237 | It's one more feature based on smart transactions. It changes `func_mode` if necessary to execute whole teardown block, 238 | intended to finalize all necessary things. 239 | 240 | ```python 241 | def test_flow-control(self): 242 | try: 243 | self._method_with_exception() 244 | self._skipped_method() 245 | finally: 246 | apiritif.set_stage("teardown") 247 | self._teardown1() 248 | self._teardown2() 249 | ``` 250 | If this test will be interrupted in `_method_with_exception`, both of teardown methods will be executed even if them raise exception. 251 | Please note two differences with usage of `tearDown` method of nose: 252 | 1. all parts of teardown stage will be executed as mentioned above (will be interrupted in regular nose execution) 253 | 2. results of teardown steps will be written by apiritif SampleWriter into output file (nose lost them as tearDown isn't recognised as test). 254 | 255 | ##### Graceful shutdown 256 | Somethimes waiting of end of test isn't necessary and we prefer to break it but save all current results and handle all teardown steps. (see above) 257 | It's possible with GRACEFUL flag. To use it you can run apiritif with GRACEFUL environment variable pointed to any file name. 258 | Apiritif will be interrupted as soon as the file is created. 259 | 260 | ## CSV Reader 261 | In order to use data from csv file as test parameters Apiritif provides two different csv readers. 262 | Simple `CSVReader` helps you to read data from file line by line and use this data wherever you need: 263 | 264 | ```python 265 | data_reader = apiritif.CSVReader('---path to required file---') 266 | class Tests(TestCase): 267 | def test_user_page(): 268 | data_reader.read_vars() 269 | vars = data_reader.get_vars() 270 | http.get("https://blazedemo.com/users/" + vars.user_id).assert_ok() 271 | ``` 272 | 273 | In case of multithreading testing you may need to deviate data between threads and ysu uniq lines for each thread. 274 | `CSVReaderPerThread` helps to solve this problem: 275 | 276 | ```python 277 | data_per_thread_reader = apiritif.CSVReaderPerThread('---path to required file---') 278 | class Tests(TestCase): 279 | def setUp(self): 280 | data_per_thread_reader.read_vars() 281 | self.vars = data_per_thread_reader.get_vars() 282 | 283 | def test_user_page(): 284 | http.get("https://blazedemo.com/users/" + self.vars.user_id).assert_ok() 285 | ``` 286 | 287 | ## Execution results 288 | 289 | Apiritif writes output data from tests in `apiritif.#.csv` files by default. Here `#` is number of executing process. 290 | The output file is similar to this: 291 | ```csv 292 | timeStamp,elapsed,Latency,label,responseCode,responseMessage,success,allThreads,bytes 293 | 1602759519185,0,0,Correct test,,,true,0,2 294 | 1602759519186,0,0,Correct transaction,,,true,0,2 295 | 1602759519187,0,0,Test with exception,,Exception: Horrible error,false,0,2 296 | ``` 297 | It contains test and transaction results for executed tests by one process. 298 | 299 | ### Environment Variables 300 | 301 | There are environment variables to control length of response/request body to be written into traces and logs: 302 | * `APIRITIF_TRACE_BODY_EXCLIMIT` - limit of body part to include into exception messages, default is 1024 303 | * `APIRITIF_TRACE_BODY_HARDLIMIT` - limit of body length to include into JSON trace records, default is unlimited 304 | -------------------------------------------------------------------------------- /apiritif/samples.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright 2017 BlazeMeter Inc. 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | """ 17 | 18 | import copy 19 | import os 20 | import traceback 21 | 22 | import apiritif 23 | from apiritif.http import RequestFailure 24 | 25 | 26 | class Assertion(object): 27 | def __init__(self, name, extras): 28 | self.name = name 29 | self.failed = False 30 | self.error_message = "" 31 | self.error_trace = "" 32 | self.extras = extras 33 | 34 | def set_failed(self, error_message, error_trace=""): 35 | self.failed = True 36 | self.error_message = error_message 37 | self.error_trace = error_trace 38 | 39 | def to_dict(self): 40 | return { 41 | "name": self.name, 42 | "failed": self.failed, 43 | "error_msg": self.error_message, 44 | "error_trace": self.error_trace, 45 | } 46 | 47 | 48 | class PathComponent(object): 49 | def __init__(self, type, value): 50 | self.type = type 51 | self.value = value 52 | 53 | def to_dict(self): 54 | return { 55 | "type": self.type, 56 | "value": self.value, 57 | } 58 | 59 | 60 | class Sample(object): 61 | def __init__(self, test_suite=None, test_case=None, status=None, start_time=None, duration=None, 62 | error_msg=None, error_trace=None): 63 | self.test_suite = test_suite # test label (test method name) 64 | self.test_case = test_case # test suite name (class name) 65 | self.status = status # test status (PASSED/FAILED/BROKEN/SKIPPED) 66 | self.start_time = start_time # test start time 67 | self.duration = duration # test duration 68 | self.error_msg = error_msg # short error message 69 | self.error_trace = error_trace # traceback of a failure 70 | self.extras = {} # extra info: ('file' - location, 'full_name' - full qualified name, 'decsription' - docstr) 71 | self.subsamples = [] # subsamples list 72 | self.assertions = [] # list of assertions 73 | self.path = [] # sample path (i.e. [package, package, module, suite, case, transaction]) 74 | self.parent_sample = None # pointer to parent sample 75 | 76 | def set_failed(self, error_msg, error_trace): 77 | current = self 78 | while current is not None: 79 | current.status = "FAILED" 80 | current.error_msg = error_msg 81 | current.error_trace = error_trace 82 | current = current.parent_sample 83 | 84 | def set_parent(self, parent): 85 | self.parent_sample = parent 86 | 87 | def add_subsample(self, sample): 88 | sample.set_parent(self) 89 | self.subsamples.append(sample) 90 | 91 | def add_assertion(self, name, extras): 92 | self.assertions.append(Assertion(name, extras)) 93 | 94 | def set_assertion_failed(self, name, error_message, error_trace=""): 95 | for ass in reversed(self.assertions): 96 | if ass.name == name: 97 | ass.set_failed(error_message, error_trace) 98 | break 99 | self.set_failed(error_message, error_trace) 100 | 101 | def to_dict(self): 102 | # type: () -> dict 103 | extras = copy.deepcopy(self.extras) 104 | if "assertions" not in extras: 105 | extras["assertions"] = [] 106 | for ass in self.assertions: 107 | extras["assertions"].append({ 108 | "name": ass.name, 109 | "isFailed": ass.failed, 110 | "errorMessage": ass.error_message, 111 | "args": ass.extras['args'], 112 | "kwargs": ass.extras['kwargs'] 113 | }) 114 | 115 | return { 116 | "test_suite": self.test_suite, 117 | "test_case": self.test_case, 118 | "status": self.status, 119 | "start_time": self.start_time, 120 | "duration": self.duration, 121 | "error_msg": self.error_msg, 122 | "error_trace": self.error_trace, 123 | "extras": extras, 124 | "assertions": [ass.to_dict() for ass in self.assertions], 125 | "subsamples": [sample.to_dict() for sample in self.subsamples], 126 | "path": [comp.to_dict() for comp in self.path], 127 | } 128 | 129 | def __repr__(self): 130 | return "Sample(%r)" % self.to_dict() 131 | 132 | 133 | class ApiritifSampleExtractor(object): 134 | def __init__(self): 135 | self.active_transactions = [] 136 | self.response_map = {} # response -> sample 137 | 138 | def parse_recording(self, recording, test_case_sample): 139 | """ 140 | 141 | :type recording: list[apiritif.Event] 142 | :type test_case_sample: Sample 143 | :rtype: list[Sample] 144 | """ 145 | self.active_transactions.append(test_case_sample) 146 | for item in recording: 147 | if isinstance(item, apiritif.Request): 148 | self._parse_request(item) 149 | elif isinstance(item, apiritif.TransactionStarted): 150 | self._parse_transaction_started(item) 151 | elif isinstance(item, apiritif.TransactionEnded): 152 | self._parse_transaction_ended(item) 153 | elif isinstance(item, apiritif.Assertion): 154 | self._parse_assertion(item) 155 | elif isinstance(item, apiritif.AssertionFailure): 156 | self._parse_assertion_failure(item) 157 | elif isinstance(item, apiritif.Event): 158 | self._parse_generic_event(item) 159 | else: 160 | raise ValueError("Unknown kind of event in apiritif recording: %s" % item) 161 | 162 | if len(self.active_transactions) != 1: 163 | # TODO: shouldn't we auto-balance them? 164 | raise ValueError("Can't parse apiritif recordings: unbalanced transactions") 165 | 166 | toplevel_sample = self.active_transactions.pop() 167 | 168 | return [toplevel_sample] 169 | 170 | def _parse_request(self, item): 171 | is_failure = isinstance(item, RequestFailure) 172 | current_tran = self.active_transactions[-1] 173 | sample = Sample( 174 | test_suite=current_tran.test_case, 175 | test_case=item.address, 176 | status="FAILED" if is_failure else "PASSED", 177 | start_time=item.timestamp, 178 | duration=item.response.elapsed.total_seconds(), 179 | ) 180 | if is_failure: 181 | sample.error_msg = str(item.exception).split('\n')[0] 182 | sample.error_trace = traceback.format_exception(type(item.exception), item.exception, None) 183 | 184 | sample.path = current_tran.path + [PathComponent("request", item.address)] 185 | extras = self._extract_extras(item) 186 | if extras: 187 | sample.extras.update(extras) 188 | self.response_map[item.response] = sample 189 | self.active_transactions[-1].add_subsample(sample) 190 | 191 | def _parse_transaction_started(self, item): 192 | current_tran = self.active_transactions[-1] 193 | tran_sample = Sample(status="PASSED", test_case=item.transaction_name, test_suite=current_tran.test_case) 194 | tran_sample.path = current_tran.path + [PathComponent("transaction", item.transaction_name)] 195 | self.active_transactions.append(tran_sample) 196 | 197 | def _parse_transaction_ended(self, item): 198 | tran = item.transaction 199 | tran_sample = self.active_transactions.pop() 200 | assert tran_sample.test_case == item.transaction_name 201 | tran_sample.start_time = tran.start_time() 202 | tran_sample.duration = tran.duration() 203 | if tran.success is not None: 204 | if tran.success: 205 | tran_sample.status = "PASSED" 206 | else: 207 | tran_sample.status = "FAILED" 208 | tran_sample.error_msg = tran.error_message 209 | last_extras = tran_sample.subsamples[-1].extras if tran_sample.subsamples else {} 210 | name = tran.name 211 | method = last_extras.get("requestMethod") or "" 212 | resp_code = tran.response_code() or last_extras.get("responseCode") 213 | reason = last_extras.get("responseMessage") or "" 214 | headers = last_extras.get("requestHeaders") or {} 215 | response_body = tran.response() or last_extras.get("responseBody") or "" 216 | response_time = tran.duration() or last_extras.get("responseTime") or 0.0 217 | request_body = tran.request() or last_extras.get("requestBody") or "" 218 | request_cookies = last_extras.get("requestCookies") or {} 219 | request_headers = last_extras.get("requestHeaders") or {} 220 | extras = copy.deepcopy(tran.extras()) 221 | extras.update(self._extras_dict(name, method, resp_code, reason, headers, 222 | response_body, len(response_body), response_time, 223 | request_body, request_cookies, request_headers)) 224 | tran_sample.extras = extras 225 | self.active_transactions[-1].add_subsample(tran_sample) 226 | 227 | def _parse_assertion(self, item): 228 | sample = self.response_map.get(item.response, None) 229 | if sample is None: 230 | raise ValueError("Found assertion for unknown response: %r", item.response) 231 | sample.add_assertion(item.name, item.extras) 232 | 233 | def _parse_assertion_failure(self, item): 234 | sample = self.response_map.get(item.response, None) 235 | if sample is None: 236 | raise ValueError("Found assertion failure for unknown response") 237 | sample.set_assertion_failed(item.name, item.failure_message, "") 238 | 239 | def _parse_generic_event(self, item): 240 | """ 241 | :type item: apiritif.Event 242 | """ 243 | sample = self.response_map.get(item.response, None) 244 | if sample is None: 245 | raise ValueError("Generic event has to go after a request") 246 | sample.extras.setdefault("additional_events", []).append(item.to_dict()) 247 | 248 | @staticmethod 249 | def _headers_from_dict(headers): 250 | return "\n".join(key + ": " + value for key, value in headers.items()) 251 | 252 | @staticmethod 253 | def _cookies_from_dict(cookies): 254 | return "; ".join("%s=%s" % (key, cookies.get(key)) for key in cookies) 255 | 256 | def _extras_dict(self, url, method, status_code, reason, response_headers, response_body, response_size, 257 | response_time, request_body, request_cookies, request_headers): 258 | record = { 259 | 'responseCode': status_code, 260 | 'responseMessage': reason, 261 | 'responseTime': int(response_time * 1000), 262 | 'connectTime': 0, 263 | 'latency': int(response_time * 1000), 264 | 'responseSize': response_size, 265 | 'requestSize': 0, 266 | 'requestMethod': method, 267 | 'requestURI': url, 268 | 'assertions': [], # will be filled later 269 | 'responseBody': response_body, 270 | 'requestBody': request_body, 271 | 'requestCookies': request_cookies, 272 | 'requestHeaders': request_headers, 273 | 'responseHeaders': response_headers, 274 | } 275 | record["requestCookiesRaw"] = self._cookies_from_dict(record["requestCookies"]) 276 | record["responseBodySize"] = len(record["responseBody"]) 277 | record["requestBodySize"] = len(record["requestBody"]) 278 | record["requestCookiesSize"] = len(record["requestCookiesRaw"]) 279 | record["requestHeadersSize"] = len(self._headers_from_dict(record["requestHeaders"])) 280 | record["responseHeadersSize"] = len(self._headers_from_dict(record["responseHeaders"])) 281 | return record 282 | 283 | def _extract_extras(self, request_event): 284 | resp = request_event.response 285 | req = request_event.request 286 | cookies = request_event.session.cookies 287 | 288 | resp_text = resp.text 289 | req_text = req.body or "" 290 | 291 | hard_limit = int(os.environ.get("APIRITIF_TRACE_BODY_HARDLIMIT", "0")) 292 | if hard_limit: 293 | req_text = req_text[:hard_limit] 294 | resp_text = resp_text[:hard_limit] 295 | 296 | return self._extras_dict( 297 | req.url, req.method, resp.status_code, resp.reason, 298 | dict(resp.headers), resp_text, len(resp.content), resp.elapsed.total_seconds(), 299 | req_text, cookies.get_dict(), dict(resp._request.headers) 300 | ) 301 | -------------------------------------------------------------------------------- /tests/unit/test_loadgen.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import logging 3 | import os 4 | import tempfile 5 | import time 6 | import threading 7 | from unittest import TestCase 8 | from multiprocessing.pool import CLOSE 9 | 10 | import apiritif 11 | from apiritif import store, thread 12 | from apiritif.samples import Sample 13 | from apiritif.loadgen import Worker, Params, Supervisor, JTLSampleWriter 14 | from tests.unit import RESOURCES_DIR 15 | 16 | dummy_tests = [os.path.join(RESOURCES_DIR, "test_dummy.py")] 17 | logging.basicConfig(level=logging.DEBUG) 18 | 19 | 20 | class DummyWriter(JTLSampleWriter): 21 | def __init__(self, output_file, workers_log): 22 | super(DummyWriter, self).__init__(output_file) 23 | with open(workers_log, 'a') as log: 24 | log.write("%s\n" % os.getpid()) 25 | 26 | 27 | class TestLoadGen(TestCase): 28 | def test_thread(self): 29 | outfile = tempfile.NamedTemporaryFile() 30 | params = Params() 31 | params.concurrency = 2 32 | params.iterations = 10 33 | params.report = outfile.name 34 | params.tests = dummy_tests 35 | 36 | worker = Worker(params) 37 | worker.run_nose(params) 38 | 39 | def test_setup_teardown_graceful(self): 40 | error_tests = [os.path.join(RESOURCES_DIR, "setup_teardown_graceful.py")] 41 | 42 | outfile = tempfile.NamedTemporaryFile() 43 | params = Params() 44 | params.concurrency = 1 45 | params.iterations = 1 46 | params.report = outfile.name 47 | params.tests = error_tests 48 | params.verbose = True 49 | 50 | worker = Worker(params) 51 | worker.run_nose(params) 52 | 53 | # todo: fix result of "samples = self.apiritif_extractor.parse_recording(recording, sample)" 54 | test_result = apiritif.get_from_thread_store('test_result') 55 | sample = ['1. setup1', '2. setup2', '3. main1', '4. main2', '5. teardown1', '6. teardown2'] 56 | self.assertEqual(sample, test_result) 57 | 58 | def test_setup_errors(self): 59 | error_tests = [os.path.join(RESOURCES_DIR, "test_setup_errors.py")] 60 | 61 | outfile = tempfile.NamedTemporaryFile() 62 | params = Params() 63 | params.concurrency = 1 64 | params.iterations = 1 65 | params.report = outfile.name 66 | params.tests = error_tests 67 | params.verbose = True 68 | 69 | worker = Worker(params) 70 | self.assertRaises(BaseException, worker.run_nose, params) 71 | 72 | def test_worker(self): 73 | outfile = tempfile.NamedTemporaryFile() 74 | params = Params() 75 | params.concurrency = 2 76 | params.iterations = 10 77 | params.report = outfile.name 78 | params.tests = dummy_tests 79 | 80 | worker = Worker(params) 81 | worker.start() 82 | worker.join() 83 | 84 | def test_empty_worker(self): 85 | outfile = tempfile.NamedTemporaryFile() 86 | params = Params() 87 | params.concurrency = 2 88 | params.iterations = 10 89 | params.report = outfile.name 90 | params.tests = [] 91 | 92 | worker = Worker(params) 93 | self.assertRaises(RuntimeError, worker.start) 94 | 95 | def test_empty_test_file(self): 96 | outfile = tempfile.NamedTemporaryFile() 97 | params = Params() 98 | params.concurrency = 1 99 | params.iterations = 1 100 | params.report = outfile.name 101 | params.tests = [os.path.join(RESOURCES_DIR, "test_invalid.py")] 102 | 103 | worker = Worker(params) 104 | self.assertRaises(RuntimeError, worker.start) 105 | 106 | def test_supervisor(self): 107 | outfile = tempfile.NamedTemporaryFile() 108 | params = Params() 109 | params.tests = dummy_tests 110 | params.report = outfile.name + "%s" 111 | params.concurrency = 9 112 | params.iterations = 5 113 | sup = Supervisor(params) 114 | sup.start() 115 | while sup.is_alive(): 116 | time.sleep(1) 117 | 118 | def test_empty_supervisor(self): 119 | outfile = tempfile.NamedTemporaryFile() 120 | params = Params() 121 | params.tests = [] 122 | params.report = outfile.name + "%s" 123 | params.concurrency = 9 124 | params.iterations = 5 125 | sup = Supervisor(params) 126 | sup.start() 127 | while sup.is_alive(): 128 | time.sleep(1) 129 | 130 | self.assertEqual(CLOSE, sup.workers._state) 131 | 132 | def test_handlers(self): 133 | # handlers must: 134 | # 1. be unique for thread 135 | # 2. be set up every launch of test suite 136 | def log_line(line): 137 | with open(thread.handlers_log, 'a') as log: 138 | log.write("%s\n" % line) 139 | 140 | def mock_get_handlers(): 141 | transaction_handlers = thread.get_from_thread_store('transaction_handlers') 142 | if not transaction_handlers: 143 | transaction_handlers = {'enter': [], 'exit': []} 144 | 145 | length = "%s/%s" % (len(transaction_handlers['enter']), len(transaction_handlers['exit'])) 146 | log_line("get: {pid: %s, idx: %s, iteration: %s, len: %s}" % 147 | (os.getpid(), thread.get_index(), thread.get_iteration(), length)) 148 | return transaction_handlers 149 | 150 | def mock_set_handlers(handlers): 151 | log_line("set: {pid: %s, idx: %s, iteration: %s, handlers: %s}," % 152 | (os.getpid(), thread.get_index(), thread.get_iteration(), handlers)) 153 | thread.put_into_thread_store(transaction_handlers=handlers) 154 | 155 | outfile = tempfile.NamedTemporaryFile() 156 | outfile.close() 157 | 158 | params = Params() 159 | 160 | # use this log to spy on writers 161 | handlers_log = outfile.name + '-handlers.log' 162 | thread.handlers_log = handlers_log 163 | 164 | params.tests = [os.path.join(RESOURCES_DIR, "test_smart_transactions.py")] 165 | params.report = outfile.name + "%s" 166 | 167 | # it causes 2 processes and 3 threads (totally) 168 | params.concurrency = 3 169 | params.worker_count = 2 170 | 171 | params.iterations = 2 172 | saved_get_handlers = apiritif.get_transaction_handlers 173 | saved_set_handlers = apiritif.set_transaction_handlers 174 | apiritif.get_transaction_handlers = mock_get_handlers 175 | apiritif.set_transaction_handlers = mock_set_handlers 176 | try: 177 | sup = Supervisor(params) 178 | sup.start() 179 | while sup.is_alive(): 180 | time.sleep(1) 181 | 182 | with open(handlers_log) as log: 183 | handlers = log.readlines() 184 | 185 | self.assertEqual(36, len(handlers)) 186 | self.assertEqual(6, len([handler for handler in handlers if handler.startswith('set')])) 187 | self.assertEqual(0, len([handler for handler in handlers if handler.endswith('2/2}')])) 188 | 189 | finally: 190 | apiritif.get_transaction_handlers = saved_get_handlers 191 | apiritif.set_transaction_handlers = saved_set_handlers 192 | 193 | os.remove(handlers_log) 194 | for i in range(params.worker_count): 195 | os.remove(params.report % i) 196 | 197 | def test_ramp_up1(self): 198 | outfile = tempfile.NamedTemporaryFile() 199 | 200 | params1 = Params() 201 | params1.concurrency = 50 202 | params1.report = outfile.name 203 | params1.tests = dummy_tests 204 | params1.ramp_up = 60 205 | params1.steps = 5 206 | 207 | params1.worker_count = 2 208 | params1.worker_index = 0 209 | 210 | worker1 = Worker(params1) 211 | res1 = [x.delay for x in worker1._get_thread_params()] 212 | self.assertEquals(params1.concurrency, len(res1)) 213 | 214 | params2 = copy.deepcopy(params1) 215 | params2.worker_index = 1 216 | worker2 = Worker(params2) 217 | res2 = [x.delay for x in worker2._get_thread_params()] 218 | self.assertEquals(params2.concurrency, len(res2)) 219 | 220 | def test_ramp_up2(self): 221 | outfile = tempfile.NamedTemporaryFile() 222 | 223 | params1 = Params() 224 | params1.concurrency = 50 225 | params1.report = outfile.name 226 | params1.tests = dummy_tests 227 | params1.ramp_up = 60 228 | 229 | params1.worker_count = 1 230 | params1.worker_index = 0 231 | 232 | worker1 = Worker(params1) 233 | res1 = [x.delay for x in worker1._get_thread_params()] 234 | self.assertEquals(params1.concurrency, len(res1)) 235 | 236 | def test_unicode_ldjson(self): 237 | outfile = tempfile.NamedTemporaryFile(suffix=".ldjson") 238 | params = Params() 239 | params.concurrency = 2 240 | params.iterations = 1 241 | params.report = outfile.name 242 | params.tests = dummy_tests 243 | 244 | worker = Worker(params) 245 | worker.start() 246 | worker.join() 247 | 248 | with open(outfile.name) as fds: 249 | result = fds.readlines() 250 | self.assertEqual(4, len(result)) 251 | 252 | 253 | class SampleGenerator(threading.Thread): 254 | def __init__(self, writer, index, outfile_name): 255 | super(SampleGenerator, self).__init__(target=self._write_sample) 256 | self.writer = writer 257 | self.index = index 258 | self.outfile_name = outfile_name 259 | self.sample = Sample(start_time=index, duration=index, test_case="Generator %s" % index) 260 | 261 | def _write_sample(self): 262 | self.writer.add(self.sample, self.index, self.index) 263 | time.sleep(0.2) 264 | 265 | with open(self.outfile_name) as log: 266 | self.written_results = log.readlines() 267 | 268 | 269 | class TestWriter(TestCase): 270 | 271 | # Writer have to write results while application is running. 272 | # Here some fake threads (SampleGenerator) send `Sample` on writing. 273 | # Then after a delay we exposing data from the result file and verify there is something already written. 274 | def test_writer_works_in_background(self): 275 | outfile = tempfile.NamedTemporaryFile() 276 | outfile.close() 277 | 278 | writer = JTLSampleWriter(outfile.name) 279 | sample_generators = [SampleGenerator(writer, i, outfile.name) for i in range(5)] 280 | 281 | with writer: 282 | for generator in sample_generators: 283 | generator.start() 284 | for generator in sample_generators: 285 | generator.join() 286 | 287 | while not writer.is_queue_empty() and writer.is_alive(): 288 | time.sleep(0.1) 289 | 290 | for generator in sample_generators: 291 | self.assertTrue(len(generator.written_results) > 1) 292 | 293 | def test_writers_x3(self): 294 | # writers must: 295 | # 1. be the same for threads of one process 296 | # 2. be set up only once 297 | # 3. be different for different processes 298 | def dummy_worker_init(self, params): 299 | """ 300 | :type params: Params 301 | """ 302 | super(Worker, self).__init__(params.concurrency) 303 | self.params = params 304 | store.writer = DummyWriter(self.params.report, self.params.workers_log) 305 | 306 | outfile = tempfile.NamedTemporaryFile() 307 | outfile.close() 308 | 309 | params = Params() 310 | 311 | # use this log to spy on writers 312 | workers_log = outfile.name + '-workers.log' 313 | params.workers_log = workers_log 314 | 315 | params.tests = [os.path.join(RESOURCES_DIR, "test_smart_transactions.py")] 316 | params.report = outfile.name + "%s" 317 | 318 | # it causes 2 processes and 3 threads (totally) 319 | params.concurrency = 3 320 | params.worker_count = 2 321 | 322 | params.iterations = 2 323 | saved_worker_init = Worker.__init__ 324 | Worker.__init__ = dummy_worker_init 325 | try: 326 | sup = Supervisor(params) 327 | sup.start() 328 | while sup.is_alive(): 329 | time.sleep(1) 330 | 331 | with open(workers_log) as log: 332 | writers = log.readlines() 333 | self.assertEqual(2, len(writers)) 334 | self.assertNotEqual(writers[0], writers[1]) 335 | finally: 336 | Worker.__init__ = saved_worker_init 337 | 338 | os.remove(workers_log) 339 | for i in range(params.worker_count): 340 | os.remove(params.report % i) 341 | 342 | 343 | def mock_spawn_worker(params): 344 | with open(params.report, 'w') as log: 345 | log.write(str(os.getpid())) 346 | time.sleep(0.2) 347 | 348 | 349 | class TestMultiprocessing(TestCase): 350 | 351 | # Each worker should be spawned in separate process 352 | # Replace new process function `spawn_worker` with `mock_spawn_worker` 353 | # This mock function writes in process report file pid of the process 354 | # Test collect data from all report files and verify different ids count. 355 | def test_worker_spawned_as_separate_process(self): 356 | outfile = tempfile.NamedTemporaryFile() 357 | outfile.close() 358 | 359 | params = Params() 360 | params.report = outfile.name + "%s" 361 | params.concurrency = 15 362 | params.worker_count = 15 363 | 364 | params.iterations = 1 365 | saved_spawn_worker = apiritif.loadgen.spawn_worker 366 | apiritif.loadgen.spawn_worker = mock_spawn_worker 367 | 368 | try: 369 | sup = Supervisor(params) 370 | sup.start() 371 | while sup.is_alive(): 372 | time.sleep(0.1) 373 | 374 | process_ids = [] 375 | for i in range(params.worker_count): 376 | with open(params.report % i) as f: 377 | process_ids.extend(f.readlines()) 378 | self.assertEqual(params.worker_count, len(set(process_ids))) 379 | 380 | finally: 381 | apiritif.loadgen.spawn_worker = saved_spawn_worker 382 | 383 | for i in range(params.worker_count): 384 | os.remove(params.report % i) 385 | -------------------------------------------------------------------------------- /apiritif/loadgen.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Copyright 2017 BlazeMeter Inc. 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | """ 17 | import copy 18 | import unicodecsv as csv 19 | import json 20 | import logging 21 | import multiprocessing 22 | import os 23 | import sys 24 | import time 25 | import traceback 26 | from multiprocessing.pool import ThreadPool 27 | from optparse import OptionParser 28 | from threading import Thread 29 | 30 | from nose2.main import PluggableTestProgram 31 | from nose2.events import Plugin 32 | 33 | import apiritif 34 | import apiritif.thread as thread 35 | import apiritif.store as store 36 | from apiritif.action_plugins import ActionHandlerFactory, import_plugins 37 | from apiritif.utils import NormalShutdown, log, get_trace, VERSION, graceful 38 | 39 | 40 | # TODO how to implement hits/s control/shape? 41 | # TODO: VU ID for script 42 | # TODO: disable assertions for load mode 43 | 44 | 45 | def spawn_worker(params): 46 | """ 47 | This method has to be module level function 48 | 49 | :type params: Params 50 | """ 51 | setup_logging(params) 52 | log.info("Adding worker: idx=%s\tconcurrency=%s\tresults=%s", params.worker_index, params.concurrency, 53 | params.report) 54 | worker = Worker(params) 55 | worker.start() 56 | worker.join() 57 | 58 | 59 | class Params(object): 60 | def __init__(self): 61 | super(Params, self).__init__() 62 | self.worker_index = 0 63 | self.worker_count = 1 64 | self.thread_index = 0 65 | self.report = None 66 | 67 | self.delay = 0 68 | 69 | self.concurrency = 1 70 | self.iterations = 1 71 | self.ramp_up = 0 72 | self.steps = 0 73 | self.hold_for = 0 74 | 75 | self.verbose = False 76 | 77 | self.tests = None 78 | 79 | def __repr__(self): 80 | return "%s" % self.__dict__ 81 | 82 | 83 | class Supervisor(Thread): 84 | """ 85 | apiritif-loadgen CLI utility 86 | overwatch workers, kill them when terminated 87 | probably reports through stdout log the names of report files 88 | :type params: Params 89 | """ 90 | 91 | def __init__(self, params): 92 | super(Supervisor, self).__init__(target=self._start_workers) 93 | self.daemon = True 94 | self.name = self.__class__.__name__ 95 | 96 | self.params = params 97 | self.workers = None 98 | 99 | def _concurrency_slicer(self, ): 100 | total_concurrency = 0 101 | inc = self.params.concurrency / float(self.params.worker_count) 102 | assert inc >= 1 103 | for idx in range(0, self.params.worker_count): 104 | progress = (idx + 1) * inc 105 | 106 | conc = int(round(progress - total_concurrency)) 107 | assert conc > 0 108 | 109 | log.debug("Idx: %s, concurrency: %s", idx, conc) 110 | 111 | params = copy.deepcopy(self.params) 112 | params.worker_index = idx 113 | params.thread_index = total_concurrency # for subprocess it's index of its first thread 114 | params.concurrency = conc 115 | params.report = self.params.report % idx 116 | params.worker_count = self.params.worker_count 117 | 118 | total_concurrency += conc 119 | 120 | yield params 121 | 122 | assert total_concurrency == self.params.concurrency 123 | 124 | def _start_workers(self): 125 | log.info("Total workers: %s", self.params.worker_count) 126 | 127 | thread.set_total(self.params.concurrency) 128 | self.workers = multiprocessing.Pool(processes=self.params.worker_count) 129 | args = list(self._concurrency_slicer()) 130 | 131 | try: 132 | self.workers.map(spawn_worker, args) 133 | finally: 134 | self.workers.close() 135 | self.workers.join() 136 | # TODO: watch the total test duration, if set, 'cause iteration might last very long 137 | 138 | 139 | class ApiritifSession(PluggableTestProgram.sessionClass): 140 | def __init__(self, *args, **kwargs): 141 | super().__init__(*args, **kwargs) 142 | self.stop_reason = "" 143 | 144 | def set_stop_reason(self, msg): 145 | if not self.stop_reason: 146 | self.stop_reason = msg 147 | 148 | 149 | class Worker(ThreadPool): 150 | def __init__(self, params): 151 | """ 152 | :type params: Params 153 | """ 154 | super(Worker, self).__init__(params.concurrency) 155 | self.params = params 156 | if self.params.report.lower().endswith(".ldjson"): 157 | store.writer = LDJSONSampleWriter(self.params.report) 158 | else: 159 | store.writer = JTLSampleWriter(self.params.report) 160 | 161 | def start(self): 162 | import_plugins() 163 | params = list(self._get_thread_params()) 164 | with store.writer: # writer must be closed finally 165 | try: 166 | self.map(self.run_nose, params) 167 | finally: 168 | self.close() 169 | 170 | def close(self): 171 | log.info("Workers finished, awaiting result writer") 172 | while not store.writer.is_queue_empty() and store.writer.is_alive(): 173 | time.sleep(0.1) 174 | log.info("Results written, shutting down") 175 | super(Worker, self).close() 176 | 177 | def run_nose(self, params): 178 | """ 179 | :type params: Params 180 | """ 181 | if not params.tests: 182 | raise RuntimeError("Nothing to test.") 183 | 184 | thread.set_index(params.thread_index) 185 | log.debug("[%s] Starting nose2 iterations: %s", params.worker_index, params) 186 | assert isinstance(params.tests, list) 187 | # argv.extend(['--with-apiritif', '--nocapture', '--exe', '--nologcapture']) 188 | 189 | end_time = self.params.ramp_up + self.params.hold_for 190 | end_time += time.time() if end_time else 0 191 | time.sleep(params.delay) 192 | store.writer.concurrency += 1 193 | 194 | config = {"tests": params.tests} 195 | if params.verbose: 196 | config["verbosity"] = 3 197 | 198 | iteration = 0 199 | handlers = ActionHandlerFactory.create_all() 200 | log.debug(f'Action handlers created {handlers}') 201 | thread.put_into_thread_store(action_handlers=handlers) 202 | for handler in handlers: 203 | handler.startup() 204 | try: 205 | while not graceful(): 206 | log.debug("Starting iteration:: index=%d,start_time=%.3f", iteration, time.time()) 207 | thread.set_iteration(iteration) 208 | 209 | session = ApiritifSession() 210 | config["session"] = session 211 | ApiritifTestProgram(config=config) 212 | 213 | log.debug("Finishing iteration:: index=%d,end_time=%.3f", iteration, time.time()) 214 | iteration += 1 215 | 216 | # reasons to stop 217 | if session.stop_reason: 218 | if "Nothing to test." in session.stop_reason: 219 | raise RuntimeError("Nothing to test.") 220 | elif session.stop_reason.startswith(NormalShutdown.__name__): 221 | log.info(session.stop_reason) 222 | else: 223 | raise RuntimeError(f"Unknown stop_reason: {session.stop_reason}") 224 | elif 0 < params.iterations <= iteration: 225 | log.debug("[%s] iteration limit reached: %s", params.worker_index, params.iterations) 226 | elif 0 < end_time <= time.time(): 227 | log.debug("[%s] duration limit reached: %s", params.worker_index, params.hold_for) 228 | else: 229 | continue # continue if no one is faced 230 | 231 | break 232 | 233 | finally: 234 | store.writer.concurrency -= 1 235 | 236 | for handler in handlers: 237 | handler.finalize() 238 | 239 | def __reduce__(self): 240 | raise NotImplementedError() 241 | 242 | def _get_thread_params(self): 243 | if not self.params.steps or self.params.steps < 0: 244 | self.params.steps = sys.maxsize 245 | 246 | step_granularity = self.params.ramp_up / self.params.steps 247 | ramp_up_per_thread = self.params.ramp_up / self.params.concurrency 248 | for thr_idx in range(self.params.concurrency): 249 | offset = self.params.worker_index * ramp_up_per_thread / float(self.params.worker_count) 250 | delay = offset + thr_idx * float(self.params.ramp_up) / self.params.concurrency 251 | delay -= delay % step_granularity if step_granularity else 0 252 | params = copy.deepcopy(self.params) 253 | params.thread_index = self.params.thread_index + thr_idx 254 | params.delay = delay 255 | yield params 256 | 257 | 258 | class ApiritifTestProgram(PluggableTestProgram): 259 | def __init__(self, **kwargs): 260 | kwargs['module'] = None 261 | kwargs['exit'] = False 262 | self.config = kwargs.pop("config") 263 | self.session = self.config["session"] 264 | self.conf_verbosity = None if "verbosity" not in self.config else self.config["verbosity"] 265 | super(ApiritifTestProgram, self).__init__(**kwargs) 266 | 267 | def parseArgs(self, argv): 268 | self.testLoader = self.loaderClass(self.session) 269 | self.session.testLoader = self.testLoader 270 | 271 | dir, filename = os.path.split(self.config["tests"][-1]) 272 | self.session.startDir = dir or "." 273 | self.testNames = [os.path.splitext(filename)[0]] 274 | 275 | if self.conf_verbosity: 276 | self.session.verbosity = self.conf_verbosity 277 | self.session.verbosity = 0 278 | 279 | self.defaultPlugins.append("apiritif.loadgen") 280 | self.loadPlugins() 281 | self.createTests() 282 | 283 | 284 | class LDJSONSampleWriter(object): 285 | """ 286 | :type out_stream: file 287 | """ 288 | 289 | def __init__(self, output_file): 290 | super(LDJSONSampleWriter, self).__init__() 291 | self.concurrency = 0 292 | self.output_file = output_file 293 | self.out_stream = None 294 | self._samples_queue = multiprocessing.Queue() 295 | 296 | self._writing = False 297 | self._writer_thread = Thread(target=self._writer) 298 | self._writer_thread.daemon = True 299 | self._writer_thread.name = self.__class__.__name__ 300 | 301 | def __enter__(self): 302 | self.out_stream = open(self.output_file, "wb") 303 | self._writing = True 304 | self._writer_thread.start() 305 | return self 306 | 307 | def is_alive(self): 308 | return self._writer_thread.is_alive() 309 | 310 | def __exit__(self, exc_type, exc_val, exc_tb): 311 | self._writing = False 312 | self._writer_thread.join() 313 | self.out_stream.close() 314 | 315 | def add(self, sample, test_count, success_count): 316 | self._samples_queue.put_nowait((sample, test_count, success_count)) 317 | 318 | def is_queue_empty(self): 319 | return self._samples_queue.empty() 320 | 321 | def _writer(self): 322 | while self._writing: 323 | if self._samples_queue.empty(): 324 | time.sleep(0.1) 325 | 326 | while not self._samples_queue.empty(): 327 | item = self._samples_queue.get(block=True) 328 | try: 329 | sample, test_count, success_count = item 330 | self._write_sample(sample, test_count, success_count) 331 | except BaseException as exc: 332 | log.debug("Processing sample failed: %s\n%s", str(exc), traceback.format_exc()) 333 | log.warning("Couldn't process sample, skipping") 334 | continue 335 | 336 | def _write_sample(self, sample, test_count, success_count): 337 | line = json.dumps(sample.to_dict()) + "\n" 338 | self.out_stream.write(line.encode('utf-8')) 339 | self.out_stream.flush() 340 | 341 | 342 | class JTLSampleWriter(LDJSONSampleWriter): 343 | def __init__(self, output_file): 344 | super(JTLSampleWriter, self).__init__(output_file) 345 | 346 | def __enter__(self): 347 | obj = super(JTLSampleWriter, self).__enter__() 348 | 349 | fieldnames = ["timeStamp", "elapsed", "Latency", "label", "responseCode", "responseMessage", "success", 350 | "allThreads", "bytes"] 351 | endline = '\n' # \r will be preprended automatically because out_stream is opened in text mode 352 | self.writer = csv.DictWriter(self.out_stream, fieldnames=fieldnames, dialect=csv.excel, lineterminator=endline, 353 | encoding='utf-8') 354 | self.writer.writeheader() 355 | self.out_stream.flush() 356 | 357 | return obj 358 | 359 | def _write_sample(self, sample, test_count, success_count): 360 | """ 361 | :type sample: Sample 362 | :type test_count: int 363 | :type success_count: int 364 | """ 365 | self._write_request_subsamples(sample) 366 | 367 | def _get_sample_type(self, sample): 368 | if sample.path: 369 | last = sample.path[-1] 370 | return last.type 371 | else: 372 | return None 373 | 374 | def _write_request_subsamples(self, sample): 375 | if self._get_sample_type(sample) == "request": 376 | self._write_single_sample(sample) 377 | elif sample.subsamples: 378 | for sub in sample.subsamples: 379 | self._write_request_subsamples(sub) 380 | else: 381 | self._write_single_sample(sample) 382 | 383 | def _write_single_sample(self, sample): 384 | """ 385 | :type sample: Sample 386 | """ 387 | bytes = sample.extras.get("responseHeadersSize", 0) + 2 + sample.extras.get("responseBodySize", 0) 388 | 389 | message = sample.error_msg 390 | if not message: 391 | message = sample.extras.get("responseMessage") 392 | if not message: 393 | for sample in sample.subsamples: 394 | if sample.error_msg: 395 | message = sample.error_msg 396 | break 397 | elif sample.extras.get("responseMessage"): 398 | message = sample.extras.get("responseMessage") 399 | break 400 | self.writer.writerow({ 401 | "timeStamp": int(1000 * sample.start_time), 402 | "elapsed": int(1000 * sample.duration), 403 | "Latency": 0, # TODO 404 | "label": sample.test_case, 405 | 406 | "bytes": bytes, 407 | 408 | "responseCode": sample.extras.get("responseCode"), 409 | "responseMessage": message, 410 | "allThreads": self.concurrency, # TODO: there will be a problem aggregating concurrency for rare samples 411 | "success": "true" if sample.status == "PASSED" else "false", 412 | }) 413 | self.out_stream.flush() 414 | 415 | 416 | # noinspection PyPep8Naming 417 | class ApiritifPlugin(Plugin): 418 | """ 419 | Saves test results in a format suitable for Taurus. 420 | :type sample_writer: LDJSONSampleWriter 421 | """ 422 | 423 | configSection = 'apiritif-plugin' 424 | alwaysOn = True 425 | 426 | def __init__(self): 427 | self.controller = store.SampleController(log=log, session=self.session) 428 | apiritif.put_into_thread_store(controller=self.controller) 429 | 430 | def startTest(self, event): 431 | """ 432 | before test run 433 | """ 434 | test = event.test 435 | thread.clean_transaction_handlers() 436 | test_fqn = test.id() # [package].module.class.method 437 | suite_name, case_name = test_fqn.split('.')[-2:] 438 | log.debug("id: %r", test_fqn) 439 | class_method = case_name 440 | 441 | description = test.shortDescription() 442 | self.controller.test_info = { 443 | "test_case": case_name, 444 | "suite_name": suite_name, 445 | "test_fqn": test_fqn, 446 | "description": description, 447 | "class_method": class_method} 448 | self.controller.startTest() 449 | 450 | def stopTest(self, event): 451 | #if not 'NormalShutdown' in self.session.stop_reason 452 | self.controller.stopTest() 453 | 454 | def reportError(self, event): 455 | """ 456 | when a test raises an uncaught exception 457 | :param test: 458 | :param error: 459 | :return: 460 | """ 461 | error = event.testEvent.exc_info 462 | 463 | # test_dict will be None if startTest wasn't called (i.e. exception in setUp/setUpClass) 464 | # status=BROKEN 465 | assertion_name = error[0].__name__ 466 | error_msg = str(error[1]).split('\n')[0] 467 | error_trace = get_trace(error) 468 | if isinstance(error[1], NormalShutdown): 469 | self.session.set_stop_reason(f"{error[1].__class__.__name__} for vu #{thread.get_index()}: {error_msg}") 470 | self.controller.current_sample = None # partial data mustn't be written 471 | else: 472 | if self.controller.current_sample is not None: 473 | self.controller.addError(assertion_name, error_msg, error_trace) 474 | else: # error in test infrastructure (e.g. module setup()) 475 | log.error("\n".join((assertion_name, error_msg, error_trace))) 476 | 477 | def reportFailure(self, event): 478 | """ 479 | when a test fails 480 | :param test: 481 | :param error: 482 | 483 | :return: 484 | """ 485 | # status=FAILED 486 | self.controller.addFailure(event.testEvent.exc_info) 487 | 488 | def reportSuccess(self, event): 489 | """ 490 | when a test passes 491 | :param test: 492 | :return: 493 | """ 494 | self.controller.addSuccess() 495 | 496 | def afterTestRun(self, event): 497 | """ 498 | After all tests 499 | """ 500 | if not self.controller.test_count: 501 | self.session.set_stop_reason("Nothing to test.") 502 | 503 | 504 | def cmdline_to_params(): 505 | parser = OptionParser() 506 | parser.add_option('', '--concurrency', action='store', type="int", default=1) 507 | parser.add_option('', '--iterations', action='store', type="int", default=sys.maxsize) 508 | parser.add_option('', '--ramp-up', action='store', type="float", default=0) 509 | parser.add_option('', '--steps', action='store', type="int", default=sys.maxsize) 510 | parser.add_option('', '--hold-for', action='store', type="float", default=0) 511 | parser.add_option('', '--result-file-template', action='store', type="str", default="result-%s.csv") 512 | parser.add_option('', '--verbose', action='store_true', default=False) 513 | parser.add_option('', "--version", action='store_true', default=False) 514 | opts, args = parser.parse_args() 515 | log.debug("%s %s", opts, args) 516 | 517 | if opts.version: 518 | print(VERSION) 519 | sys.exit(0) 520 | 521 | params = Params() 522 | params.concurrency = opts.concurrency 523 | params.ramp_up = opts.ramp_up 524 | params.steps = opts.steps 525 | params.iterations = opts.iterations 526 | params.hold_for = opts.hold_for 527 | 528 | params.report = opts.result_file_template 529 | params.tests = args 530 | params.worker_count = 1 # min(params.concurrency, multiprocessing.cpu_count()) 531 | params.verbose = opts.verbose 532 | 533 | return params 534 | 535 | 536 | def setup_logging(params): 537 | logformat = "%(asctime)s:%(levelname)s:%(process)s:%(thread)s:%(name)s:%(message)s" 538 | apiritif.http.log.setLevel(logging.WARNING) 539 | if params.verbose: 540 | logging.basicConfig(level=logging.DEBUG, stream=sys.stdout, format=logformat) 541 | else: 542 | logging.basicConfig(level=logging.INFO, stream=sys.stdout, format=logformat) 543 | log.setLevel(logging.INFO) # TODO: do we need to include apiritif debug logs in verbose mode? 544 | 545 | 546 | def main(): 547 | cmd_params = cmdline_to_params() 548 | setup_logging(cmd_params) 549 | supervisor = Supervisor(cmd_params) 550 | supervisor.start() 551 | supervisor.join() 552 | 553 | 554 | if __name__ == '__main__': 555 | main() 556 | -------------------------------------------------------------------------------- /apiritif/http.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a toplevel package of Apiritif tool 3 | 4 | Copyright 2017 BlazeMeter Inc. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | """ 18 | import copy 19 | import os 20 | import threading 21 | import time 22 | from functools import wraps 23 | from io import BytesIO 24 | 25 | import requests 26 | from jsonpath_ng.ext import parse as jsonpath_parse 27 | from lxml import etree, html 28 | from requests.structures import CaseInsensitiveDict 29 | 30 | import apiritif 31 | from apiritif.ssl_adapter import SSLAdapter 32 | from apiritif.thread import get_from_thread_store, put_into_thread_store 33 | from apiritif.utilities import * 34 | from apiritif.utils import headers_as_text, assert_regexp, assert_not_regexp, log, get_trace, NormalShutdown, graceful 35 | 36 | BODY_LIMIT = int(os.environ.get("APIRITIF_TRACE_BODY_EXCLIMIT", "1024")) 37 | 38 | 39 | class TimeoutError(Exception): 40 | pass 41 | 42 | 43 | class ConnectionError(Exception): 44 | pass 45 | 46 | 47 | class http(object): 48 | log = log.getChild('http') 49 | 50 | @staticmethod 51 | def target(*args, **kwargs): 52 | return HTTPTarget(*args, **kwargs) 53 | 54 | @staticmethod 55 | def request(method, address, session=None, 56 | params=None, headers=None, cookies=None, data=None, json=None, files=None, 57 | encrypted_cert=None, allow_redirects=True, timeout=30): 58 | """ 59 | 60 | :param method: str 61 | :param address: str 62 | :return: response 63 | :rtype: HTTPResponse 64 | """ 65 | http.log.info("Request: %s %s", method, address) 66 | msg = "Request: params=%r, headers=%r, cookies=%r, data=%r, json=%r, files=%r, allow_redirects=%r, timeout=%r" 67 | http.log.debug(msg, params, headers, cookies, data, json, files, allow_redirects, timeout) 68 | 69 | if headers is None: 70 | headers = {} 71 | if "User-Agent" not in headers: 72 | headers["User-Agent"] = "Apiritif" 73 | 74 | if session is None: 75 | session = requests.Session() 76 | 77 | if encrypted_cert is not None: 78 | certificate_file_path, passphrase = encrypted_cert 79 | adapter = SSLAdapter(certificate_file_path=certificate_file_path, passphrase=passphrase) 80 | session.mount('https://', adapter) 81 | 82 | request = requests.Request(method, address, 83 | params=params, headers=headers, cookies=cookies, json=json, data=data, files=files) 84 | prepared = session.prepare_request(request) 85 | settings = session.merge_environment_settings(prepared.url, {}, False, False, None) 86 | try: 87 | response = session.send(prepared, allow_redirects=allow_redirects, timeout=timeout, **settings) 88 | except requests.exceptions.Timeout as exc: 89 | recorder.record_http_request_failure(method, address, prepared, exc, session) 90 | raise TimeoutError("Connection to %s timed out" % address) 91 | except requests.exceptions.ConnectionError as exc: 92 | recorder.record_http_request_failure(method, address, prepared, exc, session) 93 | raise ConnectionError("Connection to %s failed" % address) 94 | except BaseException as exc: 95 | recorder.record_http_request_failure(method, address, prepared, exc, session) 96 | raise 97 | http.log.info("Response: %s %s", response.status_code, response.reason) 98 | http.log.debug("Response headers: %r", response.headers) 99 | http.log.debug("Response cookies: %r", {x: response.cookies.get(x) for x in response.cookies}) 100 | http.log.debug('Response content: \n%s', response.content) 101 | wrapped_response = HTTPResponse(response) 102 | recorder.record_http_request(method, address, prepared, wrapped_response, session) 103 | return wrapped_response 104 | 105 | @staticmethod 106 | def get(address, **kwargs): 107 | return http.request("GET", address, **kwargs) 108 | 109 | @staticmethod 110 | def post(address, **kwargs): 111 | return http.request("POST", address, **kwargs) 112 | 113 | @staticmethod 114 | def put(address, **kwargs): 115 | return http.request("PUT", address, **kwargs) 116 | 117 | @staticmethod 118 | def delete(address, **kwargs): 119 | return http.request("DELETE", address, **kwargs) 120 | 121 | @staticmethod 122 | def patch(address, **kwargs): 123 | return http.request("PATCH", address, **kwargs) 124 | 125 | @staticmethod 126 | def head(address, **kwargs): 127 | return http.request("HEAD", address, **kwargs) 128 | 129 | @staticmethod 130 | def options(address, **kwargs): 131 | return http.request("OPTIONS", address, **kwargs) 132 | 133 | @staticmethod 134 | def connect(address, **kwargs): 135 | return http.request("CONNECT", address, **kwargs) 136 | 137 | 138 | class transaction(object): 139 | def __init__(self, name): 140 | self.name = name 141 | self.success = None # None | True | False 142 | self.error_message = None 143 | self._request = None 144 | self._response = None 145 | self._response_code = None 146 | self._start_ts = None 147 | self._finish_ts = None 148 | self._extras = {} 149 | 150 | def __enter__(self): 151 | self.start() 152 | return self 153 | 154 | def __exit__(self, exc_type, exc_value, traceback): 155 | if exc_value is not None: 156 | self.fail("%s: %s" % (exc_type.__name__, str(exc_value))) 157 | self.finish() 158 | 159 | def start(self, start_time=None): 160 | if self._start_ts is None: 161 | self._start_ts = start_time or time.time() 162 | recorder.record_transaction_start(self) 163 | 164 | def finish(self, end_time=None): 165 | if self._start_ts is None: 166 | raise ValueError("Can't finish non-started transaction %s" % self.name) 167 | if self._finish_ts is None: 168 | self._finish_ts = end_time or time.time() 169 | recorder.record_transaction_end(self) 170 | 171 | def finished(self): 172 | return self._start_ts is not None and self._finish_ts is not None 173 | 174 | def start_time(self): 175 | return self._start_ts 176 | 177 | def duration(self): 178 | if self.finished(): 179 | return self._finish_ts - self._start_ts 180 | 181 | def fail(self, message=""): 182 | self.success = False 183 | self.error_message = message 184 | 185 | def request(self): 186 | return self._request 187 | 188 | def set_request(self, value): 189 | self._request = value 190 | 191 | def response(self): 192 | return self._response 193 | 194 | def set_response(self, value): 195 | self._response = value 196 | 197 | def response_code(self): 198 | return self._response_code 199 | 200 | def set_response_code(self, code): 201 | self._response_code = code 202 | 203 | def attach_extra(self, key, value): 204 | self._extras[key] = value 205 | 206 | def extras(self): 207 | return self._extras 208 | 209 | def __repr__(self): 210 | tmpl = "transaction(name=%r, success=%r)" 211 | return tmpl % (self.name, self.success) 212 | 213 | 214 | class transaction_logged(transaction): 215 | pass 216 | 217 | 218 | class smart_transaction(transaction_logged): 219 | def __init__(self, name): 220 | super(smart_transaction, self).__init__(name=name) 221 | self.driver, self.func_mode, self.controller = get_from_thread_store(("driver", "func_mode", "controller")) 222 | 223 | if self.controller.tran_mode: 224 | self.controller.startTest() 225 | else: 226 | self.controller.tran_mode = True 227 | 228 | self.test_suite = self.controller.current_sample.test_suite 229 | 230 | def __enter__(self): 231 | self.controller.set_start_time() 232 | 233 | super(smart_transaction, self).__enter__() 234 | put_into_thread_store(test_case=self.name, test_suite=self.test_suite) 235 | for func in apiritif.get_transaction_handlers()["enter"]: 236 | func() 237 | 238 | def __exit__(self, exc_type, exc_val, exc_tb): 239 | super(smart_transaction, self).__exit__(exc_type, exc_val, exc_tb) 240 | 241 | message = '' 242 | 243 | if exc_type: 244 | message = str(exc_val) 245 | exc = exc_type, exc_val, exc_tb 246 | if isinstance(exc_val, AssertionError): 247 | status = 'failed' 248 | self.controller.addFailure(exc, is_transaction=True) 249 | else: 250 | status = 'broken' 251 | tb = get_trace(exc) 252 | self.controller.addError(exc_type.__name__, message, tb, is_transaction=True) 253 | 254 | else: 255 | status = 'success' 256 | self.controller.addSuccess(is_transaction=True) 257 | 258 | put_into_thread_store(status=status, message=message) 259 | for func in apiritif.get_transaction_handlers()["exit"]: 260 | func() 261 | 262 | self.controller.stopTest(is_transaction=True) 263 | 264 | stage = apiritif.get_stage() 265 | if stage == "teardown": 266 | self.func_mode = False 267 | elif graceful(): # and stage in ("setup", "main") 268 | raise NormalShutdown("graceful!") 269 | 270 | return not self.func_mode # don't reraise in load mode 271 | 272 | 273 | class Event(object): 274 | def __init__(self, response=None): 275 | self.timestamp = time.time() 276 | self.response = response 277 | 278 | def to_dict(self): # supposed to be overridden by extenders 279 | return {} 280 | 281 | 282 | class Request(Event): 283 | def __init__(self, method, address, request, response, session): 284 | """ 285 | :type method: str 286 | :type address: str 287 | :type request: requests.PreparedRequest 288 | :type response: HTTPResponse 289 | :type session: requests.Session 290 | """ 291 | super(Request, self).__init__(response) 292 | self.method = method 293 | self.address = address 294 | self.request = request 295 | self.session = session 296 | 297 | def __repr__(self): 298 | return "Request(method=%r, address=%r)" % (self.method, self.address) 299 | 300 | 301 | class RequestFailure(Request): 302 | def __init__(self, method, address, request, exc, session): 303 | """ 304 | 305 | :type method: str 306 | :type address: str 307 | :type request: requests.PreparedRequest 308 | :type exc: BaseException 309 | :type session: requests.Session 310 | """ 311 | response = requests.Response() 312 | response.request = request 313 | response.status_code = 999 314 | response._content = "" 315 | 316 | super(RequestFailure, self).__init__(method, address, request, HTTPResponse(response), session) 317 | self.method = method 318 | self.address = address 319 | self.request = request 320 | self.exception = exc 321 | self.session = session 322 | 323 | def __repr__(self): 324 | return "RequestFailure(method=%r, address=%r)" % (self.method, self.address) 325 | 326 | 327 | class TransactionStarted(Event): 328 | def __init__(self, transaction): 329 | super(TransactionStarted, self).__init__(None) 330 | self.transaction = transaction 331 | self.transaction_name = transaction.name 332 | 333 | def __repr__(self): 334 | return "TransactionStarted(transaction_name=%r)" % self.transaction_name 335 | 336 | 337 | class TransactionEnded(Event): 338 | def __init__(self, transaction): 339 | super(TransactionEnded, self).__init__() 340 | self.transaction = transaction 341 | self.transaction_name = transaction.name 342 | 343 | def __repr__(self): 344 | return "TransactionEnded(transaction_name=%r)" % self.transaction_name 345 | 346 | 347 | class Assertion(Event): 348 | def __init__(self, name, response, extras): 349 | super(Assertion, self).__init__(response) 350 | self.name = name 351 | self.extras = extras 352 | 353 | def __repr__(self): 354 | return "Assertion(name=%r)" % self.name 355 | 356 | 357 | class AssertionFailure(Event): 358 | def __init__(self, assertion_name, response, failure_message): 359 | super(AssertionFailure, self).__init__(response) 360 | self.name = assertion_name 361 | self.failure_message = failure_message 362 | 363 | def __repr__(self): 364 | return "Assertion(name=%r, failure_message=%r)" % (self.name, self.failure_message) 365 | 366 | 367 | class _EventRecorder(object): 368 | local = threading.local() 369 | 370 | def __init__(self): 371 | self.log = log.getChild('recorder') 372 | self.log.debug("Creating recorder") 373 | 374 | def get_recording(self): 375 | rec = getattr(self.local, 'recording', None) 376 | if rec is None: 377 | self.local.recording = [] 378 | return self.local.recording 379 | 380 | def pop_events(self, from_ts, to_ts): 381 | recording = self.get_recording() 382 | collected = [] 383 | new_recording = [] 384 | for event in recording: 385 | if from_ts <= event.timestamp <= to_ts: 386 | collected.append(event) 387 | else: 388 | new_recording.append(event) 389 | del recording[:] 390 | recording.extend(new_recording) 391 | return collected 392 | 393 | def record_event(self, event): 394 | self.log.debug("Recording event %r", event) 395 | recording = self.get_recording() 396 | recording.append(event) 397 | 398 | def record_transaction_start(self, tran): 399 | self.record_event(TransactionStarted(tran)) 400 | if isinstance(tran, transaction_logged): 401 | self.log.info(u"Transaction started:: start_time=%.3f,name=%s", tran.start_time(), tran.name) 402 | 403 | def record_transaction_end(self, tran): 404 | self.record_event(TransactionEnded(tran)) 405 | if isinstance(tran, transaction_logged): 406 | self.log.info(u"Transaction ended:: duration=%.3f,name=%s", tran.duration(), tran.name) 407 | 408 | def record_http_request(self, method, address, request, response, session): 409 | self.record_event(Request(method, address, request, response, session)) 410 | 411 | def record_http_request_failure(self, method, address, request, exception, session): 412 | failure = RequestFailure(method, address, request, exception, session) 413 | self.record_event(failure) 414 | 415 | def record_assertion(self, assertion_name, target_response, extras): 416 | self.record_event(Assertion(assertion_name, target_response, extras)) 417 | 418 | def record_assertion_failure(self, assertion_name, target_response, failure_message): 419 | self.record_event(AssertionFailure(assertion_name, target_response, failure_message)) 420 | 421 | @staticmethod 422 | def assertion_decorator(assertion_method): 423 | @wraps(assertion_method) 424 | def _impl(self, *method_args, **method_kwargs): 425 | assertion_name = getattr(assertion_method, '__name__', 'assertion') 426 | extras = {"args": list(method_args), "kwargs": method_kwargs} 427 | recorder.record_assertion(assertion_name, self, extras) 428 | try: 429 | return assertion_method(self, *method_args, **method_kwargs) 430 | except BaseException as exc: 431 | recorder.record_assertion_failure(assertion_name, self, str(exc)) 432 | raise 433 | 434 | return _impl 435 | 436 | 437 | recorder = _EventRecorder() 438 | 439 | 440 | class HTTPTarget(object): 441 | def __init__(self, 442 | address, 443 | base_path=None, 444 | use_cookies=True, 445 | additional_headers=None, 446 | keep_alive=True, 447 | auto_assert_ok=True, 448 | timeout=30, 449 | allow_redirects=True, 450 | session=None, 451 | cert=None, 452 | encrypted_cert=None): 453 | self.address = address 454 | # config flags 455 | self._base_path = base_path 456 | self._use_cookies = use_cookies 457 | self._keep_alive = keep_alive 458 | self._additional_headers = additional_headers or {} 459 | self._auto_assert_ok = auto_assert_ok 460 | self._timeout = timeout 461 | self._allow_redirects = allow_redirects 462 | # internal vars 463 | self.__session = session 464 | 465 | def use_cookies(self, use=True): 466 | self._use_cookies = use 467 | return self 468 | 469 | def base_path(self, base_path): 470 | self._base_path = base_path 471 | return self 472 | 473 | def keep_alive(self, keep=True): 474 | self._keep_alive = keep 475 | return self 476 | 477 | def additional_headers(self, headers): 478 | self._additional_headers.update(headers) 479 | return self 480 | 481 | def auto_assert_ok(self, value=True): 482 | self._auto_assert_ok = value 483 | return self 484 | 485 | def timeout(self, value): 486 | self._timeout = value 487 | return self 488 | 489 | def allow_redirects(self, value=True): 490 | self._allow_redirects = value 491 | return self 492 | 493 | def _bake_address(self, path): 494 | addr = self.address 495 | if self._base_path is not None: 496 | addr += self._base_path 497 | addr += path 498 | return addr 499 | 500 | def request(self, method, path, 501 | params=None, headers=None, cookies=None, data=None, json=None, files=None, 502 | allow_redirects=None, timeout=None): 503 | """ 504 | Prepares and sends an HTTP request. Returns the HTTPResponse object. 505 | 506 | :param method: str 507 | :param path: str 508 | :return: response 509 | :rtype: HTTPResponse 510 | """ 511 | headers = headers or {} 512 | timeout = timeout if timeout is not None else self._timeout 513 | allow_redirects = allow_redirects if allow_redirects is not None else self._allow_redirects 514 | 515 | if self._keep_alive and self.__session is None: 516 | self.__session = requests.Session() 517 | 518 | if self.__session is not None and not self._use_cookies: 519 | self.__session.cookies.clear() 520 | 521 | address = self._bake_address(path) 522 | req_headers = copy.deepcopy(self._additional_headers) 523 | req_headers.update(headers) 524 | 525 | response = http.request(method, address, session=self.__session, 526 | params=params, headers=req_headers, cookies=cookies, data=data, json=json, files=files, 527 | allow_redirects=allow_redirects, timeout=timeout) 528 | if self._auto_assert_ok: 529 | response.assert_ok() 530 | return response 531 | 532 | def get(self, path, **kwargs): 533 | # TODO: how to reuse requests.session? - pass it as additional parameter for http.request ? 534 | return self.request("GET", path, **kwargs) 535 | 536 | def post(self, path, **kwargs): 537 | return self.request("POST", path, **kwargs) 538 | 539 | def put(self, path, **kwargs): 540 | return self.request("PUT", path, **kwargs) 541 | 542 | def delete(self, path, **kwargs): 543 | return self.request("DELETE", path, **kwargs) 544 | 545 | def patch(self, path, **kwargs): 546 | return self.request("PATCH", path, **kwargs) 547 | 548 | def head(self, path, **kwargs): 549 | return self.request("HEAD", path, **kwargs) 550 | 551 | def options(self, path, **kwargs): 552 | return self.request("OPTIONS", path, **kwargs) 553 | 554 | def connect(self, path, **kwargs): 555 | return self.request("CONNECT", path, **kwargs) 556 | 557 | 558 | class HTTPResponse(object): 559 | def __init__(self, py_response): 560 | """ 561 | Construct HTTPResponse from requests.Response object 562 | 563 | :type py_response: requests.Response 564 | """ 565 | self.url = py_response.url 566 | self.method = py_response.request.method 567 | self.status_code = int(py_response.status_code) 568 | self.reason = py_response.reason 569 | 570 | self.headers = CaseInsensitiveDict(py_response.headers) 571 | self.cookies = {x: py_response.cookies.get(x) for x in py_response.cookies} 572 | 573 | self.text = py_response.text 574 | self.content = py_response.content 575 | 576 | self.elapsed = py_response.elapsed 577 | 578 | self._response = py_response 579 | self._request = py_response.request 580 | 581 | def json(self): 582 | return self._response.json() 583 | 584 | def __eq__(self, other): 585 | """ 586 | :type other: HTTPResponse 587 | """ 588 | return isinstance(other, self.__class__) \ 589 | and self.status_code == other.status_code \ 590 | and self.method == other.method \ 591 | and self.url == other.url \ 592 | and self.reason == other.reason \ 593 | and self.headers == other.headers \ 594 | and self.cookies == other.cookies \ 595 | and self.text == other.text \ 596 | and self.content == other.content 597 | 598 | def __hash__(self): 599 | return hash((self.url, self.method, self.status_code, self.reason, self.text, self.content)) 600 | 601 | def __repr__(self): 602 | params = (self.method, self.url, self.status_code, self.reason) 603 | return "%s %s => %s %s" % params 604 | 605 | @recorder.assertion_decorator 606 | def assert_ok(self, msg=None): 607 | if self.status_code >= 400: 608 | msg = msg or "Request to %s didn't succeed (%s)" % (self.url, self.status_code) 609 | raise AssertionError(msg) 610 | return self 611 | 612 | @recorder.assertion_decorator 613 | def assert_failed(self, msg=None): 614 | if self.status_code < 400: 615 | msg = msg or "Request to %s didn't fail (%s)" % (self.url, self.status_code) 616 | raise AssertionError(msg) 617 | return self 618 | 619 | @recorder.assertion_decorator 620 | def assert_2xx(self, msg=None): 621 | if not 200 <= self.status_code < 300: 622 | msg = msg or "Response code isn't 2xx, it's %s" % self.status_code 623 | raise AssertionError(msg) 624 | return self 625 | 626 | @recorder.assertion_decorator 627 | def assert_3xx(self, msg=None): 628 | if not 300 <= self.status_code < 400: 629 | msg = msg or "Response code isn't 3xx, it's %s" % self.status_code 630 | raise AssertionError(msg) 631 | return self 632 | 633 | @recorder.assertion_decorator 634 | def assert_4xx(self, msg=None): 635 | if not 400 <= self.status_code < 500: 636 | msg = msg or "Response code isn't 4xx, it's %s" % self.status_code 637 | raise AssertionError(msg) 638 | return self 639 | 640 | @recorder.assertion_decorator 641 | def assert_5xx(self, msg=None): 642 | if not 500 <= self.status_code < 600: 643 | msg = msg or "Response code isn't 5xx, it's %s" % self.status_code 644 | raise AssertionError(msg) 645 | return self 646 | 647 | @recorder.assertion_decorator 648 | def assert_status_code(self, code, msg=None): 649 | actual = str(self.status_code) 650 | expected = str(code) 651 | if actual != expected: 652 | msg = msg or "Actual status code (%s) didn't match expected (%s)" % (actual, expected) 653 | raise AssertionError(msg) 654 | return self 655 | 656 | @recorder.assertion_decorator 657 | def assert_status_code_in(self, codes, msg=None): 658 | actual = str(self.status_code) 659 | expected = [str(code) for code in codes] 660 | if actual not in expected: 661 | msg = msg or "Actual status code (%s) is not one of expected expected (%s)" % (actual, expected) 662 | raise AssertionError(msg) 663 | return self 664 | 665 | @recorder.assertion_decorator 666 | def assert_not_status_code(self, code, msg=None): 667 | actual = str(self.status_code) 668 | expected = str(code) 669 | if actual == expected: 670 | msg = msg or "Actual status code (%s) unexpectedly matched" % actual 671 | raise AssertionError(msg) 672 | return self 673 | 674 | @recorder.assertion_decorator 675 | def assert_in_body(self, member, msg=None): 676 | if member not in self.text: 677 | msg = msg or "%r wasn't found in response body" % member 678 | raise AssertionError(msg) 679 | return self 680 | 681 | @recorder.assertion_decorator 682 | def assert_not_in_body(self, member, msg=None): 683 | if member in self.text: 684 | msg = msg or "%r was found in response body" % member 685 | raise AssertionError(msg) 686 | return self 687 | 688 | @recorder.assertion_decorator 689 | def assert_regex_in_body(self, regex, match=False, msg=None): 690 | assert_regexp(regex, self.text, match=match, msg=msg) 691 | return self 692 | 693 | @recorder.assertion_decorator 694 | def assert_regex_not_in_body(self, regex, match=False, msg=None): 695 | assert_not_regexp(regex, self.text, match=match, msg=msg) 696 | return self 697 | 698 | # TODO: assert_content_type? 699 | 700 | @recorder.assertion_decorator 701 | def assert_has_header(self, header, msg=None): 702 | if header not in self.headers: 703 | msg = msg or "Header %s wasn't found in response headers: %r" % (header, self.headers) 704 | raise AssertionError(msg) 705 | return self 706 | 707 | @recorder.assertion_decorator 708 | def assert_header_value(self, header, value, msg=None): 709 | self.assert_has_header(header) 710 | actual = self.headers[header] 711 | if actual != value: 712 | msg = msg or "Actual header value (%r) isn't equal to expected (%r)" % (actual, value) 713 | raise AssertionError(msg) 714 | return self 715 | 716 | @recorder.assertion_decorator 717 | def assert_in_headers(self, member, msg=None): 718 | headers_text = headers_as_text(self.headers) 719 | if member not in headers_text: 720 | msg = msg or "Header %s wasn't found in response headers text: %r" % (member, headers_text) 721 | raise AssertionError(msg) 722 | return self 723 | 724 | @recorder.assertion_decorator 725 | def assert_not_in_headers(self, member, msg=None): 726 | if member in headers_as_text(self.headers): 727 | msg = msg or "Header %s was found in response headers text" % member 728 | raise AssertionError(msg) 729 | return self 730 | 731 | @recorder.assertion_decorator 732 | def assert_regex_in_headers(self, member, msg=None): 733 | assert_regexp(member, headers_as_text(self.headers), msg=msg) 734 | return self 735 | 736 | @recorder.assertion_decorator 737 | def assert_regex_not_in_headers(self, member, msg=None): 738 | assert_not_regexp(member, headers_as_text(self.headers), msg=msg) 739 | return self 740 | 741 | @recorder.assertion_decorator 742 | def assert_jsonpath(self, jsonpath_query, expected_value=None, msg=None): 743 | jsonpath_expr = jsonpath_parse(jsonpath_query) 744 | body = self.json() 745 | matches = jsonpath_expr.find(body) 746 | if not matches: 747 | msg = msg or "JSONPath query %r didn't match response: %s" % (jsonpath_query, self.text[:BODY_LIMIT]) 748 | raise AssertionError(msg) 749 | actual_value = matches[0].value 750 | if expected_value is not None and actual_value != expected_value: 751 | tmpl = "Actual value at JSONPath query (%r) isn't equal to expected (%r)" 752 | msg = msg or tmpl % (actual_value, expected_value) 753 | raise AssertionError(msg) 754 | return self 755 | 756 | @recorder.assertion_decorator 757 | def assert_not_jsonpath(self, jsonpath_query, msg=None): 758 | jsonpath_expr = jsonpath_parse(jsonpath_query) 759 | body = self.json() 760 | matches = jsonpath_expr.find(body) 761 | if matches: 762 | msg = msg or "JSONPath query %r did match response: %s" % (jsonpath_query, self.text[:BODY_LIMIT]) 763 | raise AssertionError(msg) 764 | return self 765 | 766 | @recorder.assertion_decorator 767 | def assert_xpath(self, xpath_query, parser_type='html', validate=False, msg=None): 768 | parser = etree.HTMLParser() if parser_type == 'html' else etree.XMLParser(dtd_validation=validate) 769 | tree = etree.parse(BytesIO(self.content), parser) 770 | matches = tree.xpath(xpath_query) 771 | if not matches: 772 | msg = msg or "XPath query %r didn't match response content: %s" % (xpath_query, self.text[:BODY_LIMIT]) 773 | raise AssertionError(msg) 774 | return self 775 | 776 | @recorder.assertion_decorator 777 | def assert_not_xpath(self, xpath_query, parser_type='html', validate=False, msg=None): 778 | parser = etree.HTMLParser() if parser_type == 'html' else etree.XMLParser(dtd_validation=validate) 779 | tree = etree.parse(BytesIO(self.content), parser) 780 | matches = tree.xpath(xpath_query) 781 | if matches: 782 | msg = msg or "XPath query %r did match response content: %s" % (xpath_query, self.text[:BODY_LIMIT]) 783 | raise AssertionError(msg) 784 | return self 785 | 786 | @recorder.assertion_decorator 787 | def assert_cssselect(self, query, expected_value=None, attribute=None, msg=None): 788 | tree = html.fromstring(self.text) 789 | q = tree.cssselect(query) 790 | vals = [(x.text if attribute is None else x.attrib[attribute]) for x in q] 791 | 792 | matches = expected_value in vals if expected_value is not None else vals 793 | if not matches: 794 | msg = msg or "CSSSelect query %r didn't match response content: %s" % (query, self.text[:BODY_LIMIT]) 795 | raise AssertionError(msg) 796 | return self 797 | 798 | @recorder.assertion_decorator 799 | def assert_not_cssselect(self, query, expected_value=None, attribute=None, msg=None): 800 | try: 801 | self.assert_cssselect(query, expected_value, attribute) 802 | except AssertionError: 803 | return self 804 | 805 | msg = msg or "CSSSelect query %r did match response content: %s" % (query, self.text[:BODY_LIMIT]) 806 | raise AssertionError(msg) 807 | 808 | # TODO: assertTiming? to assert response time / connection time 809 | 810 | def extract_regex(self, regex, default=None): 811 | extracted_value = default 812 | for item in re.finditer(regex, self.text): 813 | extracted_value = item 814 | break 815 | return extracted_value 816 | 817 | def extract_jsonpath(self, jsonpath_query, default=None): 818 | jsonpath_expr = jsonpath_parse(jsonpath_query) 819 | body = self.json() 820 | matches = jsonpath_expr.find(body) 821 | if not matches: 822 | return default 823 | return matches[0].value 824 | 825 | def extract_cssselect(self, selector, attribute=None, default=None): 826 | tree = html.fromstring(self.text) 827 | q = tree.cssselect(selector) 828 | matches = [(x.text if attribute is None else x.attrib[attribute]) for x in q] 829 | 830 | if not matches: 831 | return default 832 | return matches[0] 833 | 834 | def extract_xpath(self, xpath_query, default=None, parser_type='html', validate=False): 835 | parser = etree.HTMLParser() if parser_type == 'html' else etree.XMLParser(dtd_validation=validate) 836 | tree = etree.parse(BytesIO(self.content), parser) 837 | matches = tree.xpath(xpath_query) 838 | if not matches: 839 | return default 840 | match = matches[0] 841 | return match.text 842 | --------------------------------------------------------------------------------