├── requirements.txt ├── locustfile.py ├── .gitignore ├── plotter.py └── readme.md /requirements.txt: -------------------------------------------------------------------------------- 1 | locustio==0.7.3 2 | bokeh==0.12.4 3 | -------------------------------------------------------------------------------- /locustfile.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from locust import HttpLocust, TaskSet, task 5 | 6 | 7 | class UserBehavior(TaskSet): 8 | 9 | @task(1) 10 | def index(self): 11 | self.client.get('/') 12 | 13 | 14 | class WebsiteUser(HttpLocust): 15 | task_set = UserBehavior 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # IPython Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # dotenv 81 | .env 82 | 83 | # virtualenv 84 | venv/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | .idea 94 | -------------------------------------------------------------------------------- /plotter.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import six 3 | 4 | from bokeh.client import push_session 5 | from bokeh.layouts import gridplot 6 | from bokeh.plotting import figure, curdoc 7 | 8 | # http://bokeh.pydata.org/en/latest/docs/user_guide/server.html#connecting-with-bokeh-client 9 | 10 | # here we'll keep configurations 11 | config = {'figures': [{'charts': 12 | [{'color': 'black', 'legend': 'average response time', 'marker': 'diamond', 13 | 'key': 'avg_response_time'}, 14 | {'color': 'blue', 'legend': 'median response time', 'marker': 'triangle', 15 | 'key': 'median_response_time'}, 16 | {'color': 'green', 'legend': 'min response time', 'marker': 'inverted_triangle', 17 | 'key': 'min_response_time'}, 18 | {'color': 'red', 'legend': 'max response time', 'marker': 'circle', 19 | 'key': 'max_response_time'}], 20 | 'xlabel': 'Requests count', 21 | 'ylabel': 'Milliseconds', 22 | 'title': '{} response times' 23 | }, 24 | {'charts': [{'color': 'green', 'legend': 'current rps', 'marker': 'circle', 25 | 'key': 'current_rps'}, 26 | {'color': 'red', 'legend': 'failures', 'marker': 'cross', 27 | 'key': 'num_failures', 'skip_null': True}], 28 | 'xlabel': 'Requests count', 29 | 'ylabel': 'RPS/Failures count', 30 | 'title': '{} RPS/Failures' 31 | }], 32 | 'url': 'http://localhost:8089/stats/requests', # locust json stats url 33 | 'states': ['hatching', 'running'], # locust states for which we'll plot the graphs 34 | 'requests_key': 'num_requests' 35 | } 36 | 37 | data_sources = {} # dict with data sources for our figures 38 | figures = [] # list of figures for each state 39 | for state in config['states']: 40 | data_sources[state] = {} # dict with data sources for figures for each state 41 | for figure_data in config['figures']: 42 | # initialization of figure 43 | new_figure = figure(title=figure_data['title'].format(state.capitalize())) 44 | new_figure.xaxis.axis_label = figure_data['xlabel'] 45 | new_figure.yaxis.axis_label = figure_data['ylabel'] 46 | # adding charts to figure 47 | for chart in figure_data['charts']: 48 | # adding both markers and line for chart 49 | marker = getattr(new_figure, chart['marker']) 50 | scatter = marker(x=[0], y=[0], color=chart['color'], size=10, legend=chart['legend']) 51 | line = new_figure.line(x=[0], y=[0], color=chart['color'], line_width=1, legend=chart['legend']) 52 | # adding data source for markers and line 53 | data_sources[state][chart['key']] = scatter.data_source = line.data_source 54 | figures.append(new_figure) 55 | 56 | requests_key = config['requests_key'] 57 | url = config['url'] 58 | 59 | 60 | # Next line opens a new session with the Bokeh Server, initializing it with our current Document. 61 | # This local Document will be automatically kept in sync with the server. 62 | session = push_session(curdoc()) 63 | 64 | 65 | # The next few lines define and add a periodic callback to be run every 1 second: 66 | def update(): 67 | try: 68 | resp = requests.get(url) 69 | except requests.RequestException: 70 | return 71 | resp_data = resp.json() 72 | data = resp_data['stats'][-1] # Getting "Total" data from locust 73 | if resp_data['state'] in config['states']: 74 | for key, data_source in six.iteritems(data_sources[resp_data['state']]): 75 | # adding data from locust to data_source of our graphs 76 | data_source.data['x'].append(data[requests_key]) 77 | data_source.data['y'].append(data[key]) 78 | # trigger data source changes 79 | data_source.trigger('data', data_source.data, data_source.data) 80 | 81 | curdoc().add_periodic_callback(update, 1000) 82 | 83 | session.show(gridplot(figures, ncols=2)) # open browser with gridplot containing 2 figures in row for each state 84 | 85 | session.loop_until_closed() # run forever 86 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Load testing with Locust and Bokeh 2 | 3 | Scalability testing is important part of getting web service production ready. 4 | There are a lot of tools for load testing like Gatling, Apache JMeter, The Grinder, Tsung and others. Also there is one (and my favorite) written in Python and built on the [Requests](http://docs.python-requests.org/en/master/) library: [Locust](http://locust.io/). 5 | 6 | As noticed on Locust website: 7 | > A fundamental feature of Locust is that you describe all your test in Python code. No need for clunky UIs or bloated XML, just plain code. 8 | 9 | ## Locust installation 10 | Locust load testing library requires **Python 2.6+**. It is not currently compatible with Python 3.x. 11 | Performance testing python module Locust is available on PyPI and can be installed through pip or easy_install 12 | 13 | `pip install locustio` or: `easy_install locustio` 14 | 15 | ## Example locustfile.py 16 | Then create *locustfile.py* following [example](http://docs.locust.io/en/latest/quickstart.html#example-locustfile-py) from docs. To test Django project I had to add some headers for csrftoken support and ajax requests. Resulting *locustfile.py* could be something like following: 17 | 18 | ```python 19 | # locustfile.py 20 | from locust import HttpLocust, TaskSet, task 21 | 22 | 23 | class UserBehavior(TaskSet): 24 | 25 | def on_start(self): 26 | self.login() 27 | 28 | def login(self): 29 | # GET login page to get csrftoken from it 30 | response = self.client.get('/accounts/login/') 31 | csrftoken = response.cookies['csrftoken'] 32 | # POST to login page with csrftoken 33 | self.client.post('/accounts/login/', 34 | {'username': 'username', 'password': 'P455w0rd'}, 35 | headers={'X-CSRFToken': csrftoken}) 36 | 37 | @task(1) 38 | def index(self): 39 | self.client.get('/') 40 | 41 | @task(2) 42 | def heavy_url(self): 43 | self.client.get('/heavy_url/') 44 | 45 | @task(2) 46 | def another_heavy_ajax_url(self): 47 | # ajax GET 48 | self.client.get('/another_heavy_ajax_url/', 49 | headers={'X-Requested-With': 'XMLHttpRequest'}) 50 | 51 | 52 | class WebsiteUser(HttpLocust): 53 | task_set = UserBehavior 54 | ``` 55 | 56 | ## Start Locust 57 | 58 | To run Locust with the above python locust file, if it was named *locustfile.py*, we could run (in the same directory as *locustfile.py*): 59 | 60 | `locust --host=http://example.com` 61 | 62 | When python load testing app Locust is started you should visit [http://127.0.0.1:8089/](http://127.0.0.1:8089/) and there you'll find web-interface of our Locust instance. Then input **Number of users to simulate** (e.g. 300) and **Hatch rate (users spawned/second)** (e.g. 10) and press **Start swarming**. After that Locust will start "hatching" users and you can see results in table. 63 | 64 | ## Python Data Visualization 65 | So table is nice but we'd prefer to see results on graph. There is an [issue](https://github.com/locustio/locust/issues/144) in which people ask to add graphical interface to Locust and there are several propositions how to display graphs for Locust data. I've decided to use Python interactive visualization library [Bokeh](http://bokeh.pydata.org/en/latest/). 66 | 67 | It is easy to install python graphing library Bokeh from PyPI using pip: 68 | 69 | `pip install bokeh` 70 | 71 | Here is an [example](http://bokeh.pydata.org/en/latest/docs/user_guide/server.html#connecting-with-bokeh-client) of running Bokeh server. 72 | 73 | We can get Locust data in JSON format visiting [http://localhost:8089/stats/requests]. Data there should be something like: 74 | ```json 75 | { 76 | "errors": [], 77 | "stats": [ 78 | { 79 | "median_response_time": 350, 80 | "min_response_time": 311, 81 | "current_rps": 0.0, 82 | "name": "/", 83 | "num_failures": 0, 84 | "max_response_time": 806, 85 | "avg_content_length": 17611, 86 | "avg_response_time": 488.3333333333333, 87 | "method": "GET", 88 | "num_requests": 9 89 | }, 90 | { 91 | "median_response_time": 350, 92 | "min_response_time": 311, 93 | "current_rps": 0.0, 94 | "name": "Total", 95 | "num_failures": 0, 96 | "max_response_time": 806, 97 | "avg_content_length": 17611, 98 | "avg_response_time": 488.3333333333333, 99 | "method": null, 100 | "num_requests": 9 101 | } 102 | ], 103 | "fail_ratio": 0.0, 104 | "slave_count": 2, 105 | "state": "stopped", 106 | "user_count": 0, 107 | "total_rps": 0.0 108 | } 109 | ``` 110 | 111 | To display this data on interactive plots we'll create *plotter.py* file, built on usage of python visualization library Bokeh, and put it to the directory in which our *locustfile.py* is: 112 | 113 | ```python 114 | # plotter.py 115 | import requests 116 | import six 117 | 118 | from bokeh.client import push_session 119 | from bokeh.layouts import gridplot 120 | from bokeh.plotting import figure, curdoc 121 | 122 | # http://bokeh.pydata.org/en/latest/docs/user_guide/server.html#connecting-with-bokeh-client 123 | 124 | # here we'll keep configurations 125 | config = {'figures': [{'charts': 126 | [{'color': 'black', 'legend': 'average response time', 'marker': 'diamond', 127 | 'key': 'avg_response_time'}, 128 | {'color': 'blue', 'legend': 'median response time', 'marker': 'triangle', 129 | 'key': 'median_response_time'}, 130 | {'color': 'green', 'legend': 'min response time', 'marker': 'inverted_triangle', 131 | 'key': 'min_response_time'}, 132 | {'color': 'red', 'legend': 'max response time', 'marker': 'circle', 133 | 'key': 'max_response_time'}], 134 | 'xlabel': 'Requests count', 135 | 'ylabel': 'Milliseconds', 136 | 'title': '{} response times' 137 | }, 138 | {'charts': [{'color': 'green', 'legend': 'current rps', 'marker': 'circle', 139 | 'key': 'current_rps'}, 140 | {'color': 'red', 'legend': 'failures', 'marker': 'cross', 141 | 'key': 'num_failures', 'skip_null': True}], 142 | 'xlabel': 'Requests count', 143 | 'ylabel': 'RPS/Failures count', 144 | 'title': '{} RPS/Failures' 145 | }], 146 | 'url': 'http://localhost:8089/stats/requests', # locust json stats url 147 | 'states': ['hatching', 'running'], # locust states for which we'll plot the graphs 148 | 'requests_key': 'num_requests' 149 | } 150 | 151 | data_sources = {} # dict with data sources for our figures 152 | figures = [] # list of figures for each state 153 | for state in config['states']: 154 | data_sources[state] = {} # dict with data sources for figures for each state 155 | for figure_data in config['figures']: 156 | # initialization of figure 157 | new_figure = figure(title=figure_data['title'].format(state.capitalize())) 158 | new_figure.xaxis.axis_label = figure_data['xlabel'] 159 | new_figure.yaxis.axis_label = figure_data['ylabel'] 160 | # adding charts to figure 161 | for chart in figure_data['charts']: 162 | # adding both markers and line for chart 163 | marker = getattr(new_figure, chart['marker']) 164 | scatter = marker(x=[0], y=[0], color=chart['color'], size=10, legend=chart['legend']) 165 | line = new_figure.line(x=[0], y=[0], color=chart['color'], line_width=1, legend=chart['legend']) 166 | # adding data source for markers and line 167 | data_sources[state][chart['key']] = scatter.data_source = line.data_source 168 | figures.append(new_figure) 169 | 170 | requests_key = config['requests_key'] 171 | url = config['url'] 172 | 173 | 174 | # Next line opens a new session with the Bokeh Server, initializing it with our current Document. 175 | # This local Document will be automatically kept in sync with the server. 176 | session = push_session(curdoc()) 177 | 178 | 179 | # The next few lines define and add a periodic callback to be run every 1 second: 180 | def update(): 181 | try: 182 | resp = requests.get(url) 183 | except requests.RequestException: 184 | return 185 | resp_data = resp.json() 186 | data = resp_data['stats'][-1] # Getting "Total" data from locust 187 | if resp_data['state'] in config['states']: 188 | for key, data_source in six.iteritems(data_sources[resp_data['state']]): 189 | # adding data from locust to data_source of our graphs 190 | data_source.data['x'].append(data[requests_key]) 191 | data_source.data['y'].append(data[key]) 192 | # trigger data source changes 193 | data_source.trigger('data', data_source.data, data_source.data) 194 | 195 | curdoc().add_periodic_callback(update, 1000) 196 | 197 | session.show(gridplot(figures, ncols=2)) # open browser with gridplot containing 2 figures in row for each state 198 | 199 | session.loop_until_closed() # run forever 200 | 201 | ``` 202 | 203 | ## Running all together 204 | 205 | So our Locust is running (if no, start it with `locust --host=http://example.com`) and now we should start Bokeh server with `bokeh serve` and then run our *plotter.py* with `python plotter.py`. As our script calls **show** a browser tab is automatically opened up to the correct URL to view the document. 206 | 207 | If Locust is already running the test you'll see results on graphs immediately. If no start new test at [http://localhost:8089/]() and return to the Bokeh tab and watch the results of testing in real time. 208 | 209 | That's it. You can find all code at [https://github.com/steelkiwi/locust-bokeh-load-test](https://github.com/steelkiwi/locust-bokeh-load-test). 210 | 211 | Feel free to clone it and run example. Don't forget to use **Python 2.6+** (Locust is not Python 3.x compatible for now): 212 | ``` 213 | git clone https://github.com/steelkiwi/locust-bokeh-load-test.git 214 | cd locust-bokeh-load-test 215 | pip install -r requirements.txt 216 | locust --host= 217 | bokeh serve 218 | python plotter.py 219 | ``` 220 | 221 | You should have Bokeh tab opened in browser after running these commands. Now visit [http://localhost:8089/] and start test there. Return to Bokeh tab and enjoy the graphs. 222 | --------------------------------------------------------------------------------