Running tests and linting¶
Your business logic should of course be covered by unit tests.
When generating code using lime-project generate
the result includes boilerplate tests, where you can add your own tests.
For Python we are using pytest and our own pytest
plugin lime-test
.
For Web Components we are using Stencils test framework which uses Jest,
and some helpers from @lundalogik/limeclient.js
to help create an environment that is similar to a real application.
In Lime projects we are also using linting tools to catch common code mistakes and follow a shared set of formatting rules.
For Python we are using flake8 and for Web Components we are using ESLint.
Info
Our standard CI toolchain will fail any build containing any test or lint error.
Running tests¶
To run your Python tests with pytest use:
VS Code can be configured to discover and run the tests.
Linting and formatting¶
flake8¶
To lint you Python code with flake8 use:
VS Code can be configured to automatically lint your code.
black¶
We also recommend formatting the code with black, which will format your code according to a set of formatting rules.
If you have black installed you can either use it to check your code for inconsistencies with regards to the formatting rules, or run the automatic formatting.
Checking your code:
Formatting your code:
isort¶
Another recommended formatting tool is isort, which will sort and format the import statements in your python files.
If you have isort installed it can also be used to check your code or run automatic formatting.
Checking your code:
Formatting your code:
The lime-test
framework¶
lime-test
helps you write tests for code that makes use of the Lime CRM platform, such as plugins or non-core packages.
It's implemented as a plugin to pytest, which gives you a set of ready made fixtures that you can use for the more basic types of tests for Lime.
For more control, you are encouraged to use the available helper functions that allows you to create more pointed test code.
The following example uses the lime_app
fixture, which loads a complete LimeApplication
(lime_application.application.LimeApplication) with a set of lime types defined that closely matches the current state of the lime-core database.
It also loads a complete database to memory that is disregarded after the test ends to ensure that multiple tests don't interfere with each other.
The code we want to test:
def average(limeobjects, propname):
values = [o.get_property(propname).value for o in limeobjects]
return sum(values) / len(values)
The code we can use to test it:
def test_average(lime_app):
Deal = lime_app.limetypes.deal
deals = [
Deal(value=2),
Deal(value=4)
]
assert average(deals, 'value') == 3
Fixtures¶
This list contains description of fixtures that are used in lime-core
and can be used in any dependant project. There is no need to import them. They are available in the global scope.
Command pytest --fixtures
outputs all available fixtures. Some of them are not described below and one can use them at their own risk!
lime_types¶
Scope: session
Returns list of limetypes List[LimeType]
that are created from DSL.
database¶
Scope: function
Depends on: lime_types
Returns LimeDatabase
like below.
LimeDatabase(
limename="Lime_DB",
sqlname="Lime_SQL",
sqlhost="localhost",
is_default_database=True,
id="!!",
)
The database is prepopulated. It contains groups Administrators
and Users
. It also has users admin
(password admin
) that belongs to Administrators
group, and standard
(password standard
) that belongs to Users
group. Prepopulated groups and users can be accessed from a test. The only active and system language is en_us
.
Note: every new user and group created in this database will have identifier greater than 1000, what reflects behavior on production.
connection¶
Scope: function
Depends on: database
Returns active connection to the database used for tests.
lime_databases¶
Scope: function
Depends on: database
Returns LimeDatabases
that contain the database instance used for tests. The output is the same as for lime_database.get_lime_databases()
.
lime_app¶
Scope: function
Depends on: lime_types
, database
Requires: admin
user in the database.
Returns LimeApplication
with user admin
that has also session created. Rest of parameters are default.
There is no coworker
, so user of the fixture must extend it, if needed:
@pytest.fixture
def lime_app(lime_app, save_lime_objects):
save_lime_objects(lime_app.limetypes.coworker(username=lime_app.user_id))
return lime_app
lime_app_non_admin¶
Scope: function
Depends on: lime_types
, database
Requires: standard
user in the database.
Same as lime_app
, but the user is standard
.
user_repository¶
Scope: function
Depends on: database
Yields LimeUsers
repository with active connection.
group_repository¶
Scope: function
Depends on: database
Yields Groups
repository with active connection.
create_user¶
Scope: function
Depends on: database
Factory fixture that creates a user in the database. Example:
_return_entity
flag (which is False
by default) controls whether full LimeUser
object is returned (True
), or just its identifier (False
). Returning identifier is faster and should be used if full user object is not necessary.
Created users are automatically deleted after the test.
create_users¶
Scope: function
Depends on: create_user
Shortcut for creating multiple users with single factory call.
from lime_authentication.users import LimeUser
def test_something(create_users):
users = create_users(
users=[
LimeUser(username="user_1", fullname="User 1"),
LimeUser(username="user_2", fullname="User 2"),
LimeUser(username="user_3", fullname="User 3"),
]
)
Created users are automatically deleted after the test.
create_group¶
Scope: function
Depends on: database
Factory fixture that creates a group in the database. Example:
def test_something(create_user, create_group):
user = create_user(username="my_user", _return_entity=True)
sub_group_id = create_group(
name="Best Group",
user_members=[user],
_return_entity=False,
)
group = create_group(
name="Even Better Group",
subgroup_members=[sub_group_id],
_return_entity=True,
)
_return_entity
flag (which is False
by default) controls whether full Group
object is returned (True
), or just its identifier (False
). Returning identifier is faster and should be used if full group object is not necessary.
Created groups are automatically deleted after the test.
add_to_group¶
Scope: function
Depends on: database
Callable fixture that adds specified users and subgroups to a group.
def test_something(create_user, create_group, add_to_group):
user = create_user(username="my_user", _return_entity=True)
sub_group_id = create_group(
name="Best Group",
_return_entity=False,
)
add_to_group(group=sub_group_id, user_members=[user])
group = create_group(
name="Even Better Group",
_return_entity=True,
)
add_to_group(group=group, subgroup_members=[sub_group_id])
It can be used when there is need to add a user / subgroup to the group after it is created. Good example is Administrators
or Users
groups that are prepopulated in database
.
Users and subgroups are removed from groups automatically after the test.
webserver_service_locator¶
Returns DependencyContainer
instance that allows to register webserver dependencies that are used and injected.
from lime_authentication.authentication import AuthenticationMethod
class FakeAuthenticationMethod(AuthenticationMethod):
...
def test_something(webserver_service_locator):
webserver_service_locator.register(
service=AuthenticationMethod,
factory=FakeAuthenticationMethod,
)
web_client¶
Scope: function
Depends on: lime_databases
, lime_app
, webserver_service_locator
Requires: admin
user with password admin
in the database.
Returns FlaskClient
that is authenticated as admin
user and there is no need to provide API key in the request.
web_client_non_admin¶
Scope: function
Depends on: lime_databases
, lime_app
, webserver_service_locator
Requires: standard
user with password standard
in the database.
Same as web_client
, but authenticated user is standard
.
api_client¶
Scope: function
Depends on: lime_databases
, lime_app
, webserver_service_locator
Returns FlaskClient
that is not authenticated. All requests must contain API key to succeed.
prefix_url¶
Scope: function
Depends on: database
Factory fixture that takes the url on input and prefixes it with database name.
def test_something(web_client, prefix_url):
response = web_client.get("lime_db/api/v1/users")
# or
response = web_client.get(prefix_url("/api/v1/users"))
save_lime_objects¶
Scope: function
Depends on: lime_app
Callable fixture that simplifies the process of saving lime objects.
def test_something(lime_app, save_lime_objects):
coworker, company = save_lime_objects(
lime_app.limetypes.coworker(...),
lime_app.limetypes.company(...),
)
Objects returned from save_lime_objects
are saved to database. All relation management (attach / detach) must be done before passing objects to callable.
All saved lime objects are automatically removed after the test.
create_file¶
Scope: function
Depends on: lime_app
, database
Factory fixture that creates the file. It uses FileStorage
for provided LimeApplication
. By default, lime_app
fixture output is used.
from lime_application import LimeApplication
def test_something(create_file):
file = create_file(name="my_file.txt", locked=True)
file_2 = create_file(lime_application=LimeApplication(...), name="other_file.jpg")
All created files are automatically deleted after the test.
update_service_config¶
Scope: session
Callable fixture that updates the configuration for the entire service (e.g. webserver). This fixture should be used instead of calling lime_config.load_config(...)
which is deprecated.
def test_something(update_service_config):
update_service_config(
{
"globals": {...},
"features": {...},
"whatever": {...},
}
)
set_app_config¶
Scope: function
Callable fixture that sets the configuration for the application. This fixture should be used instead of mocking lime_config.get_app_config
or creating application_config.yaml
only for tests.
def test_something(set_app_config, lime_app):
set_app_config(
app_id=lime_app.identifier,
config={
"config": {...},
"secrets": {...},
}
)
change_setting¶
Scope: function
Depends on: database
Callable fixture that changes the given setting in setting
table.
All settings are automatically removed after the test.
Extending fixtures¶
Fixtures can be extended and still used in combination with rest of them. Some of mentioned fixtures depend on other fixtures, or require some specific state, so as long as these are fulfilled, everything is fine.
To extend the fixture, one must define the fixture with the same name. Such fixture must take on input the fixture being extended.
import pytest
@pytest.fixture(scope="function")
def lime_app(lime_app, save_lime_objects):
save_lime_objects(lime_app.limetypes.coworker(username=lime_app.user_id))
return lime_app
Above extension adds coworker for the application. Another example is extending lime types.
from typing import List
import pytest
from lime_type import LimeType, create_limetypes_from_dsl
@pytest.fixture(scope="session")
def lime_types(lime_types) -> List[LimeType]:
custom_dsl = ...
custom_limetypes = create_limetypes_from_dsl(custom_dsl)
return lime_types + custom_limetypes
Note: scopes of the fixture extensions and their output types MUST match with originals.
Replacing fixtures¶
As per standard pytest
API, any fixture can be replaced if other fixture with the same name is defined. As long as scope and output type of replacement matches the original fixture, it can be used with the rest of fixtures described earlier.
from typing import List
import pytest
from lime_type import LimeType, create_limetypes_from_dsl
@pytest.fixture(scope="session")
def lime_types() -> List[LimeType]:
custom_dsl = ...
custom_limetypes = create_limetypes_from_dsl(custom_dsl)
return custom_limetypes
Note: scopes of the fixture replacements and their output types MUST match with originals.
Defining data structures¶
In order for your tests to be manageable, it's essential to distinctly be able to define and read what types are expected of your code. To help with this, lime-test makes use of lime-core's ability to express data structures as dicts or YAML-documents.
The following shows an example of a fixture that sets up an empty database with a coworker and a company that relates to each other. It is recommended to use a separate YAML file for the database structure so it can be reused for testing web components in the frontend.
company:
name: string
rating:
type: option
options:
- poor
- good
- excellent
responsible:
type: belongsto
related: coworker
backref: companies
coworker:
company:
type: hasmany
related: company
backref: responsible
@pytest.fixture
def my_database(empty_database):
with open("dsl.yaml", "r") as file:
types = file.read()
limetypes = lime_type.create_limetypes_from_dsl(types)
lime_test.db.add_limetypes(empty_database, limetypes)
return empty_database
For more detailed information see lime_type.create_limetypes_from_dsl
. It's also possible to define object instances to use in your tests by using lime_type.create_limeobjects_from_dsl
.
In-memory Web Application¶
lime-test
enables you to start up a complete web application in memory that you can make requests to to ensure that your endpoints work as they should. It builds on the in memory database to create a complete environment for your endpoints that is created for your specific test method, and teared down immediately afterwards.
It's an implementation of Flasks's test server
A simple example¶
Given the following endpoint:
class DealAverage(webserver.LimeResource):
def get(self, companyid):
company = self.application.limetypes.company.get(companyid)
avg = averagelib.average(company.properties.deal.fetch(), 'value')
return {'average': avg}
api.add_resource(DealAverage, '/dealaverage/<int:companyid>')
We can write the following test:
def test_calculate_average_for_deals(web_client, lime_app):
Company = lime_app.limetypes.company
Deal = lime_app.limetypes.deal
uow = lime_app.unit_of_work()
acme = Company(name='acme')
acme_idx = uow.add(acme)
deals = [
Deal(name='deal1', value=42),
Deal(name='deal2', value=128),
]
for d in deals:
acme.properties.deal.attach(d)
uow.add(d)
res = uow.commit()
acme = res[acme_idx]
res = web_client.get('/myapp/acme_solution/dealaverage/{acme.id}'
.format(acme=acme))
json_response = json.loads(res.data.decode('utf-8'))
assert res.status_code == 200
assert json_response == {'average': 85.0}