Testing¶
Testing Typer applications is very easy with pytest.
Let's say you have an application app/main.py
with:
from typing import Optional
import typer
app = typer.Typer()
@app.command()
def main(name: str, city: Optional[str] = None):
print(f"Hello {name}")
if city:
print(f"Let's have a coffee in {city}")
if __name__ == "__main__":
app()
So, you would use it like:
$ python main.py Camila --city Berlin
Hello Camila
Let's have a coffee in Berlin
And the directory also has an empty app/__init__.py
file.
So, the app
is a "Python package".
Test the app¶
Import and create a CliRunner
¶
Create another file/module app/test_main.py
.
Import CliRunner
and create a runner
object.
This runner is what will "invoke" or "call" your command line application.
from typer.testing import CliRunner
from .main import app
runner = CliRunner()
def test_app():
result = runner.invoke(app, ["Camila", "--city", "Berlin"])
assert result.exit_code == 0
assert "Hello Camila" in result.stdout
assert "Let's have a coffee in Berlin" in result.stdout
Tip
It's important that the name of the file starts with test_
, that way pytest will be able to detect it and use it automatically.
Call the app¶
Then create a function test_app()
.
And inside of the function, use the runner
to invoke
the application.
The first parameter to runner.invoke()
is a Typer
app.
The second parameter is a list
of str
, with all the text you would pass in the command line, right as you would pass it:
from typer.testing import CliRunner
from .main import app
runner = CliRunner()
def test_app():
result = runner.invoke(app, ["Camila", "--city", "Berlin"])
assert result.exit_code == 0
assert "Hello Camila" in result.stdout
assert "Let's have a coffee in Berlin" in result.stdout
Tip
The name of the function has to start with test_
, that way pytest can detect it and use it automatically.
Check the result¶
Then, inside of the test function, add assert
statements to ensure that everything in the result of the call is as it should be.
from typer.testing import CliRunner
from .main import app
runner = CliRunner()
def test_app():
result = runner.invoke(app, ["Camila", "--city", "Berlin"])
assert result.exit_code == 0
assert "Hello Camila" in result.stdout
assert "Let's have a coffee in Berlin" in result.stdout
Here we are checking that the exit code is 0, as it is for programs that exit without errors.
Then we check that the text printed to "standard output" contains the text that our CLI program prints.
Tip
You could also check result.stderr
for "standard error" independently from "standard output" if your CliRunner
instance is created with the mix_stderr=False
argument.
Info
If you need a refresher about what is "standard output" and "standard error" check the section in Printing and Colors: "Standard Output" and "Standard Error".
Call pytest
¶
Then you can call pytest
in your directory and it will run your tests:
$ pytest
================ test session starts ================
platform linux -- Python 3.10, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /home/user/code/superawesome-cli/app
plugins: forked-1.1.3, xdist-1.31.0, cov-2.8.1
collected 1 item
---> 100%
test_main.py <span style="color: green; white-space: pre;">. [100%]</span>
<span style="color: green;">================= 1 passed in 0.03s =================</span>
Testing input¶
If you have a CLI with prompts, like:
import typer
from typing_extensions import Annotated
app = typer.Typer()
@app.command()
def main(name: str, email: Annotated[str, typer.Option(prompt=True)]):
print(f"Hello {name}, your email is: {email}")
if __name__ == "__main__":
app()
Tip
Prefer to use the Annotated
version if possible.
import typer
app = typer.Typer()
@app.command()
def main(name: str, email: str = typer.Option(..., prompt=True)):
print(f"Hello {name}, your email is: {email}")
if __name__ == "__main__":
app()
That you would use like:
$ python main.py Camila
# Email: $ camila@example.com
Hello Camila, your email is: camila@example.com
You can test the input typed in the terminal using input="camila@example.com\n"
.
This is because what you type in the terminal goes to "standard input" and is handled by the operating system as if it was a "virtual file".
Info
If you need a refresher about what is "standard output", "standard error", and "standard input" check the section in Printing and Colors: "Standard Output" and "Standard Error".
When you hit the ENTER key after typing the email, that is just a "new line character". And in Python that is represented with "\n"
.
So, if you use input="camila@example.com\n"
it means: "type camila@example.com
in the terminal, then hit the ENTER key":
from typer.testing import CliRunner
from .main import app
runner = CliRunner()
def test_app():
result = runner.invoke(app, ["Camila"], input="camila@example.com\n")
assert result.exit_code == 0
assert "Hello Camila, your email is: camila@example.com" in result.stdout
Test a function¶
If you have a script and you never created an explicit typer.Typer
app, like:
import typer
def main(name: str = "World"):
print(f"Hello {name}")
if __name__ == "__main__":
typer.run(main)
...you can still test it, by creating an app during testing:
import typer
from typer.testing import CliRunner
from .main import main
app = typer.Typer()
app.command()(main)
runner = CliRunner()
def test_app():
result = runner.invoke(app, ["--name", "Camila"])
assert result.exit_code == 0
assert "Hello Camila" in result.stdout
Of course, if you are testing that script, it's probably easier/cleaner to just create the explicit typer.Typer
app in main.py
instead of creating it just during the test.
But if you want to keep it that way, e.g. because it's a simple example in documentation, then you can use that trick.
About the app.command
decorator¶
Notice the app.command()(main)
.
If it's not obvious what it's doing, continue reading...
You would normally write something like:
@app.command()
def main(name: str = "World"):
# Some code here
But @app.command()
is just a decorator.
That's equivalent to:
def main(name: str = "World"):
# Some code here
decorator = app.command()
new_main = decorator(main)
main = new_main
app.command()
returns a function (decorator
) that takes another function as it's only parameter (main
).
And by using the @something
you normally tell Python to replace the thing below (the function main
) with the return of the decorator
function (new_main
).
Now, in the specific case of Typer, the decorator doesn't change the original function. It registers it internally and returns it unmodified.
So, new_main
is actually the same original main
.
So, in the case of Typer, as it doesn't really modify the decorated function, that would be equivalent to:
def main(name: str = "World"):
# Some code here
decorator = app.command()
decorator(main)
But then we don't need to create the variable decorator
to use it below, we can just use it directly:
def main(name: str = "World"):
# Some code here
app.command()(main)
...that's it. It's still probably simpler to just create the explicit typer.Typer
in the main.py
file 😅.