Skip to content

Commit 041a0cf

Browse files
committed
Merge branch '120-test-with-request-context'
2 parents e12044b + a4a4236 commit 041a0cf

File tree

5 files changed

+124
-14
lines changed

5 files changed

+124
-14
lines changed

docs/source/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Contents
2121
component
2222
utils
2323
signals
24+
testing
2425
web
2526
readme/features
2627
readme/configuration

docs/source/signals.rst

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Signals
44
As Keg is based on Flask architecture, signals are used to set up and execute callback
55
methods upon certain events.
66

7-
Attaching a callback to a signal involves the connect decorator:
7+
Attaching a callback to a signal involves the connect decorator::
88

99
from keg.signals import init_complete
1010

@@ -29,17 +29,17 @@ Keg Events
2929

3030
- App config has been loaded
3131
- Config is the first property of the app to be initialized. `app.config` will be available,
32-
but do not count on anything else.
32+
but do not count on anything else.
3333

3434
* `db_before_import`
3535

3636
- Database options have been configured, and the app is about to visit modules containing
37-
entities
37+
entities
3838
- Config, logging, and error handling have been loaded, but no other extensions
3939
- Some SQLAlchemy metadata attributes, such as naming convention, need to be set prior to
40-
entities loading. Attaching a method on this signal is an ideal way to set these properties.
40+
entities loading. Attaching a method on this signal is an ideal way to set these properties.
4141
- If customization of the db object, metadata, engine options, etc. is needed, ensure that
42-
no modules containing entities are imported before the connected callback runs.
42+
no modules containing entities are imported before the connected callback runs.
4343

4444
* `testing_run_start`
4545

@@ -50,4 +50,4 @@ Keg Events
5050
* `db_clear_pre`, `db_clear_post`, `db_init_pre`, `db_init_post`
5151

5252
- Called during the database initialization process, which occurs in test setup and from CLI
53-
commands
53+
commands

docs/source/testing.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Testing Utils
2+
=============
3+
4+
.. automodule:: keg.testing
5+
:members:

keg/testing.py

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import flask
88
import six
99
from flask_webtest import TestApp
10+
from werkzeug.datastructures import ImmutableMultiDict, MultiDict
1011

1112
from keg import current_app, signals
1213
from keg.utils import app_environ_get
@@ -22,6 +23,10 @@ def _config_profile(appcls):
2223
class ContextManager(object):
2324
"""
2425
Facilitates having a single instance of an application ready for testing.
26+
27+
By default, this is used in ``Keg.testing_prep``.
28+
29+
Constructor arg is the Keg app class to manage for tests.
2530
"""
2631
apps = {}
2732

@@ -31,6 +36,7 @@ def __init__(self, appcls):
3136
self.ctx = None
3237

3338
def ensure_current(self, config):
39+
"""Ensure the manager's app has an instance set as flask's ``current_app``"""
3440

3541
if not self.app:
3642
self.app = self.appcls().init(use_test_profile=True, config=config)
@@ -43,9 +49,16 @@ def ensure_current(self, config):
4349
return self.app
4450

4551
def cleanup(self):
52+
"""Pop the app context"""
4653
self.ctx.pop()
4754

4855
def is_ready(self):
56+
"""Indicates the manager's app instance exists.
57+
58+
The instance should be created with ``get_for``. Only one ContextManager instance will get
59+
created in a Python process for any given app. But, ``get_for`` may be called multiple
60+
times. The first call to ``ensure_current`` will set up the application and bring the
61+
manager to a ready state."""
4962
return self.app is not None
5063

5164
@classmethod
@@ -63,17 +76,18 @@ def get_for(cls, appcls):
6376
def app_config(**kwargs):
6477
"""
6578
Set config values on any apps instantiated while the context manager is active.
66-
This is intended to be used with cli tests where the `current_app` in the test will be
67-
different from the `current_app` when the CLI command is invoked, making it very difficult
79+
This is intended to be used with cli tests where the ``current_app`` in the test will be
80+
different from the ``current_app`` when the CLI command is invoked, making it very difficult
6881
to dynamically set app config variables using mock.patch.dict like we normally would.
82+
6983
Example::
7084
71-
class TestCLI(CLIBase):
72-
app_cls = MyApp
73-
def test_it(self):
74-
with testing.app_config(FOO_NAME='Bar'):
75-
result = self.invoke('echo-foo-name')
76-
assert 'Bar' in result.output
85+
class TestCLI(CLIBase):
86+
app_cls = MyApp
87+
def test_it(self):
88+
with testing.app_config(FOO_NAME='Bar'):
89+
result = self.invoke('echo-foo-name')
90+
assert 'Bar' in result.output
7791
"""
7892
@signals.config_complete.connect
7993
def set_config(app):
@@ -82,7 +96,57 @@ def set_config(app):
8296
yield
8397

8498

99+
@contextlib.contextmanager
100+
def inrequest(*req_args, args_modifier=None, **req_kwargs):
101+
"""A decorator/context manager to add the flask request context to a test function.
102+
103+
Allows test to assume a request context without running a full view stack. Use for
104+
unit-testing a view instance without setting up a webtest instance for the app and
105+
running requests.
106+
107+
Flask's ``request.args`` is normally immutable, but in test cases, it can be helpful to
108+
patch in args without needing to construct the URL. But, we don't want to leave them
109+
mutable, because potential app bugs could be masked in doing so. To modify args, pass
110+
in a callable as ``args_modifier`` that takes the args dict to be modified in-place. Args
111+
will only be mutable for executing the modifier, then returned to immutable for the
112+
remainder of the scope.
113+
114+
Assumes that ``flask.current_app`` is pointing to the desired app.
115+
116+
Example::
117+
118+
@inrequest('/mypath?foo=bar&baz=boo')
119+
def test_in_request_args(self):
120+
assert flask.request.args['foo'] == 'bar'
121+
122+
def test_request_args_mutated(self):
123+
def args_modifier(args_dict):
124+
args_dict['baz'] = 'custom-value'
125+
126+
with inrequest('/mypath?foo=bar&baz=boo', args_modifier=args_modifier):
127+
assert flask.request.args['foo'] == 'bar'
128+
assert flask.request.args['baz'] == 'custom-value'
129+
"""
130+
with flask.current_app.test_request_context(*req_args, **req_kwargs):
131+
if callable(args_modifier):
132+
# Temporarily turn args into a mutable MultiDict to be patched. Then, we must
133+
# be sure to turn them back immutable, or else tests may end up not catching
134+
# bugs that attempt to modify request args improperly.
135+
new_args = MultiDict(flask.request.args)
136+
args_modifier(new_args)
137+
flask.request.args = ImmutableMultiDict(new_args)
138+
139+
yield
140+
141+
85142
def invoke_command(app_cls, *args, **kwargs):
143+
"""Invoke a command using a CLI runner and return the result.
144+
145+
Optional kwargs:
146+
- exit_code: Default 0. Process exit code to assert.
147+
- runner: Default ``click.testing.CliRunner()``. CLI runner instance to use for invocation.
148+
- use_test_profile: Default True. Drive invoked app to use test profile instead of default.
149+
"""
86150
exit_code = kwargs.pop('exit_code', 0)
87151
runner = kwargs.pop('runner', None) or click.testing.CliRunner()
88152
use_test_profile = kwargs.pop('use_test_profile', True)
@@ -107,6 +171,15 @@ def invoke_command(app_cls, *args, **kwargs):
107171

108172

109173
class CLIBase(object):
174+
"""Test class base for testing Keg click commands.
175+
176+
Creates a CLI runner instance, and allows subclass to call ``self.invoke`` with
177+
command args.
178+
179+
Class attributes:
180+
- app_cls: Optional, will default to ``flask.current_app`` class.
181+
- cmd_name: Optional, provides default in ``self.invoke`` for ``cmd_name`` kwarg.
182+
"""
110183
# child classes will need to set these values in order to use the class
111184
app_cls = None
112185
cmd_name = None
@@ -116,6 +189,7 @@ def setup_class(cls):
116189
cls.runner = click.testing.CliRunner()
117190

118191
def invoke(self, *args, **kwargs):
192+
"""Run a command, perform some assertions, and return the result for testing."""
119193
cmd_name = kwargs.pop('cmd_name', self.cmd_name)
120194
if cmd_name is None:
121195
cmd_name_args = []

keg/tests/test_utils.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
import tempfile
44
from unittest import mock
55

6+
import flask
67
import pytest
78

9+
from keg.testing import inrequest
810
from keg.utils import pymodule_fpaths_to_objects
11+
from keg_apps.web.app import WebApp
912

1013

1114
class TestUtils(object):
@@ -45,3 +48,30 @@ def test_pymodule_fpaths_to_objects_error(self, m_open, error):
4548
assert fpath == 'some-path.py'
4649
assert fpath_objs is None
4750
assert isinstance(exc, error)
51+
52+
53+
class TestInRequest:
54+
@classmethod
55+
def setup_class(cls):
56+
WebApp.testing_prep()
57+
58+
@inrequest('/mypath?foo=bar&baz=boo')
59+
def test_in_request_args(self):
60+
assert flask.request.args['foo'] == 'bar'
61+
62+
@inrequest('/mypath?foo=bar&baz=boo')
63+
def test_request_args_immutable(self):
64+
with pytest.raises(TypeError, match='immutable'):
65+
flask.request.args['foo'] = 'moo'
66+
67+
def test_request_args_mutated(self):
68+
def args_modifier(args_dict):
69+
args_dict['baz'] = 'custom-value'
70+
71+
with inrequest('/mypath?foo=bar&baz=boo', args_modifier=args_modifier):
72+
assert flask.request.args['foo'] == 'bar'
73+
assert flask.request.args['baz'] == 'custom-value'
74+
75+
# ensure args return to immutable after patch
76+
with pytest.raises(TypeError, match='immutable'):
77+
flask.request.args['foo'] = 'moo'

0 commit comments

Comments
 (0)