aboutsummaryrefslogtreecommitdiffstats
path: root/README.md
blob: 0a4f6dd840c79156d6d1728a5c185b4ebaa6b163 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# `load-pw`

a simple "load testing" *not-really-a-framework* framework built on top of [Python-Playwright](https://playwright.dev/python/).

`config.py` is straight-forward and has everything.

it's also designed to make it easy to add your own "scraping" or page navigation logic during the load test — copy `scenarios/example.py`, name the new file something relevant, go ahead and subclass `BaseScenario`, override the `run()` method, and you're good to go.


## deps

- **python 3.14+** (probably works with older versions too, but I haven't tested it)
- **playwright** for Python

## install

```bash
pip install -r requirements.txt
playwright install
```

> `playwright install` downloads the browser binaries (chromium, firefox, webkit)

## usage

### 1. configure your target

edit `config.py` and set `BASE_URL` to whatever you're testing:

```python
BASE_URL = "http://localhost:8080"
```

there are a bunch of other settings in there too — concurrent users, iterations, think time, browser type, timeouts, logging, etc. it has comments and the settings are mostly self-explanatory.

### 2. run the load test

```bash
python3 loadtest.py
```

that's it. 

it'll use the example.py scenario by default, which just loads the `BASE_URL` and closes per user.

### 3. CLI overrides

you can override config values from the command line if you don't want to edit `config.py` every time (I don't use this so I can't guarantee it works perfectly, but it should be good enough for basic use cases):

```bash
# hit a different URL with 10 users, 5 iterations each
python3 loadtest.py --url http://myapp:3000 --users 10 --iterations 5

# run headed (with browser UI)
python3 loadtest.py --headed

# use a custom scenario
python3 loadtest.py --scenario scenarios/my_scenario.py
```

```
usage: loadtest.py [-h] [--scenario SCENARIO] [--url URL]
                   [--users USERS] [--iterations ITERATIONS]
                   [--headed]
```

## writing your own scenario

"scenarios" are just Python classes that inherit from `BaseScenario`. the only thing you *need* to implement is the `run(page)` method — `page` is a full [Playwright Page](https://playwright.dev/python/docs/api/class-page) object.

### minimal scenario

```python
from scenarios.base import BaseScenario

class MyScenario(BaseScenario):
    name = "my-scenario"

    def run(self, page):
        page.goto("/")
        page.wait_for_load_state("networkidle")
        print(f"title: {page.title()}")
```

save that as `scenarios/my_scenario.py` and run:

```bash
python3 loadtest.py --scenario scenarios/my_scenario.py
```

### scenario with some scraping & navigation

```python
import logging
from scenarios.base import BaseScenario

logger = logging.getLogger("loadtest.scenario.scraper")

class ScraperScenario(BaseScenario):
    name = "scraper"

    def run(self, page):
        # hit the homepage
        page.goto("/")
        page.wait_for_load_state("networkidle")

        # scrape all the links
        links = page.query_selector_all("a")
        logger.info(f"found {len(links)} links")

        for link in links[:3]:
            href = link.get_attribute("href")
            text = link.text_content()
            logger.info(f"  link: {text!r} -> {href}")

        # click into a page
        page.click("a[href='/about']")
        page.wait_for_load_state("networkidle")
        logger.info(f"navigated to: {page.url}")

        # grab some content
        body = page.text_content("body")
        logger.info(f"page body length: {len(body)} chars")

    def on_response(self, response):
        if response.status >= 400:
            logger.warning(f"HTTP {response.status} <- {response.url}")
```

### available hooks

| method | when it's called |
|---|---|
| `setup(page)` | before each iteration |
| `run(page)` | the main scenario logic (required) |
| `teardown(page)` | after each iteration |
| `on_request(request)` | on every outgoing HTTP request |
| `on_response(response)` | on every incoming HTTP response |

## results

after a run, you'll get:

1. **verbose stdout logging** — everything that happened, when, and how long it took
2. **a log file** — same stuff, but in a file
3. **a results file** — structured results with timing stats and per-user breakdowns

## license

distributed under the 0BSD license. 

see `LICENSE` for more information.