Before listing the commands we use, we'd like to give a brief introduction of Docker concepts.
- Docker: Docker is a set of platform as a service (PaaS) products that use OS-level virtualization to deliver software in packages called containers. In short, it's a platform that provide you with containerized/isolated software/system.
- Docker Image: Image is a static, immutable template that defines the behavior of your software/system. Most of the time, we 'build' an image.
- Docker Container: Container is a runtime instance. Most of the time, we 'run' or 'start' a container.
- Dockerhub/GHCR: Dockerhub/GitHub Container repository is a place to store your Docker images just like Github stores your code.
Here are the commands (signature + example) you will use often:
- image-related
# Build an image from a local Dockerfile
# <path> is where the Dockerfile stays in
$ docker build -t <image_name>:<tag> <path>
$ docker build -t rstudio-notebook:myTest ./images/rstudio-notebook
# List images
$ docker images
# Pull an image from GHCR
$ docker pull <image_name>:<tag>
$ docker pull ghcr.io/ucsd-ets/rstudio-notebook:2023.2-d3e5619
# Push an local image to GHCR
$ docker push <image_name>:<tag>
$ docker push ghcr.io/ucsd-ets/rstudio-notebook:2023.2-d3e5619
- container-related
# Run a (new) container
$ docker run -d -p <host port>:<container port> <image_name>:<tag>
$ docker run -d -p 80:80 rstudio-notebook:myTest
# Start a stopped (not active) container
$ docker start <container ID/Name>
$ docker start 87120d0aefb0 # this is container ID
# Stop a running container
$ docker stop <container ID/Name>
$ docker stop 87120d0aefb0 # this is container ID
# List info of all (running or not) containers
$ docker ps -a
# Remove a container
$ docker rm <image_name>:<tag>
$ docker rm rstudio-notebook:myTest
Alternatively, you may use Docker Desktop to save you from typing all these commands. But keep in mind that some options are not supported or hard to find in the UI.
Official Docs is the place with most comprehensive information.
If you are familiar with the concepts already, this cheatsheet is also a great reference.
If you want to run any Python code from a project locally, it's always a good habit to create a virtual environment and install all required packages there. You can either use conda environment or python venv. They have some subtle difference but are functionally the same for our purpose
# create a Python venv locally (root of this repo)
$ python -m venv .
# Each time: activate the venv
$ source bin/activate
# create a conda environment
$ conda create --name <name you choose>
# Each time: activate the environment
$ conda activate <name you choose>
After you create the virtual environment, install Python packages used in this repo:
# assume you are in the project root
$ pip install -r scripts/requirements.txt
- Clone the repository and make a new branch:
git checkout -b dev_***_notebook
. - Make a new directory under
./images
with the name being the base-name of the new image. For example, forghcr.io/ucsd-ets/scipy-ml-notebook
, make a new directory./images/scipy-ml-notebook
. - Modify
./images/spec.yml
. Add a new key under/images
with the new base-name. Fill in the fullimage_name
, its upstream image base-namedepend_on
. To understand what other fields do, please refer to our images.md - Under the new directory, add the Dockerfile and the necessary bits for building the container. For how to write a Dockerfile, please refer to the official doc or other tutorials.
- Follow the instructions in images.md
- Clone the repository and make a new branch:
$ git checkout -b dev_<image name>
. - Under
image/
, find the respective directory for the image you want to change. A manifest of a recent build can be found in the wiki section. An example of a manifest is here. - Follow the instructions in images.md. This time step #1 should be editing the existing Dockerfile.
- Activate the virtual environment
$ source bin/activate
$ cd images
$ export TEST_IMAGE=MYIMAGE
replaceMYIMAGE
with your locally built image name$ pytest tests_common
as an example
Side Note: For test_wiki.py
to work, please create 3 folders logs/
, manifests/
, and wiki/
under project root. In addition, please copy this Home_original.md into wiki/
.
- (Only do this once) Unzip the
test_resource.zip
and put the 2 folderswiki/
andartifacts/
in the root directory - Activate the virtual environment
$ source bin/activate
$ pytest tests/test_<module name>.py
to test a individual module- OR
$ pytest tests
to test all modules
The purpose of a unit test is to ensure the correctness of the testing component/module/function/class ONLY. This means all the helper function or API calls within the testing target should not complicate your unit test and are assumed to be functionally correct. This is opposed to an integration test, which is usually created after all the unit tests and aims to ensure every module work together correctly.
Unit tests are arguably the first and necessary step in industry standard of software testing. Again, we strongly
recommend you to write the most basic unit test at least in tests/
of you make any infrastructure change to scripts/
.
A unit test not only removes the dependency on some untested yet, potentially buggy components of your testing target, but also save the execution time of performing those API calls even if they have been thoroughly tested.
A concrete example can illustrate this better. Suppose you are testing a Media Player which supports input in the form of local image and video files, YouTube links, etc. The actual reading and rendering of the contents of each type of media have been tested by other senior developers and your job is just to make sure the Player recognizes and invokes the matching function call correctly. E.g. some_youtube_video.mp4
should not be treated as a YouTube link. You created several inputs for each type, but you don't really want to call the actual reading-playing functions, which are definitely an expensive operation. Instead, you only want to know if they are called at the correct time. To achieve this, you need a way to know how many times each reading-playing function has been called.
Following the above example, the first thing you may think of is to attach some EventListener to the functions of interest. Congratulations, you are a good developer. But this is not a good testing practice because it unnecessarily
complicates the testing. Instead, we will replace all the actual components we assume are working with some no-op
duplicates of which we have access to the calling count. These duplicates are called mocks and are included in
unittest.mock
module in Python. If you would like a general introduction to it, you can read this blog. For more details, see the official doc.
I will briefly go over these 2 in case you haven't read the above blog. Essentially, MagicMock
is a mock object
whose behavior can be configured and calling information (more than call count) can be read.
And patch
is a decorator to tell you testing function that "please replace this actual function with a MagicMock
object."
One thing to notice is "which MagicMock
exactly to use" when you make the request to patch
. I found most example
code says "I will give it later in the function argument list.", like the following:
# code from the blog post linked above
@patch("bar.requests.get")
@patch("bar.requests.put")
def test_foo(self, mock_put, mock_get):
Bar.sync(id=42, query_first=False)
self.assertFalse(mock_get.called)
self.assertTrue(mock_put.called)
This has 2 limitations. First, all mock objects are passed in via the argument list, and if we need
a lot, we will have an undesirably long list. Secondly, you can see the order of mock objects should
be the reverse of the patch
decorator order, which is often a source of error. Thus, we suggest the
other approach that says "I will give you all the MagicMock
to the actual functions now."
# with my change
mock_get = MagicMock()
mock_put = MagicMock()
@patch("bar.requests.get", mock_get)
@patch("bar.requests.put", mock_put)
def test_foo(self):
Bar.sync(id=42, query_first=False)
self.assertFalse(mock_get.called)
self.assertTrue(mock_put.called)
Finally, let's look at the usage in our script tests. I think understanding test_docker_adapter.py::TestDocker::_tag_stable()
alone is enough for you to understand all the flexibility we need for MagicMock
configuration.
# test definition
def _tag_stable(self):
# to mock:
# 1. __docker_client.images.get()
# 2. img_obj.tag()
# 3. __docker_client.close()
# NOTE: need to mock an Image obj returned by images.get()
# and this mock_img_obj should be able to call tag()
mock_tag = MagicMock()
mock_img_obj = MagicMock(tag=mock_tag)
mock_get = MagicMock(return_value=mock_img_obj)
mock_images = MagicMock(get=mock_get)
mock_close = MagicMock()
@patch('scripts.docker_adapter.__docker_client', images=mock_images, close=mock_close)
def run_test(pos_arg):
return internal_docker.tag_stable(self.orig_images[0], self.tag_replace)
stable_name, result = run_test()
return (stable_name, result, mock_get, mock_tag, mock_close)
# run the test and check values
def test_tag_stable(self):
stable_name, result, mock_get, mock_tag, mock_close = self._tag_stable()
assert result == True, "prepull_images() failed somewhere"
assert stable_name == self.stable_fullnames[0], f"Stable name is wrong: {stable_name}"
assert mock_get.call_count == 1, mock_get.call_count
assert mock_tag.call_count == 1, mock_tag.call_count
# .args gives argument; .kwargs gives keyword arguments
arg_list = [arg.kwargs for arg in mock_tag.call_args_list]
assert arg_list == [{'repository': 'image_1', 'tag': '2099.1-stable'}], arg_list
assert mock_close.call_count == 1, mock_close.call_count
This example has several important points to help you become an insider.
- A
MagicMock
object can serve as a mock component of anotherMagicMock
object. This is often useful when we need to mock both the object and the function it calls insome_obj.some_f()
, like ourimg_obj.tag(repository=repo, tag=tag_replace)
- The most commonly config option is the
return_value
, which can also be set as anotherMagicMock
. - In addition to mock an object entirely, you can also mock only some functions it calls or some attributes it accesses. This can be easily done by passing in keyword arguments in the
patch
decorator. For example, ourscripts.docker_adapter.__docker_client
is of typedocker.DockerClient
which has attributesimages
and functionclose()
. - Besides
call_count
, we can check a lot more attributes of aMagicMock
object. Here we look at the call_args_list which gives a list of length n if the mock function has been called n times. To see what attributes are available, you can usemock_obj.__dir__()
to list them all and go to the official doc and search for their usage. - It's a good idea to separate test definition and test executioner. This can be particularly helpful if we want to test a function several times using different inputs. In our case, we can easily modify the code and test against multiple input like this:
def _tag_stable(self, orig_fullname: str, tag_replace: str):
# to mock:
# 1. __docker_client.images.get()
# 2. img_obj.tag()
# 3. __docker_client.close()
# NOTE: need to mock an Image obj returned by images.get()
# and this mock_img_obj should be able to call tag()
mock_tag = MagicMock()
mock_img_obj = MagicMock(tag=mock_tag)
mock_get = MagicMock(return_value=mock_img_obj)
mock_images = MagicMock(get=mock_get)
mock_close = MagicMock()
@patch('scripts.docker_adapter.__docker_client', images=mock_images, close=mock_close)
def run_test(pos_arg):
return internal_docker.tag_stable(orig_fullname, tag_replace)
stable_name, result = run_test()
return (stable_name, result, mock_get, mock_tag, mock_close)
def test_tag_stable1(self):
orig_fullname, tag_replace = "some_name", "latest"
stable_name, result, mock_get, mock_tag, mock_close = self._tag_stable()
def test_tag_stabl2(self):
orig_fullname, tag_replace = "other", "dev"
stable_name, result, mock_get, mock_tag, mock_close = self._tag_stable()
def test_tag_stable3(self):
orig_fullname, tag_replace = "unknown", "production"
stable_name, result, mock_get, mock_tag, mock_close = self._tag_stable()