VSCode, Dev Containers and Docker: moving software development forward

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.

Getting started with Dev Containers and Docker

There are several different approaches to using Dev Containers. In this post, we shall cover three options:

  1. Using an existing Docker image from Docker Hub
  2. Using a pre-build Microsoft container setup
  3. Using a custom Docker image based on a project specific Dockerfile

There are a couple of prerequisites:

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.

  1. Create an empty folder, e.g.
$ mkdir ceedling_test
  1. In the new folder, create another folder called .devcontainer (note the preceding .)
$ cd ceedling_test
$ mkdir .devcontainer
  1. In that new folder, add a file called devcontainer.json with the contents
{
    "image": "feabhas/ceedling"
}
  1. Your project structure should now be:
.devcontainer
    └── devcontainer.json
  1. Now open VSCode in the working directory
$ code .
  1. VScode will detect the Dev Container configuration file and ask if you want to reopen the folder in a container. Click Reopen in Container.
  2. 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 image feabhas/ceedling.
  3. 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

ceedling-test

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:

  1. Create an empty working directory
  2. In that directory, create a simple main.cpp, e.g.
#include <iostream>

int main()
{
    std::cout << "Hello from Dev Containers\n";
}
  1. Open VSCode:
$ code .
  1. Open the VSCode command pallet (F1 on all platforms) and select
Remote-Containers: Reopen In Container
  1. VSCode will present several pre-defined development container alternatives.
  2. Select C++; this will now reopen the current VSCode project in a container.
  3. Next, you can select your preferred base Linux image – I have used the ubuntu-20.04 base (it doesn’t matter for this example).
  4. 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.
  1. 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)
  1. 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:

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

  1. 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
  1. 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
  1. 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:

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:

  1. 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
  1. 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.

Niall Cooling
Dislike (0)
Website | + posts

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.

About Niall Cooling

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.
This entry was posted in Agile, C/C++ Programming, Testing and tagged , , , , , , , . Bookmark the permalink.

1 Response to VSCode, Dev Containers and Docker: moving software development forward

  1. David says:

    Hi Niall, great article but could you please explain why one might want to do all this?

    Like (0)
    Dislike (0)

Leave a Reply