0
# Testing Support
1
2
Built-in testing framework with CLI runner, result objects, and utilities for testing CLI applications in isolation with controlled input/output.
3
4
## Capabilities
5
6
### CLI Runner
7
8
Primary testing utility for invoking CLI commands in isolated environments.
9
10
```python { .api }
11
class CliRunner:
12
charset: str
13
env: Mapping[str, str]
14
echo_stdin: bool
15
mix_stderr: bool
16
17
def __init__(
18
self,
19
charset: str | None = None,
20
env: Mapping[str, str] | None = None,
21
echo_stdin: bool = False,
22
mix_stderr: bool = True,
23
) -> None:
24
"""
25
CLI test runner for isolated command execution.
26
27
Parameters:
28
- charset: Character encoding for input/output
29
- env: Environment variables for test execution
30
- echo_stdin: Echo stdin to stdout for debugging
31
- mix_stderr: Mix stderr into stdout in results
32
33
Usage:
34
runner = click.testing.CliRunner()
35
result = runner.invoke(my_command, ['--help'])
36
"""
37
38
def invoke(
39
self,
40
cli: BaseCommand,
41
args: str | Iterable[str] | None = None,
42
input: bytes | str | IO[Any] | None = None,
43
env: Mapping[str, str] | None = None,
44
catch_exceptions: bool = True,
45
color: bool = False,
46
**extra: Any,
47
) -> Result:
48
"""
49
Invoke a CLI command and return results.
50
51
Parameters:
52
- cli: Command or group to invoke
53
- args: Command arguments (list or string)
54
- input: Input data for stdin
55
- env: Environment variables (merged with runner env)
56
- catch_exceptions: Catch and store exceptions instead of raising
57
- color: Enable colored output
58
- **extra: Additional keyword arguments
59
60
Returns:
61
Result object with execution details
62
63
Usage:
64
# Basic command invocation
65
result = runner.invoke(hello_command)
66
67
# With arguments
68
result = runner.invoke(hello_command, ['--name', 'World'])
69
70
# With input
71
result = runner.invoke(interactive_command, input='y\\n')
72
73
# With environment
74
result = runner.invoke(env_command, env={'DEBUG': '1'})
75
"""
76
77
def isolated_filesystem(self) -> ContextManager[str]:
78
"""
79
Create isolated temporary filesystem for testing.
80
81
Returns:
82
Context manager yielding temporary directory path
83
84
Usage:
85
with runner.isolated_filesystem():
86
# Create test files
87
with open('test.txt', 'w') as f:
88
f.write('test content')
89
90
# Run command that operates on files
91
result = runner.invoke(process_command, ['test.txt'])
92
assert result.exit_code == 0
93
"""
94
95
def get_default_prog_name(self, cli: BaseCommand) -> str:
96
"""Get default program name for command."""
97
98
def make_env(self, overrides: Mapping[str, str] | None = None) -> dict[str, str]:
99
"""Create environment dict with overrides."""
100
```
101
102
### Test Result
103
104
Object containing the results of CLI command execution.
105
106
```python { .api }
107
class Result:
108
runner: CliRunner
109
exit_code: int
110
exception: Any
111
exc_info: Any | None
112
stdout_bytes: bytes
113
stderr_bytes: bytes
114
115
def __init__(
116
self,
117
runner: CliRunner,
118
stdout_bytes: bytes,
119
stderr_bytes: bytes,
120
exit_code: int,
121
exception: Any,
122
exc_info: Any | None = None,
123
) -> None:
124
"""
125
Result of CLI command execution.
126
127
Attributes:
128
- runner: CliRunner that produced this result
129
- exit_code: Command exit code
130
- exception: Exception that occurred (if any)
131
- exc_info: Exception info tuple
132
- stdout_bytes: Raw stdout bytes
133
- stderr_bytes: Raw stderr bytes
134
"""
135
136
@property
137
def output(self) -> str:
138
"""
139
Combined stdout output as string.
140
141
Returns:
142
Decoded stdout (and stderr if mixed)
143
"""
144
145
@property
146
def stdout(self) -> str:
147
"""
148
Stdout output as string.
149
150
Returns:
151
Decoded stdout
152
"""
153
154
@property
155
def stderr(self) -> str:
156
"""
157
Stderr output as string.
158
159
Returns:
160
Decoded stderr
161
"""
162
```
163
164
### Input Stream Utilities
165
166
Utilities for creating test input streams.
167
168
```python { .api }
169
def make_input_stream(input: bytes | str | IO[Any] | None, charset: str) -> BinaryIO:
170
"""
171
Create input stream for testing.
172
173
Parameters:
174
- input: Input data
175
- charset: Character encoding
176
177
Returns:
178
Binary input stream
179
180
Usage:
181
# Usually used internally by CliRunner
182
stream = make_input_stream('test input\\n', 'utf-8')
183
"""
184
185
class EchoingStdin:
186
"""
187
Stdin wrapper that echoes input to output for debugging.
188
189
Used internally when CliRunner.echo_stdin is True.
190
"""
191
192
def __init__(self, input: BinaryIO, output: BinaryIO) -> None: ...
193
def read(self, n: int = ...) -> bytes: ...
194
def readline(self, n: int = ...) -> bytes: ...
195
def readlines(self) -> list[bytes]: ...
196
```
197
198
### Testing Patterns
199
200
**Basic Command Testing:**
201
202
```python
203
import click
204
from click.testing import CliRunner
205
206
@click.command()
207
@click.option('--count', default=1, help='Number of greetings')
208
@click.argument('name')
209
def hello(count, name):
210
"""Simple greeting command."""
211
for _ in range(count):
212
click.echo(f'Hello {name}!')
213
214
def test_hello_command():
215
runner = CliRunner()
216
217
# Test basic functionality
218
result = runner.invoke(hello, ['World'])
219
assert result.exit_code == 0
220
assert 'Hello World!' in result.output
221
222
# Test with options
223
result = runner.invoke(hello, ['--count', '3', 'Alice'])
224
assert result.exit_code == 0
225
assert result.output.count('Hello Alice!') == 3
226
227
# Test help
228
result = runner.invoke(hello, ['--help'])
229
assert result.exit_code == 0
230
assert 'Simple greeting command' in result.output
231
```
232
233
**Testing Interactive Commands:**
234
235
```python
236
@click.command()
237
def interactive_setup():
238
"""Interactive setup command."""
239
name = click.prompt('Your name')
240
age = click.prompt('Your age', type=int)
241
if click.confirm('Save configuration?'):
242
click.echo(f'Saved config for {name}, age {age}')
243
else:
244
click.echo('Configuration not saved')
245
246
def test_interactive_setup():
247
runner = CliRunner()
248
249
# Test with confirmations
250
result = runner.invoke(interactive_setup, input='John\\n25\\ny\\n')
251
assert result.exit_code == 0
252
assert 'Saved config for John, age 25' in result.output
253
254
# Test with rejection
255
result = runner.invoke(interactive_setup, input='Jane\\n30\\nn\\n')
256
assert result.exit_code == 0
257
assert 'Configuration not saved' in result.output
258
```
259
260
**Testing File Operations:**
261
262
```python
263
@click.command()
264
@click.argument('filename', type=click.Path(exists=True))
265
def process_file(filename):
266
"""Process a file."""
267
with open(filename, 'r') as f:
268
content = f.read()
269
270
lines = len(content.splitlines())
271
click.echo(f'File has {lines} lines')
272
273
def test_process_file():
274
runner = CliRunner()
275
276
with runner.isolated_filesystem():
277
# Create test file
278
with open('test.txt', 'w') as f:
279
f.write('line 1\\nline 2\\nline 3\\n')
280
281
# Test command
282
result = runner.invoke(process_file, ['test.txt'])
283
assert result.exit_code == 0
284
assert 'File has 3 lines' in result.output
285
286
# Test missing file
287
result = runner.invoke(process_file, ['missing.txt'])
288
assert result.exit_code != 0
289
```
290
291
**Testing Environment Variables:**
292
293
```python
294
@click.command()
295
def env_command():
296
"""Command that uses environment variables."""
297
debug = os.environ.get('DEBUG', 'false').lower() == 'true'
298
if debug:
299
click.echo('Debug mode enabled')
300
else:
301
click.echo('Debug mode disabled')
302
303
def test_env_command():
304
runner = CliRunner()
305
306
# Test without environment
307
result = runner.invoke(env_command)
308
assert 'Debug mode disabled' in result.output
309
310
# Test with environment
311
result = runner.invoke(env_command, env={'DEBUG': 'true'})
312
assert 'Debug mode enabled' in result.output
313
```
314
315
**Testing Exception Handling:**
316
317
```python
318
@click.command()
319
@click.argument('number', type=int)
320
def divide_by_number(number):
321
"""Divide 100 by the given number."""
322
if number == 0:
323
raise click.BadParameter('Cannot divide by zero')
324
325
result = 100 / number
326
click.echo(f'100 / {number} = {result}')
327
328
def test_divide_by_number():
329
runner = CliRunner()
330
331
# Test normal operation
332
result = runner.invoke(divide_by_number, ['5'])
333
assert result.exit_code == 0
334
assert '100 / 5 = 20.0' in result.output
335
336
# Test error handling
337
result = runner.invoke(divide_by_number, ['0'])
338
assert result.exit_code != 0
339
assert 'Cannot divide by zero' in result.output
340
341
# Test with catch_exceptions=False to see actual exception
342
with pytest.raises(click.BadParameter):
343
runner.invoke(divide_by_number, ['0'], catch_exceptions=False)
344
```
345
346
**Testing Groups and Subcommands:**
347
348
```python
349
@click.group()
350
def cli():
351
"""Main CLI group."""
352
pass
353
354
@cli.command()
355
def status():
356
"""Show status."""
357
click.echo('Status: OK')
358
359
@cli.command()
360
@click.argument('name')
361
def greet(name):
362
"""Greet someone."""
363
click.echo(f'Hello {name}!')
364
365
def test_cli_group():
366
runner = CliRunner()
367
368
# Test group help
369
result = runner.invoke(cli, ['--help'])
370
assert result.exit_code == 0
371
assert 'Main CLI group' in result.output
372
373
# Test subcommand
374
result = runner.invoke(cli, ['status'])
375
assert result.exit_code == 0
376
assert 'Status: OK' in result.output
377
378
# Test subcommand with args
379
result = runner.invoke(cli, ['greet', 'World'])
380
assert result.exit_code == 0
381
assert 'Hello World!' in result.output
382
```
383
384
**Testing with Fixtures:**
385
386
```python
387
import pytest
388
389
@pytest.fixture
390
def runner():
391
"""CLI runner fixture."""
392
return CliRunner()
393
394
@pytest.fixture
395
def temp_config(runner):
396
"""Temporary config file fixture."""
397
with runner.isolated_filesystem():
398
config = {'host': 'localhost', 'port': 8080}
399
with open('config.json', 'w') as f:
400
json.dump(config, f)
401
yield 'config.json'
402
403
def test_with_fixtures(runner, temp_config):
404
result = runner.invoke(load_config_command, [temp_config])
405
assert result.exit_code == 0
406
```