Running tests¶
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.
Info
Our standard CI toolchain will fail any build containing any test error.
Running tests¶
To run your Python tests with pytest use:
VS Code can be configured to discover and run the tests.
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¶
lime-test ships a large set of ready-made fixtures — see the Fixtures section for an overview, the conventions they follow, and a per-fixture reference.
Mocking¶
The Lime CRM platform wires services together with a dependency injection container, and that shapes how you should write test doubles. The guiding principle is to keep tests connected to real behavior: prefer real objects, inject test doubles through the container rather than patching, and reach for mocking only at genuine external boundaries.
Prefer real objects over mocks¶
Don't mock what you can pass in or inject. If a dependency can be provided as a parameter or through a fixture, do that instead of reaching for unittest.mock or monkeypatch.
When you do need a test double, write a fake — a small implementation of the interface — rather than a mock. Reserve mocking for true external boundaries (network calls, third-party services) where no other option exists.
import abc
class MyService(abc.ABC):
@abc.abstractmethod
def get_value(self) -> int: ...
class FakeMyService(MyService):
def __init__(self, value: int):
self._value = value
def get_value(self) -> int:
return self._value
@pytest.fixture
def my_service() -> MyService:
return FakeMyService(value=42)
def test_uses_service_value(my_service):
result = compute(my_service)
assert result == 42
A fake stays honest as the interface evolves — if MyService gains a new abstract method, the fake fails to instantiate until it implements it, whereas a Mock(spec=MyService) would silently keep passing.
Swap injected services through the container¶
When the code under test resolves a dependency from the DI container, override that registration for the test instead of patching the implementation. Define a lime_test_component_registrations__ fixture that returns the replacement registration — lime-test collects it and registers it in the container for the duration of the test.
from lime_core.dependencies import (
ComponentRegistration,
ComponentRegistrationMeta,
)
@pytest.fixture
def lime_test_component_registrations__fake_my_service(
request,
) -> list[ComponentRegistration]:
return [
ComponentRegistration(
service=MyService,
factory=lambda: FakeMyService(getattr(request, "param", 42)),
lifestyle="scoped",
meta=ComponentRegistrationMeta(label="test"),
)
]
This keeps production wiring untouched: the application still resolves MyService from the container, it just receives the fake. The request.param indirection lets a single fixture serve many tests through indirect parametrization. See lime_test_component_registrations for activation, naming variants, and the difference between replacing a registration and adding a new one.
Warning
meta=ComponentRegistrationMeta(label="test") replaces an existing registration. If the service is not already registered by the service being bootstrapped, the registration is silently dropped — omit meta to register a brand-new service instead.
Spies are fine — replacing behavior is not¶
Sometimes you need to verify that a dependency was called with the right arguments, or the right number of times. A spy (for example mocker.spy) is fine for this, because the real implementation still runs. What to avoid is replacing behavior with MagicMock, Mock, or patch — that disconnects the test from reality, so nothing production-like actually executes.
# Good — the spy wraps the real method, production code still runs
def test_service_is_queried_once(mocker, my_service):
spy = mocker.spy(my_service, "get_value")
compute(my_service)
spy.assert_called_once()
# Bad — MagicMock replaces behavior, nothing real runs
def test_service_is_queried_once():
my_service = MagicMock(spec=MyService)
compute(my_service)
my_service.get_value.assert_called_once()
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 torn down immediately afterwards.
It's an implementation of Flask's test client.
A simple example¶
Given the following endpoint in the acme_solution plugin:
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:
import json
def test_calculate_average_for_deals(
web_client, lime_app, prefix_url, save_lime_objects
):
company = lime_app.limetypes.company(name="acme")
deals = [
lime_app.limetypes.deal(name="deal1", value=42),
lime_app.limetypes.deal(name="deal2", value=128),
]
for deal in deals:
company.properties.deal.attach(deal)
company, *_ = save_lime_objects(company, *deals)
response = web_client.get(prefix_url(f"acme_solution/dealaverage/{company.id}"))
assert response.status_code == 200
assert json.loads(response.data) == {"average": 85.0}
Refactoring tests to use new fixtures¶
Many codebases still rely on old fixtures, primarily due to their inclusion in lime-project templates. However, these fixtures significantly degrade the performance of testing suites compared to the newer ones. It's recommended that whenever a project with old fixtures is encountered, the test files should be rewritten to use the new ones. Here is a short guide on how to act when working on refactoring the test files.
- Find the
conftest.pyfile and look if old fixtures are used. - Replace the
limetypesfixture with code below (if you use something else than custom_dsl.dsl, use what you had before).
@pytest.fixture(scope="session")
def lime_types():
return lime_type.create_limetypes_from_dsl(custom_dsl.dsl)
- Remove the
limeappfixture in order to use the framework'slime_app. - Remove the
databasefixture (the framework's fixture of the same name will be used automatically). - Replace all occurrences of
limeappwithlime_app. - Replace all occurrences of
webapptoweb_client(exclude the changes which may accidentally be done topoetry.lock). - Run all tests and make adjustments if anything breaks.
Tips and tricks¶
Endpoint tests gives an error when no coworker is set for app¶
This is caused because in the DSL, the coworker does not have a label user, which makes it impossible to read the
attribute lime_app.coworker. The DSL file should be modified with the proper user label set.
If the problem still exists, it may be because the limepkg-dsl-extract overrides the label property. The fix for
that is currently in progress and the documentation will be updated after the proper release.
Adding a specific type of user to app.user¶
If you need a lime_app with specific app.user for some reason, maybe special functionality for a portaluser you can
create a separate lime_app instance for that matter.
You can achieve that by using lime_test.app_create_app and passing the created user as active_user argument.
Example:
import lime_test
@pytest.fixture
def lime_app_portal_user(database, lime_types, create_user, monkeypatch):
user: LimeUser = create_user(
username="apiuser_portal@lime.tech",
password="password",
full_name="Portal User",
_return_entity=True
)
app = lime_test.app.create_app(
monkeypatch=monkeypatch,
database=database,
limetypes=lime_types,
active_user=user,
)
return app
The tests take a long time to run¶
This may happen due to a bug in lime-crm tests. This bug is solved now, upgrade to minimum 2.936.2 CRM version to solve it.