A simple MyQSL query

In one of my projects I had to add a function to get a number from a MySQL table, increase it, add it back and return the number. This is more or less it:

import pymysql

def get_obsnumber(conf):
    '''Get a number from the database'''
    conn = pymysql.connect(host=conf['host'],
                           port=conf['port'],
                           user=conf['user'],
                           password=conf['password'],
                           database=conf['database'])
    cursor = conn.cursor()
    # get the number
    cursor.execute('SELECT MAX(obsnum) FROM my_table')
    obsnumber = cursor.fetchone()[0]
    # increase it
    obsnumber += 1
    # set it back
    cursor.execute('INSERT INTO my_table (obsnum) values (%s)', obsnumber)
    # commit and close everything
    conn.commit()
    cursor.close()
    conn.close()

    return obsnumber

I was given an example of the code, so I knew what I had to implement, but first I had to figure out how to test it.

How hard can it be?

For the tests I needed to start a MySQL server, add the my_table table and some row and then at the end throw away the server.

It sounds easy, doesn’t it?

Unfortunately I never used MySQL before, so the first thing to do was to start searching online for tutorials and possible solutions.

Create a temporary MySQL server.

At first I decided to give a go at pytest-mysql: it seems easy enough to use and, being a pytest plugin, would fit into my workflow. The package provides a couple of fixtures: i) mysql_proc starts a MySQL server when first used and tears it down at the end of the test session and ii) mysql creates a database that is thrown away at the end of the each test function. The test functions looks like this:

def test_get_obsnumber(mysql_proc, mysql):
    '''try to get the observation number'''
    # connect to the database
    # add my_table and one entry with obsnum = 42
    # create a configuration object
    conf = ...

    obs_num = get_obsnumber(conf)
    assert obs_num == 43

With a test function ready, I was ready to run pytest. Of course I had to install a couple of MySQL administration packages to be able to start the database server. Then I hit a wall: the plugin doesn’t support MySQL v5.7 1 and my desktop computer has exactly that version. On an other computer I have MariaDB v10.2.10, a drop-in replacement for MySQL. I soon abandoned hope: MariaDB and MySQL administration interfaces are likely subtly different and pytest-mysql cannot properly set user names when creating a new database.

During my searches I stumbled across my_virtualenv, a nice bash script that sets up a sort of virtual environment with a temporary MySQL server. It did work very well on my system with MariaDB, but failed on MySQL because I have v5.7.

The docker way

So I needed to find a way to run a MySQL server that would work at least of my systems, independently of the MySQL/MariaDB version, or even without them. Then I realised that I already had the answer: Docker.

I found the pytest-docker plugin and some examples on how to use mysql docker containers and docker compose.

So I sat down and started to explore how to create docker containers, connect to the MySQL servers running in them and create tables and add data. Once I felt confident, I wrote the test fixtures and functions that I needed. They looked roughly like the following code:

import yaml

@pytest.fixture(scope='session')
def docker_compose_file(tmpdir_factory):
    '''Temporary docker compose file'''
    compose_file = tmpdir_factory.mktemp('docker_files').join('docker-compose.yml')

    environment_dict = dict(MYSQL_ALLOW_EMPTY_PASSWORD='no',
                            MYSQL_ROOT_PASSWORD='test',
                            MYSQL_DATABASE='test_db',
                            MYSQL_USER
                            MYSQL_USER='mysql_user',
                            MYSQL_PASSWORD='mysql_password')
    compose_conf = {'services': {'mysql': {'image': 'mysql',
                                           'container_name': 'test_suite',
                                           'environment': environment_dict}
                                  }
                     }

    with compose_file.open('w') as f:
        yaml.dump(compose_conf, stream=f)

    return compose_file.strpath


@pytest.fixture
def mysql_table(docker_ip, docker_services):
    '''Create the database table, fill it and drop it after the test is done'''
    # connect to the database
    # add my_table and one entry with obsnum = 42

    yield

    # drop my_table


def test_get_obsnumber(docker_ip, mysql_table):
    '''try to get the observation number'''
    # create a configuration object
    conf = ...  # this also contain the docker_ip

    obs_num = get_obsnumber(conf)
    assert obs_num == 43

Now that I had a test, I could start implementing the new function.

One more step

The requirement had been satisfied, but I still had a problem:

How to test my code on my machines?

Of course the answer is:

start the MySQL docker container and fill the database

I could do it my hand every time that I need it. But I don’t like this kind of things: it’s tedious and very error prone.

So I decided to invest some more time and, with the help of docker-py, I added a set of commands to integrate the setup and teardown of a MySQL docker container and the creation of my_table with the rest of the project. I also dropped pytest-docker and rewrote the docker and MySQL fixtures using those new features: this way I have more flexibility and control over my tests and less code repetitions.

So now, when I want to execute the code on my computer I run

my_project docker_mysql up

to start the docker image and

my_project run

to execute the main part. When I’m done I can get rid of the docker container with

my_project docker_mysql down

Footnotes

1mysql_install_db has been deprecated in favour of mysqld --initialize/--initialize-insecure