Long term readers of this blog will know our devotion to using container-based technology, especially Docker, to significantly improve software quality through repeatable builds.
In the Autumn/fall of 2020, Microsoft introduced a Visual Studio Code (VSCode) extension Remote – Containers. With one quick stroke, this extension allows you to open a VSCode project within a Docker container.
Contents
Getting started with Dev Containers and Docker
There are several different approaches to using Dev Containers. In this post, we shall cover three options:
- Using an existing Docker image from Docker Hub
- Using a pre-build Microsoft container setup
- Using a custom Docker image based on a project specific Dockerfile
There are a couple of prerequisites:
- Docker is installed – Install Docker Engine
- The VSCode extension Remote – Containers is installed
Using an existing Docker image – TDD in C with Ceedling
Anyone using or experimenting with Test-Driven-Development in C will probably be aware of Ceedling, unity and CMock.
Whether or not you have Ceedling, or any dependents, such as Ruby, installed we can begin using Dev Container with an existing Dockerhub container image. Containerisation ensures we can quickly get up and running with Ceedling in a known environment. In true ‘Blue Peter‘ style, we happen to have a pre-built Ceedling based Docker image on Docker Hub.
- Create an empty folder, e.g.
$ mkdir ceedling_test
- In the new folder, create another folder called
.devcontainer
(note the preceding.
)
$ cd ceedling_test
$ mkdir .devcontainer
- In that new folder, add a file called
devcontainer.json
with the contents
{
"image": "feabhas/ceedling"
}
- Your project structure should now be:
.devcontainer
└── devcontainer.json
- Now open VSCode in the working directory
$ code .
- VScode will detect the Dev Container configuration file and ask if you want to reopen the folder in a container. Click
Reopen in Container
. - Open a terminal window within VSCode, and you will be presented with a shell prompt
#
. We are now running within a Docker container based on the imagefeabhas/ceedling
.
- Test the container, e.g.
# ceedling new test_project
Welcome to Ceedling!
create test_project/project.yml
Project 'test_project' created!
- Execute 'ceedling help' from test_project to view available test & build tasks
# cd test_project
# ceedling module:create[widget]
File src/widget.c created
File src/widget.h created
File test/test_widget.c created
Generate Complete
# ceedling test
Test 'test_widget.c'
--------------------
Generating runner for test_widget.c...
Compiling test_widget_runner.c...
Compiling test_widget.c...
Compiling unity.c...
Compiling widget.c...
Compiling cmock.c...
Linking test_widget.out...
Running test_widget.out...
--------------------
IGNORED TEST SUMMARY
--------------------
[test_widget.c]
Test: test_widget_NeedToImplement
At line (15): "Need to Implement widget"
--------------------
OVERALL TEST SUMMARY
--------------------
TESTED: 1
PASSED: 0
FAILED: 0
IGNORED: 1
After exiting VSCode, all files created will exist in your local file system. Reopening VSCode, you will once again be prompted to reopen in the container.
Using a pre-build Microsoft container environment – C++ and CMake
Starting with a classic “Hello World” project:
- Create an empty working directory
- In that directory, create a simple
main.cpp
, e.g.
#include <iostream>
int main()
{
std::cout << "Hello from Dev Containers\n";
}
- Open VSCode:
$ code .
- Open the VSCode command pallet (
F1
on all platforms) and select
Remote-Containers: Reopen In Container
- VSCode will present several pre-defined development container alternatives.
- Select
C++
; this will now reopen the current VSCode project in a container. - Next, you can select your preferred base Linux image – I have used the
ubuntu-20.04
base (it doesn’t matter for this example).
- The default Microsoft C++ container image has many additional packages suitable for hosted C/C++ development already installed in the container, e.g. (at the time of writing)
- GCC version 9.3.0 (Ubuntu 9.3.0-17ubuntu1~20.04)
- make version 3.16.3.
- git version 2.25.1
- etc.
- Next, add a simple
CMakeLists.txt
file
cmake_minimum_required(VERSION 3.16)
# set the project name
project(Test)
# add the executable
add_executable(App main.cpp)
- Finally, we can build our ‘application’. In a VSCode terminal window, build the CMake project, e.g.:
vscode ➜ /workspaces/ms-cpp $ mkdir build && cd build
mkdir: created directory 'build'
/workspaces/ms-cpp/build
vscode ➜ /workspaces/ms-cpp/build $ cmake ..
-- The C compiler identification is GNU 9.3.0
-- The CXX compiler identification is GNU 9.3.0
-- Check for working C compiler: /usr/bin/cc
-- Check for working C compiler: /usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /workspaces/ms-cpp/build
vscode ➜ /workspaces/ms-cpp/build $ make
Scanning dependencies of target App
[ 50%] Building CXX object CMakeFiles/App.dir/main.cpp.o
[100%] Linking CXX executable App
[100%] Built target App
vscode ➜ /workspaces/ms-cpp/build $ ./App
Hello from Dev Containers
So how does this all work? If you examine the project, you will see that a .devcontainer
folder is created along with two files:
devcontainer.json
Dockerfile
The devcontainer.json
file references the Dockerfile
defining the core Microsoft C++ Docker image. If you know Docker, then the files are pretty intuitive. We shall build on these in the next example.
Using a custom Docker image based on a local Dockerfile – GoogleTest, GoogleMock
The default Microsoft image does not include the capabilities for using Googletest, GoogleMock or an alternative build system, such as the Meson Build system.
We could builder our own Docker image and store this on Dockerhub, as shown previously. However, depending on your container requirements’ complexity, it can be easier to build on the base Microsoft Dockerfile and add the required packages.
Adding GoogleTest and GoogleMock
Using the previous “hello world” project, we want to write a simple test using GoogleTest.
The default Dockerfile has the following lines commented out:
# [Optional] Uncomment this section to install additional packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>
Usefully both GoogleTest and GoogleMock are standard ubuntu packages:
- libgtest-dev v1.10.0-2 on 20.04LTS (libgtest-dev)
- libgmock-dev v1.10.0-3 on 20.04LTS (libgmock-dev)
And can be installed using the apt package manager. As GoogleMock is dependent on GoogleTest, we only need to specify the package libgmock-dev
and apt
will also install libgtest-dev
. Uncomment and modify the lines in the Dockerfile to read:
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends libgmock-dev
Reopen the workspace in a container (you may be prompted to rebuild the container – if so, then select the rebuild option). We can quickly test if GoogleTest is installed by creating a simple test file test.cpp
:
#include "gtest/gtest.h"
TEST(setup_test_case, testWillFail)
{
ASSERT_EQ(42, 0);
}
and modifying our CMakeList.txt
to
cmake_minimum_required(VERSION 3.16)
# set the project name
project(Test)
enable_testing()
find_package(GTest REQUIRED)
# add the executable
add_executable(gtest_test test.cpp)
target_link_libraries(gtest_test GTest::GTest GTest::Main)
add_test(FailingTest gtest_test)
Build and test with CMake
- First, create a build directory and generate the
Makefile
:
vscode ➜ /workspaces/ms-cpp $ mkdir build && cd build
mkdir: created directory 'build'
/workspaces/ms-cpp/build
vscode ➜ /workspaces/ms-cpp/build $ cmake ..
...
-- Configuring done
-- Generating done
-- Build files have been written to: /workspaces/ms-CPP/build
- Build the executable:
vscode ➜ /workspaces/ms-cpp/build $ make
[ 50%] Building CXX object CMakeFiles/gtest_test.dir/test.cpp.o
[100%] Linking CXX executable gtest_test
[100%] Built target gtest_test
- Run the test code:
vscode ➜ /workspaces/ms-cpp/build $ ctest -V
UpdateCTestConfiguration from :/workspaces/ms-cpp/build/DartConfiguration.tcl
UpdateCTestConfiguration from :/workspaces/ms-cpp/build/DartConfiguration.tcl
Test project /workspaces/ms-cpp/build
Constructing a list of tests
Done constructing a list of tests
Updating test list for fixtures
Added 0 tests to meet fixture requirements
Checking test dependency graph...
Checking test dependency graph end
test 1
Start 1: FailingTest
1: Test command: /workspaces/ms-cpp/build/gtest_test
1: Test timeout computed to be: 10000000
1: Running main() from /build/googletest-j5yxiC/googletest-1.10.0/googletest/src/gtest_main.cc
1: [==========] Running 1 test from 1 test suite.
1: [----------] Global test environment set-up.
1: [----------] 1 test from setup_test_case
1: [ RUN ] setup_test_case.testWillFail
1: /workspaces/ms-cpp/test.cpp:4: Failure
1: Expected equality of these values:
1: 42
1: 0
1: [ FAILED ] setup_test_case.testWillFail (0 ms)
1: [----------] 1 test from setup_test_case (0 ms total)
1:
1: [----------] Global test environment tear-down
1: [==========] 1 test from 1 test suite ran. (0 ms total)
1: [ PASSED ] 0 tests.
1: [ FAILED ] 1 test, listed below:
1: [ FAILED ] setup_test_case.testWillFail
1:
1: 1 FAILED TEST
1/1 Test #1: FailingTest ......................***Failed 0.01 sec
0% tests passed, 1 tests failed out of 1
Total Test time (real) = 0.04 sec
The following tests FAILED:
1 - FailingTest (Failed)
Errors while running CTest
Adding Meson and Ninja
Finally, rather than using CMake/make
for our build we prefer to use Meson and Ninja for projects. Meson is a Python-based build system, which I find far more intuitive than CMake
(Note: CMake can also produce Ninja build files instead of Makefiles).
Meson is also a standard Ubuntu package and has dependents:
- ninja-build (>= 1.6)
- python3
- python3-pkg-resources
Add meson
to the apt
package list. It is also worth adding pkg-config
as well as meson
uses this when looking for package dependencies (it’s not essential but keeps things cleaner).
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends libgmock-dev meson pkg-config
Rebuild the container and confirm meson
is installed:
vscode ➜ /workspaces/ms-cpp $ meson -h
Setting up meson
Create a simple meson.build
file, e.g.
project('tdd-cpp', 'cpp', default_options: ['cpp_std=c++17'])
gtest_dep = dependency('gtest', main : true, required : true)
gmock_dep = dependency('gmock', main : true, required : true)
gtest_test = executable(
'gtest_test',
sources : [ 'test.cpp', ],
dependencies : [ gtest_dep, ]
)
test('failing_test', gtest_test)
As with CMake
:
- Create a build directory :
vscode ➜ /workspaces/ms-cpp $ meson builddir && cd builddir
The Meson build system
Version: 0.53.2
Source dir: /workspaces/ms-cpp
Build dir: /workspaces/ms-cpp/builddir
Build type: native build
Project name: tdd-cpp
Project version: undefined
C++ compiler for the host machine: c++ (gcc 9.3.0 "c++ (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0")
C++ linker for the host machine: c++ ld.bfd 2.34
Host machine cpu family: x86_64
Host machine cpu: x86_64
Found pkg-config: /usr/bin/pkg-config (0.29.1)
Run-time dependency GTest found: YES 1.10.0
Run-time dependency GMock found: YES 1.10.0
Build targets in project: 1
Found ninja-1.10.0 at /usr/bin/ninja
/workspaces/ms-cpp/builddir
- Build and run the test:
vscode ➜ /workspaces/ms-cpp/builddir $ meson test -v
ninja: Entering directory `/workspaces/ms-cpp/builddir'
[2/2] Linking target gtest_test.
Running main() from /build/googletest-j5yxiC/googletest-1.10.0/googletest/src/gtest_main.cc
[==========] Running 1 test from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 test from setup_test_case
[ RUN ] setup_test_case.testWillFail
../test.cpp:4: Failure
Expected equality of these values:
42
0
[ FAILED ] setup_test_case.testWillFail (0 ms)
[----------] 1 test from setup_test_case (0 ms total)
[----------] Global test environment tear-down
[==========] 1 test from 1 test suite ran. (1 ms total)
[ PASSED ] 0 tests.
[ FAILED ] 1 test, listed below:
[ FAILED ] setup_test_case.testWillFail
1 FAILED TEST
1/1 failing_test FAIL 0.01 s (exit status 1)
Ok: 0
Expected Fail: 0
Extending the Dev Container
The devcontainer.json
file enables the created container to be configured and extended (devcontainer.json reference), including VSCode extensions (Visual Studio Marketplace).
For example, VSCode extensions are available for both meson
and GoogleTest
. There are a number of specific extensions, here we shall use asabil.meson and C++ TestMate. These can be added to the container configuration using the devcontainer.json
file, e.g.
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"ms-vscode.cpptools",
"asabil.meson",
"matepek.vscode-catch2-test-adapter"
],
Now when we open the container, the extensions are present and allow us to manage the project.
Note, you’ll need to modify the file .vscode/settings.json
to include
{
"testMate.cpp.test.advancedExecutables": [
"builddir/gtest_test",
]
}
to get C++ TestMate
to pick up the test executable.
Areas of note…
Before you rush off turning all your projects into VSCode Dev Containers, I’ve come across a couple of issues.
First, some of our existing containers, especially those optimised for small size (notably multi-build, alpine-based images) fail to run using Dev Containers. I’ve yet to get to the bottom of these issues (yet another backlog item).
Also, I have had problems where existing Dockerfiles make significant use of bash scripts for internal configuration. Often these scripts are built around specific path configurations. When using Dev Containers the workspace is mounted under the internal path /workspaces/<git repo name>
and can cause scripts to fail. It appears this can be managed using advanced container configuration, but it’s yet another thing I’ve yet to experiment with.
Finishing off
The integration of container technology, specifically Docker, to VSCode creates an exciting development environment. It cements the relationship between local TDD development and CI pipeline builds by ensuring common build and test environments using Docker.
The example CMake
and meson
projects are elementary and not intended to represent real-world CMake
or meson
projects, but hopefully are enough to get going with. A slightly better, but by still no means complete, Meson/gtest/gmock example project can be found at here.
GitHub Codespaces
If Dev Containers pique your interest, then you love Codespaces by GitHub which is the topic of the next post.
- Disassembling a Cortex-M raw binary file with Ghidra - December 20, 2022
- Using final in C++ to improve performance - November 14, 2022
- Understanding Arm Cortex-M Intel-Hex (ihex) files - October 12, 2022
Co-Founder and Director of Feabhas since 1995.
Niall has been designing and programming embedded systems for over 30 years. He has worked in different sectors, including aerospace, telecomms, government and banking.
His current interest lie in IoT Security and Agile for Embedded Systems.
Hi Niall, great article but could you please explain why one might want to do all this?