Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add test_cli_runner for testing app.cli commands #2636

Merged
merged 1 commit into from
Feb 20, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ unreleased
development server over HTTPS. (`#2606`_)
- Added :data:`SESSION_COOKIE_SAMESITE` to control the ``SameSite``
attribute on the session cookie. (`#2607`_)
- Added :meth:`~flask.Flask.test_cli_runner` to create a Click runner
that can invoke Flask CLI commands for testing. (`#2636`_)

.. _pallets/meta#24: https://github.com/pallets/meta/issues/24
.. _#1421: https://github.com/pallets/flask/issues/1421
Expand Down Expand Up @@ -178,6 +180,7 @@ unreleased
.. _#2581: https://github.com/pallets/flask/pull/2581
.. _#2606: https://github.com/pallets/flask/pull/2606
.. _#2607: https://github.com/pallets/flask/pull/2607
.. _#2636: https://github.com/pallets/flask/pull/2636


Version 0.12.2
Expand Down
9 changes: 9 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,15 @@ Test Client
:members:


Test CLI Runner
---------------

.. currentmodule:: flask.testing

.. autoclass:: FlaskCliRunner
:members:


Application Globals
-------------------

Expand Down
30 changes: 20 additions & 10 deletions docs/testing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -413,30 +413,40 @@ with ``get_json``.
Testing CLI Commands
--------------------

Click comes with `utilities for testing`_ your CLI commands.
Click comes with `utilities for testing`_ your CLI commands. A
:class:`~click.testing.CliRunner` runs commands in isolation and
captures the output in a :class:`~click.testing.Result` object.

Use :meth:`CliRunner.invoke <click.testing.CliRunner.invoke>` to call
commands in the same way they would be called from the command line. The
:class:`~click.testing.CliRunner` runs the command in isolation and
captures the output in a :class:`~click.testing.Result` object. ::
Flask provides :meth:`~flask.Flask.test_cli_runner` to create a
:class:`~flask.testing.FlaskCliRunner` that passes the Flask app to the
CLI automatically. Use its :meth:`~flask.testing.FlaskCliRunner.invoke`
method to call commands in the same way they would be called from the
command line. ::

import click
from click.testing import CliRunner

@app.cli.command('hello')
@click.option('--name', default='World')
def hello_command(name)
click.echo(f'Hello, {name}!')

def test_hello():
runner = CliRunner()
runner = app.test_cli_runner()

# invoke the command directly
result = runner.invoke(hello_command, ['--name', 'Flask'])
assert 'Hello, Flask' in result.output

# or by name
result = runner.invoke(args=['hello'])
assert 'World' in result.output

In the example above, invoking the command by name is useful because it
verifies that the command was correctly registered with the app.

If you want to test how your command parses parameters, without running
the command, use the command's :meth:`~click.BaseCommand.make_context`
method. This is useful for testing complex validation rules and custom
types. ::
the command, use its :meth:`~click.BaseCommand.make_context` method.
This is useful for testing complex validation rules and custom types. ::

def upper(ctx, param, value):
if value is not None:
Expand Down
25 changes: 25 additions & 0 deletions flask/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,14 @@ class Flask(_PackageBoundObject):
#: .. versionadded:: 0.7
test_client_class = None

#: The :class:`~click.testing.CliRunner` subclass, by default
#: :class:`~flask.testing.FlaskCliRunner` that is used by
#: :meth:`test_cli_runner`. Its ``__init__`` method should take a
#: Flask app object as the first argument.
#:
#: .. versionadded:: 1.0
test_cli_runner_class = None

#: the session interface to use. By default an instance of
#: :class:`~flask.sessions.SecureCookieSessionInterface` is used here.
#:
Expand Down Expand Up @@ -983,6 +991,23 @@ def __init__(self, *args, **kwargs):
from flask.testing import FlaskClient as cls
return cls(self, self.response_class, use_cookies=use_cookies, **kwargs)

def test_cli_runner(self, **kwargs):
"""Create a CLI runner for testing CLI commands.
See :ref:`testing-cli`.

Returns an instance of :attr:`test_cli_runner_class`, by default
:class:`~flask.testing.FlaskCliRunner`. The Flask app object is
passed as the first argument.

.. versionadded:: 1.0
"""
cls = self.test_cli_runner_class

if cls is None:
from flask.testing import FlaskCliRunner as cls

return cls(self, **kwargs)

def open_session(self, request):
"""Creates or opens a new session. Default implementation stores all
session data in a signed cookie. This requires that the
Expand Down
36 changes: 36 additions & 0 deletions flask/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

import werkzeug
from contextlib import contextmanager

from click.testing import CliRunner
from flask.cli import ScriptInfo
from werkzeug.test import Client, EnvironBuilder
from flask import _request_ctx_stack
from flask.json import dumps as json_dumps
Expand Down Expand Up @@ -193,3 +196,36 @@ def __exit__(self, exc_type, exc_value, tb):
top = _request_ctx_stack.top
if top is not None and top.preserved:
top.pop()


class FlaskCliRunner(CliRunner):
"""A :class:`~click.testing.CliRunner` for testing a Flask app's
CLI commands. Typically created using
:meth:`~flask.Flask.test_cli_runner`. See :ref:`testing-cli`.
"""
def __init__(self, app, **kwargs):
self.app = app
super(FlaskCliRunner, self).__init__(**kwargs)

def invoke(self, cli=None, args=None, **kwargs):
"""Invokes a CLI command in an isolated environment. See
:meth:`CliRunner.invoke <click.testing.CliRunner.invoke>` for
full method documentation. See :ref:`testing-cli` for examples.

If the ``obj`` argument is not given, passes an instance of
:class:`~flask.cli.ScriptInfo` that knows how to load the Flask
app being tested.

:param cli: Command object to invoke. Default is the app's
:attr:`~flask.app.Flask.cli` group.
:param args: List of strings to invoke the command with.

:return: a :class:`~click.testing.Result` object.
"""
if cli is None:
cli = self.app.cli

if 'obj' not in kwargs:
kwargs['obj'] = ScriptInfo(create_app=lambda: self.app)

return super(FlaskCliRunner, self).invoke(cli, args, **kwargs)
49 changes: 47 additions & 2 deletions tests/test_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@
:copyright: © 2010 by the Pallets team.
:license: BSD, see LICENSE for more details.
"""

import click
import pytest

import flask
import werkzeug

from flask._compat import text_type
from flask.cli import ScriptInfo
from flask.json import jsonify
from flask.testing import make_test_environ_builder
from flask.testing import make_test_environ_builder, FlaskCliRunner


def test_environ_defaults_from_config(app, client):
Expand Down Expand Up @@ -335,3 +336,47 @@ def view(company_id):

assert 200 == response.status_code
assert b'xxx' == response.data


def test_cli_runner_class(app):
runner = app.test_cli_runner()
assert isinstance(runner, FlaskCliRunner)

class SubRunner(FlaskCliRunner):
pass

app.test_cli_runner_class = SubRunner
runner = app.test_cli_runner()
assert isinstance(runner, SubRunner)


def test_cli_invoke(app):
@app.cli.command('hello')
def hello_command():
click.echo('Hello, World!')

runner = app.test_cli_runner()
# invoke with command name
result = runner.invoke(args=['hello'])
assert 'Hello' in result.output
# invoke with command object
result = runner.invoke(hello_command)
assert 'Hello' in result.output


def test_cli_custom_obj(app):
class NS(object):
called = False

def create_app():
NS.called = True
return app

@app.cli.command('hello')
def hello_command():
click.echo('Hello, World!')

script_info = ScriptInfo(create_app=create_app)
runner = app.test_cli_runner()
runner.invoke(hello_command, obj=script_info)
assert NS.called