Testing Services Before Deployment¶
Iteratively debugging a service by repeatedly deploying and calling entrypoints
can become a somewhat slow and tedious process, having to wait a short time
for every deploy. To help ensure that services are in
a working state before deployment, we recommend that you use an automated
testing framework. When creating a new project with mvi init
a test/
directory is created. It contains two files: base_functionality_test.py
that performes base tests for things that are necessary for these services
to work, and user_tests.py for the user to add their own tests.
Writing Tests¶
The tests use pytest which requires the
tests to lay in a folder called tests/, in modules that end with _test.py
and the names of the test functions should start with test_. Tests use the
assert
statement to see if code behaves as expected. Let’s use the hello
service that is generated with mvi init
for an example test
import logging
from mvi import service
from mvi.communication import notify, Severity
logger = logging.getLogger(__name__)
service.add_parameter("greeting_phrase", "Hello")
@service.entrypoint
def hello(name: str) -> str:
greeting_phrase = service.get_parameter("greeting_phrase")
if name == "World":
notify(
msg="Someone is trying to greet the World, too time consuming. Skipping!",
severity=Severity.WARNING,
emails=None,
)
return "Greeting failed"
logger.info(f"Greeting someone with the name: {name}")
return f"{greeting_phrase} {name}"
if __name__ == "__main__":
service.run()
The output of the hello()
function can be tested with the following function
# <project_path>/tests/user_tests
def test_hello():
# Test that service.hello("Bob") returns what is expected
assert service.hello("Bob") == "Hello Bob"
We can run the tests using:
>>> mvi test <project_path>
Which is equivalent to:
>>> cd <project_path>
>>> python3 -m pytest .
Functions That Do Not Work Locally¶
There are three ways to run an MVI service. We have seen how to deploy and run
your services to a manager using mvi deploy
many times now, but you could also
run it with python <project_path>/service.py
in which case it will be run as
it’s own webserver on localhost if service.py ends with
mvi.service.run()
. If it does not have mvi.service.run()
it will behave like a normal python module and the functions therein can be used
however you’d like. The if __name__ == "__main__"
clause that surrounds
mvi.service.run()
allows us to import service.py freely and use it’s
functions. However, the SDK will only have full functionality if run on a manager.
The table below shows the SDK functions that cannot run locally without a manager:
Functions |
Module |
Localhost |
Manager |
No |
No |
Yes |
|
No |
No |
Yes |
|
|
No |
Yes |
Yes |
|
No* |
Yes |
Yes |
*Will not repeat without starting the service with mvi.service.run()
but it won’t cause any errors
Mocking Functions to Run Services Locally¶
To be able to test services that depend on some of the functions listed above, we
have to use something called mocking, which means to templorarily replace code to
do something else. In the case of the hello service we would get an error if
notify()
was called. We could mock
notify()
to do nothing and then check if it was called
to test that a notification would be posted to the manager. In mvi.testing
there is a function patch()
that is included in MVI for
convenience from unittest.mock
and is used for mocking
# <project_path>/tests/user_tests
import service
from mvi.testing import patch
def test_notify_called():
# Test that notify() is called when running service.hello("World")
with patch("service.notify") as notify:
service.hello("World")
notify.assert_called()
We import service.py and patch()
and then in the test function
we create a session scope for the patch to revert the change back once we are done. In
patch()
we give a path to the function to mock, in this case the
notify()
function that we imported in service.py. We can
then call the hello()
function as normal and use the mock object
notify
to check if notify()
was called in
service.hello()
. The process for mocking store()
is
very similar, because neither store()
or
notify()
have any return values.
call_service()
on the other hand, will often have return values
and testing a service that uses it requires you to mock a problem specific return value.
Let’s take a look at an example service
import logging
from mvi import service
from mvi.communication import call_service
logger = logging.getLogger(__name__)
@service.entrypoint
def ping_service(service_name: str) -> str:
logger.info(f"Pinging: {service_name}")
response = call_service(
service_name=service_name,
entrypoint_name="ping",
)
return response
if __name__ == "__main__":
service.run()
This service has an entrypoint that pings another service to see if it is responding.
To create an automated test for this we need to mock call_service()
to return something that we would expect the ping()
entrypoint of the target
service to return in a real scenario
# <project_path>/tests/user_tests
import service
from mvi.testing import patch
def test_ping_service():
with patch("service.call_service") as call_service:
call_service.return_value = "Responding"
service.ping_service("test_service")
call_service.assert_called()
Here we mock call_service()
in the same way as we did with
notify()
but we add a return value to the mock object
call_service
to make it return that when it is called in
service.ping_service()
.
Note
The patch()
function returns an object of the
Mock
class from unittest.mock
.
More Testing¶
Refer to the pytest documentation and its
many addons if you would like to do more testing. To learn more about mocking and
the patch()
function, take a look at
unittest.mock.