Introduction
Hi, friend! Welcome back, and thank you for learning along with me. In our last tutorial, we learned how to build a Docker container and simultaneously run a Bash file within the container to iteratively call a Python script. But what if the Python script is some sort of analysis tool? Following good programming etiquette, we should also write a unit test for our analysis functions. Today, we’ll see how to merge these two concepts: Docker containers and programming unit tests.
The Setup
The main idea behind the setup we’ll be building is this:
- Build a single Docker container; the container’s image should have Python installed.
- The container will have a
/smee
directory which will house two new Python files:helloworld.py
andtest.py
. - The container will be built using Docker and Docker Compose.
- The
helloworld.py
file will simply print a message (“Hello, Python user!”). - The
test.py
file will test that the output from helloworld.py is equal to itself.
Conceptually, the logic is pretty simple, but the Python files can be a bit nonintuitive. In the interest of transparency, our folder hierarchy will look like something like this. We have a folder (“docker_intro”) housing a Docker file (dockerfile
), a Compose file (docker-compose.yml
), and a subdirectory titled smee
. The smee
folder contains two new Python files: helloworld.py
and test.py
. Now we need to describe the the contents of each file!
Docker file
Since we still need Python within our container’s image, we’ll keep the previous Python image, 3.10-slim-bullseye
build. The AS base
portion of the image call defines our image as part of the build
stage. In doing so, base
becomes a label we can use later in the Docker file to call and work from.
We also need to copy files from our host directory into the container. So, we transition into the work directory /smee
in our container and copy everything from our current host folder into the working directory. Finally, we define our base stage as our test stage. Within the test stage of our Docker file, we issue a command within the container to run the test.py
file located within the /smee
directory using the python
command.
# Use the Python image, define as the "base" stage
FROM python:3.10-slim-bullseye AS base
# Change to container directory. Copy over files.
WORKDIR /smee
COPY . .
# Define "base" stage as "test" stage. Run test.py
FROM base as test
CMD ["python", "./smee/test.py"]
Compose file
Our Compose file is pretty similar to our previous tutorials. We’re running Compose version 3.9, and we only have a single service, container
. The container service builds a container called first_container
using our Docker file titled dockerfile
.
version: '3.9'
# Define services
services:
container:
container_name: first_container
build:
context: .
dockerfile: dockerfile
Python files: helloworld.py
As we noted earlier, the helloworld.py
file is very basic. We define a function, hello_world
, that simply returns a string, “Hello, Python user!”. We then call hello_world
from our main function (main()
), defining the output of our first function to the variable message. A print statement ensures the hello_world
function is being called properly. Finally, we set our main
function to run automatically when the script is executed.
import sys
def hello_world():
return "Hello, Python user!"
def main(n):
message = hello_world()
print(message)
if __name__ == '__main__':
main()
Python files: test.py
The test.py
script is a bit more complex, but it’s functionality is straight forward. We begin by importing two things: (1) the unit test module (named unittest
), and (2) our hello_world
function from our helloworld.py
script. The former lets us access Python’s suite of unit test capabilities, while the latter allows us to access the function that generates our message.
We then define a class to test our message — we title this TestMessage
, which ingests the TestCase
method from the Python’s unittest
module. This is the basic test class. Within the test class, we define our function to test the message itself; we call this test_message
.
This is the actual test we wanted to run within our container. We define a message
variable, which will store the output of the hello_world
function from our helloworld
script. We then ask Python to assert if the string contained in our message
variable is equal to the same phrase we asked it to print, “Hello, Python user!”. If the two messages are equal, the test will pass. Similar to our other Python script, we then set the unit test to run automatically when our test.py
script is run.
import unittest
from helloworld import hello_world
class TestMessage(unittest.TestCase):
"""
Basic test class
"""
def test_message(self):
"""
The actual test.
"""
message = hello_world()
self.assertEqual(message,"Hello, Python user!")
if __name__ == '__main__':
unittest.main()
Putting it all together: Docker
So now we’ve defined our two Python files and our Docker file. Similar to our previous tutorial on building Docker containers in VS Code, we’ll tell Docker to build our container. While doing so, however, we’re doing something we haven’t done before: we’re targeting a specific stage of our build using the --target
flag. Specifically, we’re asking Docker to build the test
stage of our build, but the test
stage is built on our base
stage, which includes the Python image. We’re also giving the resulting container a label (or tag) of test:v0
with the -t
flag.
vscode ➜ /com.docker $ docker build --target test -t "test:v0" .
[+] Building 0.6s (8/8) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 38B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/python:3.10-slim-bullseye 0.5s
=> [base 1/3] FROM docker.io/library/python:3.10-slim-bullseye@sha256:ca78039cbd3772addb9179953bbf8fe71b50d4824b192e901d312720f5902b22 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 321B 0.0s
=> CACHED [base 2/3] WORKDIR /semm 0.0s
=> CACHED [base 3/3] COPY . . 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:6ab866cad69a8ad4e2e86177511938b7ca7f7a0d0d7c3ea77e36799eabf29cbc 0.0s
=> => naming to docker.io/library/test:v0
Perfect! So now we have a container, and we can validate the container’s existence with the docker container ls
or docker ps
command. More importantly, we can interactively run the container we just built using docker run -it test:v0
. WHen we do this, we get an output resembling something we’d expect to see from a Python output during unit tests:
vscode ➜ /com.docker $ docker run -it test:v0
.
---------------------------------------------------
Ran 1 test in 0.000s
OK
Neat! That works great — our unit test is called correctly and our test passes. Next, let’s try to build the container with Compose. We do this by simply calling the up command in Compose: docker-compose run --build
. Now let’s look at the result:
# Run Compose, ensure container is rebuilt
vscode ➜ /com.docker $ docker-compose up --build
Building container
[+] Building 1.4s (8/8) FINISHED
=> [internal] load build definition from dockerfile 0.0s
=> => transferring dockerfile: 197B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/python:3.10-slim-bullseye 1.2s
=> [base 1/3] FROM docker.io/library/python:3.10-slim-bullseye@sha256:ca78039cbd3772addb9179953bbf8fe71b50d4824b192e901d312720f5902b22 0.0s
=> [internal] load build context 0.1s
=> => transferring context: 2.88kB 0.0s
=> CACHED [base 2/3] WORKDIR /semm 0.0s
=> [base 3/3] COPY . . 0.1s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:743446fbb44d35a4af51dbb8fa13418fdeb72bf3fd0567715ab4c21cdda48b22 0.0s
=> => naming to docker.io/library/comdocker_container 0.0s
Creating first_container ... done
Attaching to first_container
first_container | .
first_container | ----------------------------------------------------------------------
first_container | Ran 1 test in 0.000s
first_container |
first_container | OK
first_container exited with code 0
Fantastic! Not only did our container build, but during the build our Python scripts ran as requested without us having to issue a specific command in Compose. Now that we are able to make unit tests that automatically run for our Python scripts, we can begin to ensure the robustness of our code, and thus our application as a whole.
Conclusion
In this tutorial, we’ve constructed to basic Python scripts — one Python script to print a “Hello world” message, and the other to call that script and run a test on its output. Then we built this capability into a single container. We built and ran the Docker container in both Docker and Compose, demonstrating that the unit tests ran successfully in both cases. In doing so, we gave ourselves a huge hand up in making our code more robust!
Next, join me for a quick demo of how to install a Conda environment inside a Docker container! Until then, thanks again for learning with me — we’re all in this together! If you’re enjoying the content, please feel free to Like, comment, and subscribe!
Get new content delivered directly to your inbox.
(Header image: Wave border by rawpixel.com)