Unit Tests with Pytest

In this article I show how to write unit tests with Pytest. I show the parametrized tests, the conftest and the fixtures.

Content:

  • Pytest setup
  • Unit Test structure : given when then
  • Parametrized Tests
  • Exceptions Assertions
  • Conftest & Fixtures
  • Mocks

For more details, watch this video.

All the code of article is available in this repository.

Pytest setup

To write unit test, there is the already included module unittest in Python. But I can also use Pytest, which has a lot more features and the tests are more compact.

Pytest is not included in Python by default. So let’s add the dependency with Poetry.

poetry add -D pytest

I’ve used the -D option to add the dependency as a development dependency. I don’t need my test to run my application, just to develop it.

With Pytest, the tests are located in a separated folder, tests. Then, I will try to reproduce the same folder structure as in the production code, but with the test files. With unittest, I can have the test files next to the production files. I think this has some inconvenience, as I can’t deploy my project without the tests, or it will be difficult.

Pytest folder structure
Pytest folder structure

Unit Test structure : given when then

Let’s create my first test. I will test the logic of the password validation. I have to test which password is acceptable and which is not.

I will use the given when then methodology to write tests. In the given block, I initialize the data. In the when block, I call the methods to be tested. And in the then block, I validate the returned values. This helps me a lot to have tests that can be read easily.

def test_validate_password():
    # given
    schema = UserCreationSchema()
    data = {
        "username": "sergio",
        "password": "Abcde12345",
        "email": "sergio@sergio.com"
    }

    # when
    user = schema.load(data)

    # then
    assert user is not None
    assert user.username == data["username"]
    assert user.password == data["password"]
    assert user.email == data["email"]

Parametrized Tests

That’s fine. But I should test some more passwords to validate the method. And I don’t want to write the same test again and again to test different passwords. For that, I will use parameterized tests.

@pytest.mark.parametrize(
    "password,valid",
    [
        ("Abcde12345", True),
        ("12345Abcde", True),
        ("Abcdefghij", True),
        ("abcde12345", False),
        ("ABCDE12345", False),
        ("12345678", False),
        ("Abc123", False),
    ]
)
def test_validate_password(password, valid):
    # given
    schema = UserCreationSchema()
    data = {
        "username": "sergio",
        "password": password,
        "email": "sergio@sergio.com"
    }

    # when
    try:
        user = schema.load(data)
        assert valid

        # then
        assert user is not None
        assert user.username == data["username"]
        assert user.password == password
        assert user.email == data["email"]
    except ValidationError:
        assert not valid

With the parameterized tests, I indicate as input, the values which will change in the decorator definition. I indicate the name of the input variables and all the desired different values to test.

Exceptions Assertions

Sometimes, I also want to test that my application is returning an expected exception. I must ensure that when something goes wrong, my application is prepared for that.

def test_missing_fields():
    # given
    schema = UserCreationSchema()
    data = {
        "username": "sergio",
        "password": "Abcde12345",
    }

    # when / then
    with pytest.raises(ValidationError):
        schema.load(data)

Here, I create a context where I expect an exception to be raised. If the exception isn’t raised in this context, my test will be considered as failed.

Conftest & Fixtures

Let’s go now with the conftest files. The conftest files contain some initialization processes. Those initialization processes are grouped into fixtures. Within a Flask application, the main fixture to be created is a flask application.

@pytest.fixture(scope="session")
def flask_app():
    app = create_app() # create the Flask application

    client = app.test_client() # create the test client

    ctx = app.test_request_context() # create an available context
    ctx.push() # make the context available

    yield client

    ctx.pop() # clean up the context

I define the fixture to be scoped by the test session. This means that this fixture will be loaded only once per test session. Running all the tests in my application, will make to call this fixture only once. I can scope a fixture by function, which is the default behavior, by class, by module, by package, or by session.

Then, I create my Flask application as done in the main file. Next, I create a test client. This client will be used to perform requests for my unit test against my Flask application, against my production code. Then, I create a context and push it, making it available. This way, I can use a database connection in this context.

And here comes the yield. The fixture will stop in the yield, then run all the tests upon the configured scope, and when finished, continue with the last line, which will close the context.

The yield will separate the fixture into a setup and the teardown block. I can initialize my tests before the yield and destroy whatever I want after the yield.

Okay, i’ve created my first fixture. Nevertheless, most of my tests will need the database. And sometimes, they also need that the database already contains some data. I will create two more fixtures: one to set up the database, and another one to feed the database with fake data.

@pytest.fixture(scope="session")
def app_with_db(flask_app):
    db.create_all()

    yield flask_app

    db.session.commit()
    db.drop_all()


@pytest.fixture
def app_with_data(app_with_db):
    country = Country()
    country.code = "FR"
    country.name = "France"
    db.session.add(country)

    user = User()
    user.username = "sergio"
    user.password = generate_password_hash("pass")
    user.email = "sergio@mail.com"
    db.session.add(user)

    db.session.commit()

    yield app_with_db

    db.session.execute(delete(User))
    db.session.execute(delete(Country))
    db.session.commit()

As input parameters of my fixtures, I have the name of other fixtures. This will create some hierarchy. The flask fixture will be the first fixture. Then, the database setup. And finally, the fake data. And when the tests finish, the teardown will be called in a reverse way: the fake data, then the database setup, and finally the flask application. Let’s see now how to use those fixtures.

def test_auth_no_user(app_with_db):
    # when
    response = app_with_db.post(
        url_for("auth.login"),
        json={
            "username": "sergio",
            "password": "pass"
        }
    )

    # then
    assert response.status_code == 404

In the previous test, I call the fixture where i’ve configured an empty database. As in the yield statement I’ve always returned the flask client, this is what I used to request my endpoints.

To identify an endpoint, I use url_for, which combines the Blueprint name and the method name of an endpoint. Then, I specify the content to send to the endpoint. In this case, I send JSON data. If I need to send text data, i just change it by data. If I need to send some headers, I just add the headers dictionary.

Mocks

Sometimes, I don’t want to invoke a method used in the production code. It may be a request to an external service. It may be to read some user’s input. It’s usually about methods that depend on an external application.

This time, instead of adding data to my database, I want to mock my database connection. I want that every time I call the database connector, it returns me a hard-coded result.

def fake_session(query):
    class FakeQuery:
        def all(self):
            return [{"id": 1, "name": "First"}, {"id": 2, "name": "Second"}]
    return FakeQuery()


@mock.patch("backend.db.session.scalars", new=lambda query: fake_session(query))
def test_get_all_groups(flask_app):
    # when
    response = flask_app.get(url_for("groups.get_all_groups"))

    # then
    assert response.status_code == 200

    data = response.json
    assert len(data) == 2

With the decorator @mock.patch, I indicate which is the method I want to mock. Instead of calling the real database connector, it will now call the method fake_session I’ve created. Inside, I returned my hard-coded results. Let’s see another example where I can ensure that my mocked method is really used.

def test_get_all_groups_validate(flask_app):
    with mock.patch("backend.db.session.scalars") as mocked_session:
        # given
        mocked_session.return_value = fake_session(None)

        # when
        response = flask_app.get(url_for("groups.get_all_groups"))

        data = response.json
        assert len(data) == 2
        mocked_session.assert_called_once()

This time, I’ve created the context where my mock method will be used. I configure the returned value to be used. And at the end, i ensured that my mock was called once. If the method was called more or less that once, the test will be considered as failed.

Conclusion

  • I’ve created a separated folder, test, where we’ll be located all the unit tests.
  • I’ve written my unit test using the given when then method.
  • When I have tests where I want to use multiple inputs, I’ve used the Parameterizered Tests.
  • I’ve used a context when I want to ensure an exception is raised.
  • I’ve created a conftest with fixtures, which contains the setup of my tests and the teardown operations.
  • And finally, I’ve used the mocks to simulate the behavior of an unwanted method.

References

Repository

My New ebook, How to Master Git With 20 Commands, is available now.

Leave a comment

A WordPress.com Website.