0
# Testing Utilities
1
2
Complete test client and utilities for testing WSGI applications including request simulation, cookie handling, redirect following, and response validation. These tools provide everything needed to thoroughly test web applications without running a server.
3
4
## Capabilities
5
6
### Test Client
7
8
The Client class provides a high-level interface for making HTTP requests to WSGI applications in test environments.
9
10
```python { .api }
11
class Client:
12
def __init__(self, application, response_wrapper=None, use_cookies=True, allow_subdomain_redirects=False):
13
"""
14
Create a test client for a WSGI application.
15
16
Parameters:
17
- application: WSGI application to test
18
- response_wrapper: Response class to wrap results (defaults to TestResponse)
19
- use_cookies: Whether to persist cookies between requests
20
- allow_subdomain_redirects: Allow following redirects to subdomains
21
"""
22
23
def open(self, *args, **kwargs):
24
"""
25
Make a request to the application.
26
27
Can be called with:
28
- open(path, method='GET', **kwargs)
29
- open(EnvironBuilder, **kwargs)
30
- open(Request, **kwargs)
31
32
Parameters:
33
- path: URL path to request
34
- method: HTTP method
35
- data: Request body data (string, bytes, dict, or file)
36
- json: JSON data to send (sets Content-Type automatically)
37
- headers: Request headers (dict or Headers object)
38
- query_string: URL parameters (string or dict)
39
- content_type: Content-Type header value
40
- auth: Authorization (username, password) tuple or Authorization object
41
- follow_redirects: Whether to follow HTTP redirects automatically
42
- buffered: Whether to buffer the response
43
- environ_base: Base WSGI environ values
44
- environ_overrides: WSGI environ overrides
45
46
Returns:
47
TestResponse object
48
"""
49
50
def get(self, *args, **kwargs):
51
"""Make a GET request. Same parameters as open() except method='GET'."""
52
53
def post(self, *args, **kwargs):
54
"""Make a POST request. Same parameters as open() except method='POST'."""
55
56
def put(self, *args, **kwargs):
57
"""Make a PUT request. Same parameters as open() except method='PUT'."""
58
59
def delete(self, *args, **kwargs):
60
"""Make a DELETE request. Same parameters as open() except method='DELETE'."""
61
62
def patch(self, *args, **kwargs):
63
"""Make a PATCH request. Same parameters as open() except method='PATCH'."""
64
65
def options(self, *args, **kwargs):
66
"""Make an OPTIONS request. Same parameters as open() except method='OPTIONS'."""
67
68
def head(self, *args, **kwargs):
69
"""Make a HEAD request. Same parameters as open() except method='HEAD'."""
70
71
def trace(self, *args, **kwargs):
72
"""Make a TRACE request. Same parameters as open() except method='TRACE'."""
73
74
# Cookie management
75
def get_cookie(self, key, domain="localhost", path="/"):
76
"""
77
Get a cookie by key, domain, and path.
78
79
Parameters:
80
- key: Cookie name
81
- domain: Cookie domain (default: 'localhost')
82
- path: Cookie path (default: '/')
83
84
Returns:
85
Cookie object or None if not found
86
"""
87
88
def set_cookie(self, key, value="", domain="localhost", origin_only=True, path="/", **kwargs):
89
"""
90
Set a cookie for subsequent requests.
91
92
Parameters:
93
- key: Cookie name
94
- value: Cookie value
95
- domain: Cookie domain
96
- origin_only: Whether domain must match exactly
97
- path: Cookie path
98
- **kwargs: Additional cookie parameters
99
"""
100
101
def delete_cookie(self, key, domain="localhost", path="/"):
102
"""
103
Delete a cookie.
104
105
Parameters:
106
- key: Cookie name
107
- domain: Cookie domain
108
- path: Cookie path
109
"""
110
```
111
112
### Environment Builder
113
114
EnvironBuilder creates WSGI environment dictionaries for testing, providing fine-grained control over request parameters.
115
116
```python { .api }
117
class EnvironBuilder:
118
def __init__(self, path="/", base_url=None, query_string=None, method="GET", input_stream=None, content_type=None, content_length=None, errors_stream=None, multithread=False, multiprocess=True, run_once=False, headers=None, data=None, environ_base=None, environ_overrides=None, mimetype=None, json=None, auth=None):
119
"""
120
Build a WSGI environment for testing.
121
122
Parameters:
123
- path: Request path (PATH_INFO in WSGI)
124
- base_url: Base URL for scheme, host, and script root
125
- query_string: URL parameters (string or dict)
126
- method: HTTP method (GET, POST, etc.)
127
- input_stream: Request body stream
128
- content_type: Content-Type header
129
- content_length: Content-Length header
130
- errors_stream: Error stream for wsgi.errors
131
- multithread: WSGI multithread flag
132
- multiprocess: WSGI multiprocess flag
133
- run_once: WSGI run_once flag
134
- headers: Request headers (list, dict, or Headers)
135
- data: Request body data (string, bytes, dict, or file)
136
- environ_base: Base environ values
137
- environ_overrides: Environ overrides
138
- mimetype: MIME type for data
139
- json: JSON data (sets Content-Type automatically)
140
- auth: Authorization (username, password) or Authorization object
141
"""
142
143
# Properties for accessing parsed data
144
form: MultiDict # Parsed form data
145
files: FileMultiDict # Uploaded files
146
args: MultiDict # Query string parameters
147
148
def get_environ(self):
149
"""
150
Build and return the WSGI environment dictionary.
151
152
Returns:
153
Complete WSGI environ dict
154
"""
155
156
def get_request(self, cls=None):
157
"""
158
Get a Request object from the environment.
159
160
Parameters:
161
- cls: Request class to use (defaults to werkzeug.wrappers.Request)
162
163
Returns:
164
Request object
165
"""
166
```
167
168
### Test Response
169
170
Enhanced response object with additional testing utilities and properties.
171
172
```python { .api }
173
class TestResponse(Response):
174
def __init__(self, response, status, headers, request, history, auto_to_bytes):
175
"""
176
Test-specific response wrapper.
177
178
Parameters:
179
- response: Response iterable
180
- status: HTTP status
181
- headers: Response headers
182
- request: Original request
183
- history: Redirect history
184
- auto_to_bytes: Whether to convert response to bytes
185
"""
186
187
# Additional properties for testing
188
request: Request # The original request
189
history: list[TestResponse] # Redirect history
190
text: str # Response body as text (decoded)
191
192
@property
193
def json(self):
194
"""
195
Parse response body as JSON.
196
197
Returns:
198
Parsed JSON data
199
200
Raises:
201
ValueError: If response is not valid JSON
202
"""
203
```
204
205
### Cookie
206
207
Represents a cookie with domain, path, and other attributes for testing.
208
209
```python { .api }
210
@dataclasses.dataclass
211
class Cookie:
212
key: str # Cookie name
213
value: str # Cookie value
214
domain: str # Cookie domain
215
path: str # Cookie path
216
origin_only: bool # Whether domain must match exactly
217
218
def should_send(self, server_name, path):
219
"""
220
Check if cookie should be sent with a request.
221
222
Parameters:
223
- server_name: Request server name
224
- path: Request path
225
226
Returns:
227
True if cookie should be included
228
"""
229
```
230
231
### Utility Functions
232
233
Helper functions for creating environments and running WSGI applications.
234
235
```python { .api }
236
def create_environ(*args, **kwargs):
237
"""
238
Create a WSGI environ dict. Shortcut for EnvironBuilder(...).get_environ().
239
240
Parameters:
241
Same as EnvironBuilder constructor
242
243
Returns:
244
WSGI environ dictionary
245
"""
246
247
def run_wsgi_app(app, environ, buffered=False):
248
"""
249
Run a WSGI application and capture the response.
250
251
Parameters:
252
- app: WSGI application callable
253
- environ: WSGI environment dictionary
254
- buffered: Whether to buffer the response
255
256
Returns:
257
Tuple of (app_iter, status, headers)
258
"""
259
260
def stream_encode_multipart(data, use_tempfile=True, threshold=1024*500, boundary=None):
261
"""
262
Encode form data as multipart/form-data stream.
263
264
Parameters:
265
- data: Dict of form fields and files
266
- use_tempfile: Use temp file for large data
267
- threshold: Size threshold for temp file
268
- boundary: Multipart boundary string
269
270
Returns:
271
Tuple of (stream, length, content_type)
272
"""
273
274
def encode_multipart(data, boundary=None, charset="utf-8"):
275
"""
276
Encode form data as multipart/form-data bytes.
277
278
Parameters:
279
- data: Dict of form fields and files
280
- boundary: Multipart boundary string
281
- charset: Character encoding
282
283
Returns:
284
Tuple of (data_bytes, content_type)
285
"""
286
```
287
288
### Exceptions
289
290
Testing-specific exceptions.
291
292
```python { .api }
293
class ClientRedirectError(Exception):
294
"""
295
Raised when redirect following fails or creates a loop.
296
"""
297
```
298
299
## Usage Examples
300
301
### Basic Application Testing
302
303
```python
304
from werkzeug.test import Client
305
from werkzeug.wrappers import Request, Response
306
307
# Simple WSGI application
308
def app(environ, start_response):
309
request = Request(environ)
310
311
if request.path == '/':
312
response = Response('Hello World!')
313
elif request.path == '/json':
314
response = Response('{"message": "Hello JSON"}', mimetype='application/json')
315
else:
316
response = Response('Not Found', status=404)
317
318
return response(environ, start_response)
319
320
# Test the application
321
def test_basic_requests():
322
client = Client(app)
323
324
# Test GET request
325
response = client.get('/')
326
assert response.status_code == 200
327
assert response.text == 'Hello World!'
328
329
# Test JSON endpoint
330
response = client.get('/json')
331
assert response.status_code == 200
332
assert response.json == {"message": "Hello JSON"}
333
334
# Test 404
335
response = client.get('/notfound')
336
assert response.status_code == 404
337
assert response.text == 'Not Found'
338
```
339
340
### Form Data and File Uploads
341
342
```python
343
from werkzeug.test import Client, EnvironBuilder
344
from werkzeug.datastructures import FileStorage
345
from io import BytesIO
346
347
def form_app(environ, start_response):
348
request = Request(environ)
349
350
if request.method == 'POST':
351
name = request.form.get('name', 'Anonymous')
352
email = request.form.get('email', '')
353
uploaded_file = request.files.get('avatar')
354
355
response_data = {
356
'name': name,
357
'email': email,
358
'file_uploaded': uploaded_file is not None,
359
'filename': uploaded_file.filename if uploaded_file else None
360
}
361
response = Response(str(response_data))
362
else:
363
response = Response('Send POST with form data')
364
365
return response(environ, start_response)
366
367
def test_form_submission():
368
client = Client(form_app)
369
370
# Test form data
371
response = client.post('/', data={
372
'name': 'John Doe',
373
'email': 'john@example.com'
374
})
375
assert 'John Doe' in response.text
376
assert 'john@example.com' in response.text
377
378
# Test file upload
379
response = client.post('/', data={
380
'name': 'Jane',
381
'avatar': (BytesIO(b'fake image data'), 'avatar.png')
382
})
383
assert 'file_uploaded": True' in response.text
384
assert 'avatar.png' in response.text
385
386
def test_with_environ_builder():
387
# More control with EnvironBuilder
388
builder = EnvironBuilder(
389
path='/upload',
390
method='POST',
391
data={
392
'description': 'Test file',
393
'file': FileStorage(
394
stream=BytesIO(b'test content'),
395
filename='test.txt',
396
content_type='text/plain'
397
)
398
}
399
)
400
401
environ = builder.get_environ()
402
request = Request(environ)
403
404
assert request.method == 'POST'
405
assert request.form['description'] == 'Test file'
406
assert request.files['file'].filename == 'test.txt'
407
```
408
409
### JSON API Testing
410
411
```python
412
import json
413
from werkzeug.test import Client
414
415
def api_app(environ, start_response):
416
request = Request(environ)
417
418
if request.path == '/api/data' and request.method == 'POST':
419
if request.is_json:
420
data = request.get_json()
421
response_data = {
422
'received': data,
423
'status': 'success'
424
}
425
response = Response(
426
json.dumps(response_data),
427
mimetype='application/json'
428
)
429
else:
430
response = Response(
431
'{"error": "Content-Type must be application/json"}',
432
status=400,
433
mimetype='application/json'
434
)
435
else:
436
response = Response('{"error": "Not found"}', status=404)
437
438
return response(environ, start_response)
439
440
def test_json_api():
441
client = Client(api_app)
442
443
# Test JSON request
444
test_data = {'name': 'Test', 'value': 123}
445
response = client.post(
446
'/api/data',
447
json=test_data # Automatically sets Content-Type
448
)
449
450
assert response.status_code == 200
451
assert response.json['status'] == 'success'
452
assert response.json['received'] == test_data
453
454
# Test non-JSON request
455
response = client.post(
456
'/api/data',
457
data='not json',
458
content_type='text/plain'
459
)
460
461
assert response.status_code == 400
462
assert 'Content-Type must be application/json' in response.json['error']
463
```
464
465
### Cookie Testing
466
467
```python
468
def cookie_app(environ, start_response):
469
request = Request(environ)
470
471
if request.path == '/set-cookie':
472
response = Response('Cookie set')
473
response.set_cookie('user_id', '12345', max_age=3600)
474
response.set_cookie('theme', 'dark', path='/settings')
475
elif request.path == '/check-cookie':
476
user_id = request.cookies.get('user_id')
477
theme = request.cookies.get('theme')
478
response = Response(f'User ID: {user_id}, Theme: {theme}')
479
else:
480
response = Response('Hello')
481
482
return response(environ, start_response)
483
484
def test_cookies():
485
client = Client(cookie_app, use_cookies=True)
486
487
# Set cookies
488
response = client.get('/set-cookie')
489
assert response.status_code == 200
490
491
# Cookies should be sent automatically
492
response = client.get('/check-cookie')
493
assert 'User ID: 12345' in response.text
494
495
# Manual cookie management
496
client.set_cookie('custom', 'value', domain='localhost')
497
cookie = client.get_cookie('custom', domain='localhost')
498
assert cookie.value == 'value'
499
500
# Check theme cookie with specific path
501
client.set_cookie('theme', 'light', path='/settings')
502
response = client.get('/settings/check-cookie')
503
# Theme cookie should be sent because path matches
504
```
505
506
### Authentication Testing
507
508
```python
509
from werkzeug.datastructures import Authorization
510
511
def auth_app(environ, start_response):
512
request = Request(environ)
513
514
if request.authorization:
515
if (request.authorization.username == 'admin' and
516
request.authorization.password == 'secret'):
517
response = Response(f'Welcome {request.authorization.username}!')
518
else:
519
response = Response('Invalid credentials', status=401)
520
else:
521
response = Response('Authentication required', status=401)
522
response.www_authenticate.set_basic('Test Realm')
523
524
return response(environ, start_response)
525
526
def test_authentication():
527
client = Client(auth_app)
528
529
# Test without auth
530
response = client.get('/protected')
531
assert response.status_code == 401
532
assert 'Authentication required' in response.text
533
534
# Test with basic auth (tuple shortcut)
535
response = client.get('/protected', auth=('admin', 'secret'))
536
assert response.status_code == 200
537
assert 'Welcome admin!' in response.text
538
539
# Test with Authorization object
540
auth = Authorization('basic', {'username': 'admin', 'password': 'secret'})
541
response = client.get('/protected', auth=auth)
542
assert response.status_code == 200
543
544
# Test invalid credentials
545
response = client.get('/protected', auth=('admin', 'wrong'))
546
assert response.status_code == 401
547
```
548
549
### Redirect Following
550
551
```python
552
def redirect_app(environ, start_response):
553
request = Request(environ)
554
555
if request.path == '/redirect':
556
response = Response('Redirecting...', status=302)
557
response.location = '/target'
558
elif request.path == '/target':
559
response = Response('You made it!')
560
else:
561
response = Response('Not found', status=404)
562
563
return response(environ, start_response)
564
565
def test_redirects():
566
client = Client(redirect_app)
567
568
# Don't follow redirects
569
response = client.get('/redirect')
570
assert response.status_code == 302
571
assert response.location == '/target'
572
573
# Follow redirects automatically
574
response = client.get('/redirect', follow_redirects=True)
575
assert response.status_code == 200
576
assert response.text == 'You made it!'
577
578
# Check redirect history
579
assert len(response.history) == 1
580
assert response.history[0].status_code == 302
581
```
582
583
### Custom Headers and Advanced Options
584
585
```python
586
def header_app(environ, start_response):
587
request = Request(environ)
588
589
user_agent = request.headers.get('User-Agent', 'Unknown')
590
custom_header = request.headers.get('X-Custom-Header', 'None')
591
592
response = Response(f'UA: {user_agent}, Custom: {custom_header}')
593
response.headers['X-Response-ID'] = '12345'
594
595
return response(environ, start_response)
596
597
def test_custom_headers():
598
client = Client(header_app)
599
600
response = client.get('/', headers={
601
'User-Agent': 'TestBot/1.0',
602
'X-Custom-Header': 'test-value',
603
'Accept': 'application/json'
604
})
605
606
assert 'UA: TestBot/1.0' in response.text
607
assert 'Custom: test-value' in response.text
608
assert response.headers['X-Response-ID'] == '12345'
609
610
def test_environ_customization():
611
# Custom WSGI environ values
612
response = client.get('/', environ_base={
613
'REMOTE_ADDR': '192.168.1.100',
614
'HTTP_HOST': 'testserver.com'
615
})
616
617
# Test with EnvironBuilder for more control
618
builder = EnvironBuilder(
619
path='/api/test',
620
method='PUT',
621
headers={'Authorization': 'Bearer token123'},
622
data='{"update": true}',
623
content_type='application/json'
624
)
625
626
response = client.open(builder)
627
```
628
629
### Error Handling and Edge Cases
630
631
```python
632
def test_error_handling():
633
client = Client(app)
634
635
# Test malformed requests
636
try:
637
# This should handle gracefully
638
response = client.post('/', data=b'\xff\xfe\invalid')
639
except Exception as e:
640
# Check specific error types
641
pass
642
643
# Test large uploads
644
large_data = b'x' * (1024 * 1024) # 1MB
645
response = client.post('/', data={'file': (BytesIO(large_data), 'big.txt')})
646
647
# Test cookies without cookie support
648
no_cookie_client = Client(app, use_cookies=False)
649
try:
650
no_cookie_client.get_cookie('test') # Should raise TypeError
651
except TypeError as e:
652
assert 'Cookies are disabled' in str(e)
653
```