Skip to content

๐Ÿ”ง Testing

Panini testing is possible with various testing frameworks. Here we will show testing based on pytest framework.

Using TestClient

Import TestClient.

Create a TestClient using pytest.fixture by passing it to the function that runs panini.

TestClient object .start() will start the panini app for testing.

Create functions with a name that starts with test_ (this is standard pytest convention).

Use the TestClient object for NATS communication the same way you would do with panini

Panini uses nats-python synchronous NATS client for testing

Write simple assert statements with the standard Python expressions that you need to check (pytest standard).

import pytest
from panini import app as panini_app
from panini.test_client import TestClient

def run_panini():
    app = panini_app.App(
        service_name="test",
        host="127.0.0.1",
        port=4222
    )

    @app.listen("main.subject")
    def main_subject(msg):
        return {"message": "Hello World!"}

    app.start()

@pytest.fixture
def client():
    client = TestClient(run_panini).start()
    yield client
    client.stop()

def test_main_subject(client):
    response = client.request("main.subject", {})
    assert response["message"] == "Hello World!"

๐Ÿ”ฅ

Notice that the testing functions are normal def, not async def. Also, the calls to the client are also normal calls, not using await. This allows you to use pytest directly without complications.

๐Ÿ› 

Notice that panini TestClient will run a panini app in a different process. The Windows and Mac platforms have limitations for transferring objects to a different process. So we have to use run_panini function that will implement or import our app, and we must use fixtures to setup TestClient.

๐Ÿ”‘

Notice that if you use pytest.fixture without scope the panini App will setup and teardown for each test. If you don't want this - please use pytest.fixture(scope="module)

Separating tests

In a real application, you mostly would have your tests in a different file.

Your Panini app can also be in different files or modules.

Panini app file

Let's say you have a file main.py with your Panini app:

from panini import app as panini_app

app = panini_app.App(
    service_name="test",
    host="127.0.0.1",
    port=4222
)

@app.listen("main.subject")
def main_subject(msg):
    return {"message": "Hello World!"}

if __name__ == '__main__':
    app.start()

Testing file

Then you could have a file test_main.py with your tests, and import your app from the main module (main.py):

import pytest
from panini.test_client import TestClient

def run_panini():
    from .main import app
    app.start()

@pytest.fixture
def client():
    client = TestClient(run_panini).start()
    yield client
    client.stop()

def test_main_subject(client):
    response = client.request("main.subject", {})
    assert response["message"] == "Hello World!"

Advanced testing

Now, let's dig into more details to see how to test different parts.

โš™

Under the hood, TestClient will run your panini application inside the different process. And will communicate with it only using NATS messaging. This is how you can simulate your panini app activity.

๐Ÿ› 

TestClient will run 2 NATS clients for testing (one for sending, and one for listening). The sending NATS client is in the main Thread, while the listening client can be in a separate thread.

Error testing

Let's say we need to check that the subject always requires an authorization.

from panini import app as panini_app

app = panini_app.App(
    service_name="test",
    host="127.0.0.1",
    port=4222
)

@app.listen("subject.with.authorization")
def get_secret(msg):
    if "authorization" not in msg.data:
        raise ValueError("You need to be authorized, to get the secret data!")
    return {"secret": "some meaningful data"}

if __name__ == '__main__':
    app.start()

Let's create a test for this example

import pytest
from panini.test_client import TestClient

def run_panini():
    from main import app
    app.start()

@pytest.fixture(scope="module")
def client():
    client = TestClient(run_panini).start()
    yield client
    client.stop()

def test_secret_subject(client):
    response = client.request("subject.with.authorization", {"authorization": "token"})
    assert response["secret"] == "some meaningful data"

def test_unauthorized_secret_subject(client):
    with pytest.raises(OSError):
        client.request("subject.with.authorization", {})

We can use this method of testing, but if we take a better look - it's strange that we are getting a OSError instead of a ValueError Also, the tests will run for an extremely long time because of nats-timeout.

The reason for this is that TestClient is a separate NATS service, that performs a simple request in our case. It does not get the response, because of an error on the application side.

But still, let's modify a bit our functions, to get the more obvious result:

@app.listen("subject.with.authorization")
def get_secret(msg):
    if "authorization" not in msg.data:
        return {"success": False, "message": "You have to be authorized, to get the secret data!"}
    return {"success": True, "secret": "some meaningful data"}

And the test function will look like this:

def test_unauthorized_secret_subject(client):
    response = client.request("subject.with.authorization", {})
    assert response["success"] is False
    assert response["message"] == "You have to be authorized, to get the secret data!"

So we get the results faster and in a more obvious way.

Testing microservice with dependencies

Let's say we want to test an authorization application, which depends on another panini application, that fetches data from a DB.

For our testing purpose, we don't want to create & support the database, because our application does not have a direct dependency on DB.

Using TestClient we can mock the communication between those applications.

Let's dive into the code:

from panini import app as panini_app

app = panini_app.App(
    service_name="authorization_microservice",
    host="127.0.0.1",
    port=4222
)

@app.listen("get.token")
async def get_token(msg):
    if "email" not in msg.data or "password" not in msg.data:
        return {"message": "You should provide email & password to authorize"}
    response = await app.request("db.authorization", {"email": msg.data["email"], "password": msg.data["password"]})
    return {"token": response["db_token"]}

if __name__ == '__main__':
    app.start()

And then, apply mocking using TestClient object .listen function.

import pytest
from panini.test_client import TestClient

def run_panini():
    from main import app
    app.start()

fake_db = {"test_email": "test_token"}

@pytest.fixture()
def client():
    client = TestClient(run_panini)

    @client.listen("db.authorization")  # mock db.authorization subject
    def authorize(msg):
        return {"db_token": fake_db[msg.data["email"]]}  # provide testing data

    client.start()
    yield client
    client.stop()

def test_get_token(client):
    response = client.request("get.token", {"email": "test_email", "password": "test_password"})
    assert response["token"] == "test_token"

๐Ÿ’ก

Notice that you have to call @client.listen decorator before client.start()

TestClient publish & wait

We strongly recommend using other tools for testing (like client.request & client.listen only), but sometimes we need a specific client.publish & client.wait to test the panini application.

๐Ÿ“–

client.wait is used to manually wait for @client.listen callback to be called. It was previously done automatically

โ˜

You should specify client.start(do_always_listen=False) to be able to use client.wait

Example for client.publish & client.wait:

from panini import app as panini_app

app = panini_app.App(
    service_name="test",
    host="127.0.0.1",
    port=4222
)

@app.listen("start")
async def start(msg):
    await app.publish("app.started", {"success": True})

if __name__ == '__main__':
    app.start()

And testing file:

import pytest
from panini.test_client import TestClient

def run_panini():
    from main import app
    app.start()

is_app_started = False

@pytest.fixture()
def client():
    client = TestClient(run_panini)

    @client.listen("app.started")
    def app_started(msg):
        global is_app_started
        is_app_started = True

    client.start(do_always_listen=False)
    yield client
    client.stop()

def test_get_token(client):
    assert is_app_started is False
    client.publish("start", {})
    client.wait(1)  # wait for @client.listen callback to work
    assert is_app_started is True

๐Ÿ’ก

Use client.publish only, when @client.listen returns nothing or subject. But you will need another subject, to check, if the call was successful.

Example for client.wait:

Let's say you want to test @app.task job:

from panini import app as panini_app

app = panini_app.App(
    service_name="test",
    host="127.0.0.1",
    port=4222
)

@app.task()
async def task():
    await app.publish("task.job", {"job": "test"})

if __name__ == '__main__':
    app.start()

You can use @client.listen and client.wait for this:

import pytest
from panini.test_client import TestClient

def run_panini():
    from main import app
    app.start()

task_data = None

@pytest.fixture()
def client():
    client = TestClient(run_panini)

    @client.listen("task.job")
    def app_started(msg):
        global task_data
        task_data = msg.data["job"]

    client.start(do_always_listen=False)
    yield client
    client.stop()

def test_get_token(client):
    assert task_data is None
    client.wait(1)  # wait for @client.listen callback to work
    assert task_data == "test"