From cf5525f98a2a9a15f6bb93b050dfd43f2bd90d97 Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 19 Feb 2018 15:34:46 -0800 Subject: [PATCH] add test_cli_runner for testing app.cli commands --- CHANGES.rst | 3 +++ docs/api.rst | 9 ++++++++ docs/testing.rst | 30 +++++++++++++++++--------- flask/app.py | 25 ++++++++++++++++++++++ flask/testing.py | 36 +++++++++++++++++++++++++++++++ tests/test_testing.py | 49 +++++++++++++++++++++++++++++++++++++++++-- 6 files changed, 140 insertions(+), 12 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 58ea4b16..84fa436f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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 @@ -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 diff --git a/docs/api.rst b/docs/api.rst index e24160c4..de33ff95 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -188,6 +188,15 @@ Test Client :members: +Test CLI Runner +--------------- + +.. currentmodule:: flask.testing + +.. autoclass:: FlaskCliRunner + :members: + + Application Globals ------------------- diff --git a/docs/testing.rst b/docs/testing.rst index 79856341..4a272df6 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -413,15 +413,17 @@ 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 ` 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') @@ -429,14 +431,22 @@ captures the output in a :class:`~click.testing.Result` object. :: 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: diff --git a/flask/app.py b/flask/app.py index bbd66be7..7c53a583 100644 --- a/flask/app.py +++ b/flask/app.py @@ -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. #: @@ -983,6 +991,23 @@ class Flask(_PackageBoundObject): 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 diff --git a/flask/testing.py b/flask/testing.py index 586084eb..cd346ce0 100644 --- a/flask/testing.py +++ b/flask/testing.py @@ -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 @@ -193,3 +196,36 @@ class FlaskClient(Client): 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 ` 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) diff --git a/tests/test_testing.py b/tests/test_testing.py index 615f120e..b0619d2c 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -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): @@ -335,3 +336,47 @@ def test_nosubdomain(app, client): 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