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 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
/smeedirectory which will house two new Python files:
- The container will be built using Docker and Docker Compose.
helloworld.pyfile will simply print a message (“Hello, Python user!”).
test.pyfile 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 folder contains two new Python files:
test.py. Now we need to describe the the contents of each 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
# 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"]
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
version: '3.9' # Define services services: container: container_name: first_container build: context: . dockerfile: dockerfile
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()
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
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
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.
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)