Testing Laputa¶
Running tests¶
Default configuration parameters can be found in tests/common/parameters:
tests/common/parameters/
├── auth.json
├── config.yml
├── default_email_templates.yml
└── default_settings.yml
Note: by default, the database configuration (for both redis and mongo) is meaningless since they are set dynamically: a mongo and a redis containers are started automatically at the beginning of the test session and the configuration will be adjusted accordingly to match the random ports exposed by the containers.
Tests can be run with pytest directly:
pytest
If you want to use your own redis or mongo databases, or if you want to run your
tests inside a container (as jenkins does for instance), you should use the
--system-databases pytest option: in that case, pytest will use the database
definition found in tests/common/parameters/config.yml.
Writing tests¶
Helpers¶
There are quite a few cases where you want to test an object structure but you
can't test for strict equality because there are a few random or unpredictable
values in it. This is exactly what the assertlib module in the tests/ directory is for.
It provides "check-ables" that can be used to test structures.
A check-able is an instance of an assertlib.AbstractCheckable.
It can be used to test a data structure using either its .match method, or just by testing equality:
from tests.assertlib import InstanceOf, OfLength
assert 12 == InstanceOf(int)
assert [1, 2] == OfLength(2)
assert [1, 2] != OfLength(3)
These check-ables can be logically combined using | and & operators:
from tests.assertlib import InstanceOf, OfLength, Almost
assert [1, 2] == InstanceOf(list) & OfLength(2)
assert 12.00000001 == Almost(21, 1) | Almost(12, 0.1)
The ListContaining and DictContaining check-ables are quite useful to test nested structures,
when only some properties are of interest. They can be combined recursively if needed, and with other check-ables:
from tests.assertlib import DictContaining, ListContaining, InstanceOf, OfLength
assert DictContaining({
'name': 'Delta',
'order': InstanceOf(int),
}) == {
'name': 'Delta',
'order': 4,
'uid': '123-456-789',
'last_update': '2022-02-11'
}
assert ListContaining([
DictContaining({ 'name': 'Delta' }),
DictContaining({ 'name': 'Epsilon' }),
]) & OfLength(6) == [
{ 'name': 'Alpha', 'char': 'α'},
{ 'name': 'Beta', 'char': 'β'},
{ 'name': 'Gamma', 'char': 'γ'},
{ 'name': 'Delta', 'char': 'δ'},
{ 'name': 'Epsilon', 'char': 'ε'},
{ 'name': 'Zeta', 'char': 'ζ'},
]
The package provides two other helpers with a similar function:
is_seq_like(tested, model)to compare sequence structures,is_dict_like(tested, model)to compare dict structures.
The model can use also any combination of check-ables:
from tests.assertlib import is_dict_like, InstanceOf, Optional
assert is_dict_like({'a': 12, 'b': b'foo', 'c': True}, {
'a': 12,
'b': InstanceOf(str) | InstanceOf(bytes),
'c': True,
'd': Optional(str)
})
The main difference between is_dict_like and DictContaining is that the first one will fail if there is any extra
fields not defined in the model (hence the frequent use of Any or Optional).
The same idea applies to is_seq_like and ListContaining: the first will expect the model and the tested structure
to have the same length.
Using config fixtures¶
In tests, we use a custom TestConfig class that extends the standard
laputa.common.config.Config one. This class is just a small extension to the
base class that adds:
- helper methods to add users or smallapps dynamically to the config,
- a custom filesystem storage handler to make sure we never clutter the
default template directory in
tests/storage.
Using the "global" config fixture¶
config is a session-scoped fixture that creates a TestConfig instance with
default parameters found in tests/common/parameters. If you want to perform API
queries on it, you should use the testutil.client_session_from_config helper
that accepts 2 parameters:
config: theTestConfiginstance,username: the username the session should use to make api calls.
WARNING Since it's a session-scoped configuration, it means that possible changes pushed
on the underlying database will impact subsequent tests. You should avoid using it as much
as possible unless performing read-only calls. Consider the local_config fixture instead.
Example¶
from ..testutil import client_session_from_config
def test_get_users_list_with_superadmin(config):
"""It should return the users list with superadmin if possible"""
with client_session_from_config(config, username='appadmin') as api_client:
res = api_client.get('/users?superadmin=true')
assert res.status_code == 200
assert 'appsuperuser' not in [user['username'] for user in res.json]
with client_session_from_config(config, username='appsuperuser') as api_client:
res = api_client.get('/users?superadmin=true')
assert res.status_code == 200
assert 'appsuperuser' in [user['username'] for user in res.json]
Using the "local_config" fixture¶
This is an alternative to the config fixture. It behaves exactly as config
BUT runs on a local database created specifically for a single test.
Therefore, changes made to the database are local to this test.
Example¶
from ..testutil import client_session_from_config
def test_get_users_list_with_superadmin(local_config):
"""It should return the users list with superadmin if possible"""
with client_session_from_config(local_config, username='appadmin') as api_client:
api_client.put('/laputa/url', json={'url': 'test_url'})
Switching between multiple users¶
Do not use client_session_from_config multiple times.
Instead, create only one flask app from the config, and create different sessions:
from tests.testutil import bound_flaskapp, client_session
def test_get(local_config):
with bound_flaskapp(local_config) as flaskapp:
with client_session(flaskapp, username='rick') as api_client:
api_client.get('/') # call using rick user
with client_session(flaskapp, username='appuser') as api_client:
api_client.get('/') # call using morty user
Using the "config_factory" fixture¶
When you don't want to load the full configuration defined in
tests/common/parameters because you don't want to remember which users or
smallapps preexist, you should use this fixture that will create an "empty" test
configuration. "Empty" actually means without smallapp and only with a
appsuperuser super user. All the other configuration parameters (e.g. email
templates) are loaded. However, would you wish to override them, you could pass
custom parameters to the factory.
As for the local_config fixture, configurations created with this factory will
be bound to a database temporarily created for the test it is used on.
Example¶
from ..testutil import client_session_from_config
def test_get_privileges_list(config_factory):
"""It should return the privileges list"""
config = config_factory().add_user('u1@userland.fr', role='USER')
with client_session_from_config(config, username='u1@userland.fr') as api_client:
res = api_client.get('/privileges')
assert res.status_code == 200
assert res.json == ['none', 'view', 'validate', 'contribute']
Caveats¶
If you are used to the old API and use data= parameters in your API calls
expecting that the api_client would do the magic by itself to set the
content-type to application/json, this is bad! If you do so with the new
API, you'll likely get an error such as:
TypeError: add_file() got an unexpected keyword argument 'username'
In that case, just use json= instead of data= and it should be good.
Example of bad API call:
res = api_client.post('/users', data={'attributes': user})
You should instead do:
res = api_client.post('/users', json={'attributes': user})