A minimal C++ project (a multiplication quiz) whose real purpose is to demonstrate a comfortable VS Code developer setup pinned to a legacy toolchain:
| Tool | Version |
|---|---|
| OS | Ubuntu 20.04 (Focal) |
| Compiler | GCC 9 |
| Build system | CMake 3.22 |
| Package manager | Conan 1.66 |
| Test framework | GoogleTest 1.8.1 |
| Debugger | GDB (bundled) |
The application source is intentionally small. The real value is showing the setup: a devcontainer that pins every tool version, one-key build, F5 step-debug for both the app and the tests, and GoogleTest crash dumps on test failure.
- Quickstart (devcontainer)
- Project layout
- Build & Run
- Run tests
- Trigger a failure dump
- Inspect a core dump with GDB
- VS Code workflow
- Architecture diagram
- Troubleshooting
- Docker Desktop or Docker Engine 20+
- VS Code
- VS Code extension: Remote – Containers
No local installation of GCC, CMake, Conan, or GDB is needed on your host machine. Everything runs inside the container.
-
Clone this repository:
git clone <repo-url> gtest-example cd gtest-example
-
Open the folder in VS Code:
code . -
VS Code detects
.devcontainer/devcontainer.jsonand shows a notification:"Folder contains a Dev Container configuration file. Reopen folder to develop in a container."
Click Reopen in Container.
Alternatively open the Command Palette (
Ctrl+Shift+P) and run:Dev Containers: Reopen in Container
-
Docker builds the image (Ubuntu 20.04 + GCC 9 + CMake 3.22 + Conan 1.66). First build takes a few minutes while packages are downloaded and installed. Subsequent reopens are nearly instant (the image is cached).
-
Once the container is ready, open VS Code's integrated terminal (
Ctrl+````) and verify the toolchain:# Compiler — should print "gcc (Ubuntu 9.x.x …) 9.x.x" gcc --version # CMake — should print "cmake version 3.22.6" cmake --version # Conan — should print "Conan version 1.66.0" conan --version # Debugger — should print "GNU gdb … x.x" gdb --version
You are now inside a fully configured legacy C++ development environment.
gtest-example/
├── .devcontainer/
│ ├── Dockerfile # Ubuntu 20.04 image: GCC-9, CMake 3.22, Conan 1.66, GDB
│ └── devcontainer.json # VS Code container config, extensions, postCreateCommand
│
├── .vscode/ # (Phase 4) VS Code build & debug config
│ ├── extensions.json # Recommended extensions
│ ├── tasks.json # Build tasks (Ctrl+Shift+B)
│ ├── launch.json # Debug configurations (F5)
│ ├── c_cpp_properties.json # IntelliSense compiler/include paths
│ └── settings.json # Workspace settings
│
├── src/
│ ├── quiz_lib/ # (Phase 2) Core library: question generation, scoring
│ │ ├── quiz.hpp
│ │ ├── quiz.cpp
│ │ └── CMakeLists.txt
│ └── quiz_app/ # (Phase 2) Interactive CLI: asks questions, reports score
│ ├── main.cpp
│ └── CMakeLists.txt
│
├── tests/ # (Phase 3) GoogleTest suite
│ ├── quiz_lib_test.cpp # Passing unit tests for quiz_lib
│ ├── quiz_failure_demo_test.cpp # Deliberately failing test (gated by env var)
│ ├── dump_on_failure_listener.hpp # Custom GTest listener: aborts on failure
│ ├── dump_on_failure_listener.cpp # … so the OS writes a core dump
│ ├── main.cpp # Test entry-point: registers the listener
│ └── CMakeLists.txt
│
├── conanfile.txt # (Phase 2) Conan dependencies: gtest/1.8.1
├── CMakeLists.txt # (Phase 2) Top-level build definition
├── .gitignore # Excludes build/, core dumps, Conan cache
├── specs/ # Project specification and planning documents
└── README.md # This file
All commands are run from inside the devcontainer in the VS Code integrated terminal.
conan profile new default --detect and the libstdc++11 ABI fix are run
automatically when the container starts. You only need to redo this if you
wipe the Conan cache (~/.conan/).
# 1. Create and enter the build directory.
mkdir -p build && cd build
# 2. Fetch GoogleTest 1.8.1 and generate conanbuildinfo.cmake.
# --build=missing — build any package that has no pre-built binary for
# this profile (expected the first time).
conan install .. --build=missing
# 3. Configure the CMake project.
# -DCMAKE_BUILD_TYPE=Debug — keeps debug symbols for step-debugging in F5.
cmake .. -DCMAKE_BUILD_TYPE=Debug
# 4. Compile everything (quiz_lib + quiz_app).
cmake --build .After a successful build you will see:
build/
├── bin/
│ └── quiz_app ← the executable
├── conanbuildinfo.cmake ← generated by conan install
└── compile_commands.json ← used by IntelliSense
# From the repo root, or from inside build/:
./build/bin/quiz_appExpected output (answers are yours to provide):
=== Multiplication Quiz ===
Answer 5 multiplication questions.
Q1: What is 1 × 1? 1
Correct!
Q2: What is 2 × 1? 3
Wrong. The answer was 2.
...
Result: 4 / 5 correct (80%)
The process exits with code 0 on a perfect score and 1 otherwise, making it scriptable.
CMake tracks dependencies automatically. After editing any .cpp or .hpp
file, just re-run step 4:
cd build && cmake --build .Build the test binary first (if not already built — the cmake --build step
from §3 builds everything including quiz_tests).
cd build
ctest --output-on-failureExpected output:
Test project /workspaces/gtest-example/build
Start 1: quiz_tests
1/1 Test #1: quiz_tests ....................... Passed 0.01 sec
100% tests passed, 0 tests failed out of 1
An XML report is written to build/test-reports/quiz_tests.xml automatically
(wired into the add_test() command in tests/CMakeLists.txt).
# From the repo root:
./build/bin/quiz_tests
# Or with an explicit XML output path and verbose output:
./build/bin/quiz_tests \
--gtest_output=xml:build/test-reports/quiz_tests.xml \
--gtest_color=yes# --gtest_filter accepts wildcards; * matches any substring.
./build/bin/quiz_tests --gtest_filter=GenerateQuestion.CorrectAnswerEqualsProductTwo environment variables control the dump pipeline:
| Variable | Effect |
|---|---|
QUIZ_DEMO_FAILURE=1 |
Activates the intentionally-failing test in quiz_failure_demo_test.cpp. Without it the test silently passes (GTest 1.8.1 has no GTEST_SKIP()). |
GTEST_DUMP_ON_FAILURE=1 |
Activates DumpOnFailureListener — when any test fails, std::abort() is called, raising SIGABRT. |
There are two ways to capture the failure state, depending on whether the
container has --privileged access.
Run the test binary directly under gdb. When std::abort() fires, gdb
catches SIGABRT and you land in an interactive session with the full call
stack visible:
cd /workspaces/gtest-example
QUIZ_DEMO_FAILURE=1 GTEST_DUMP_ON_FAILURE=1 \
gdb -q \
-ex 'run --gtest_filter=DemoFailure.IntentionallyFailsWhenEnabled \
--gtest_output=xml:build/test-reports/quiz_tests_fail.xml' \
-ex 'bt' \
--args build/bin/quiz_testsGDB catches the abort and shows a backtrace like:
#2 DumpOnFailureListener::OnTestPartResult (…) ← our listener called abort()
#6 DemoFailure_IntentionallyFailsWhenEnabled_Test::TestBody (…)
quiz_failure_demo_test.cpp:48 ← EXPECT_EQ(2+2, 5) is here
From there you can use all the usual gdb commands (up, print, list,
frame N, …) to inspect the failure live. See §6
for the command reference.
This path requires the container to be started with --privileged so that
core_pattern can be set to write dumps as plain files instead of piping
them to systemd-coredump (which is not running inside Docker).
The devcontainer has already been updated (--privileged is enabled in
.devcontainer/devcontainer.json). If you are in an older container:
-
Open the Command Palette (
Ctrl+Shift+P) and run:Dev Containers: Rebuild Container
-
The
postCreateCommandautomatically sets:/proc/sys/kernel/core_pattern = core.%e.%pThis writes core files as
core.<binary>.<pid>in the process cwd. -
Trigger the dump:
ulimit -c # verify: prints "unlimited" # Run from build/dumps/ so the core lands there cd /workspaces/gtest-example/build/dumps QUIZ_DEMO_FAILURE=1 GTEST_DUMP_ON_FAILURE=1 \ ../bin/quiz_tests \ --gtest_output=xml:../test-reports/quiz_tests_fail.xml
Expected output:
[ FAILED ] DemoFailure.IntentionallyFailsWhenEnabled [DumpOnFailureListener] GTEST_DUMP_ON_FAILURE=1 detected. [DumpOnFailureListener] Calling std::abort() to trigger core dump. Aborted (core dumped) -
Verify the core file:
ls -lh /workspaces/gtest-example/build/dumps/ # should show: core.quiz_tests.<pid>
The environment variable QUIZ_DEMO_HANG=1 activates a test that blocks
indefinitely inside simulate_blocking_work() (which calls pause()). The
VS Code task run-tests-with-timeout-dump attaches gdb from outside after a
5-second watchdog expires, captures the full thread stack, writes a core file
for post-mortem use, and then terminates the hung process.
This is different from the GTEST_DUMP_ON_FAILURE path above: rather than
triggering an abort inside the process on assertion failure, the watchdog
observes the live stuck process from outside, which preserves the exact
blocked state without perturbing it.
Run the demo (VS Code):
Open the Command Palette (Ctrl+Shift+P) → Tasks: Run Task →
run-tests-with-timeout-dump.
Run the demo (terminal):
cd /workspaces/gtest-example/build
QUIZ_DEMO_HANG=1 ./bin/quiz_tests \
--gtest_filter=DemoHang.HangsWhenEnabled &
TEST_PID=$!
sleep 5 # watchdog timeout
sudo gdb -batch -q -p "$TEST_PID" \
-ex "set pagination off" \
-ex "thread apply all bt full" \
-ex "generate-core-file dumps/core.quiz_tests.timeout" \
-ex quit 2>&1 | tee dumps/timeout_backtrace.txt
kill "$TEST_PID" 2>/dev/nullExpected output (excerpt from build/dumps/timeout_backtrace.txt):
Thread 1 (process <PID>):
#0 __GI___libc_pause ()
#1 pause ()
#2 simulate_blocking_work () ← quiz_failure_demo_test.cpp
#3 DemoHang_HangsWhenEnabled_Test::TestBody ()
#4 testing::Test::Run ()
...
Artifacts written:
| File | Contents |
|---|---|
build/dumps/timeout_backtrace.txt |
Full thread apply all bt full output |
build/dumps/core.quiz_tests.timeout |
Core file — inspect with gdb build/bin/quiz_tests build/dumps/core.quiz_tests.timeout (see §6) |
If you used Path A from §5 (running under gdb directly), you are already in an interactive gdb session when the abort fires. Skip to the useful commands below.
If you used Path B (core dump to disk), open the dump:
cd /workspaces/gtest-example
gdb build/bin/quiz_tests build/dumps/core.quiz_tests.*# Full call stack at the point of abort:
(gdb) bt
# Jump to the frame with your test code (number from bt output,
# usually the TestBody frame):
(gdb) frame 6
# List source code around the current line:
(gdb) list
# Print a local variable:
(gdb) print result
# Inspect a struct:
(gdb) print q
# Quit:
(gdb) quit#0 __GI_raise (sig=6) ← C stdlib raise()
#1 __GI_abort () ← C stdlib abort()
#2 DumpOnFailureListener::OnTestPartResult (…) ← our listener (dump_on_failure_listener.cpp:47)
#3 testing::internal::TestEventRepeater::OnTestPartResult (…)
#4 testing::UnitTest::AddTestPartResult (…) ← GTest records the EXPECT_EQ failure
#5 testing::internal::AssertHelper::operator= (…)
#6 DemoFailure_IntentionallyFailsWhenEnabled_Test::TestBody (…)
quiz_failure_demo_test.cpp:48 ← EXPECT_EQ(2+2, 5) lives here
#7 testing::Test::Run ()
... (GTest internals)
#14 main () main.cpp:32
Type frame 6 to jump to the failing test body, then list and print
to inspect local state.
cat build/test-reports/quiz_tests_fail.xmlThe XML report is written before std::abort() fires (the GTest result is
recorded as soon as the assertion fails; the abort happens in the
listener, which runs after recording).
All of the following assumes you are working inside the devcontainer
(VS Code shows Dev Container: C++ Legacy Toolchain in the bottom-left corner).
Press Ctrl+Shift+B (or run Terminal → Run Build Task…).
This triggers the cmake-build default task, which chains:
conan-install → cmake-configure → cmake-build
All three steps run in the integrated terminal. A successful build ends with:
[100%] Built target quiz_tests
Outputs: build/bin/quiz_app and build/bin/quiz_tests.
Subsequent builds after a source-only change: press
Ctrl+Shift+Bagain. Conan and CMake are idempotent — only changed files are recompiled.
- Open src/quiz_app/main.cpp.
- Click in the gutter next to the line:
A red dot appears — this is your breakpoint.
quiz::Question q = quiz::generate_question(seed);
- Open the Run & Debug panel (
Ctrl+Shift+D). - Confirm the drop-down shows Debug quiz_app, then press
F5. - Execution pauses at
main()(stopAtEntry: true). PressF5again to continue to your breakpoint. - Type an answer in the cppdbg: quiz_app terminal at the bottom of VS Code.
- Step controls:
F10— step over (stay in the current function)F11— step into (entergenerate_question/check_answer/Score)F5— continue to the next breakpointShift+F5— stop debugging
- Open tests/quiz_failure_demo_test.cpp.
- Set a breakpoint on the
EXPECT_EQ(2 + 2, 5)line. - In the Run & Debug drop-down, select Debug quiz_tests, then press
F5. - Execution stops at your breakpoint inside the failing test body.
- The environment has
QUIZ_DEMO_FAILURE=1(activates the test) andGTEST_DUMP_ON_FAILURE=0(keeps the process alive for step-debugging). - To observe the
std::abort()call instead, changeGTEST_DUMP_ON_FAILUREto"1"in .vscode/launch.json — GDB will catch SIGABRT and you can inspect the stack at the point of abort.
- The environment has
- To debug a passing test, remove the
--gtest_filterarg inlaunch.jsonand set a breakpoint anywhere in tests/quiz_lib_test.cpp.
Open Ctrl+Shift+P → Tasks: Run Task → choose:
| Task | What it does |
|---|---|
run-tests |
Runs all tests; writes XML report to build/test-reports/quiz_tests.xml |
run-tests-with-failure-dump |
Activates the failing test, runs under gdb, prints backtrace |
run-tests-with-timeout-dump |
Activates the hanging test, attaches gdb after 5 s, saves backtrace + core to build/dumps/ |
IntelliSense is driven by build/compile_commands.json (set in
.vscode/c_cpp_properties.json). If you see
red squiggles on #include <gtest/gtest.h>:
- Make sure you have run the build at least once (
Ctrl+Shift+B). - Open the Command Palette → C/C++: Reset IntelliSense Database.
- The squiggles disappear once IntelliSense re-indexes.
flowchart TD
Dev[Developer in VS Code] -->|Reopen in Container| DC[Devcontainer\nUbuntu 20.04 · GCC-9 · CMake 3.22 · Conan 1.66]
DC -->|task: conan-install| Conan[Conan 1.66\nfetch GTest 1.8.1]
Conan --> Build[CMake configure + build\nquiz_lib · quiz_app · quiz_tests]
Build -->|F5: Debug quiz_app| AppDbg[GDB step-debug\nquiz_app]
Build -->|F5: Debug quiz_tests| TestDbg[GDB step-debug\nquiz_tests]
Build -->|task: run-tests| TestRun[quiz_tests\n--gtest_output=xml]
TestRun -->|on failure + GTEST_DUMP_ON_FAILURE=1| Abort[Custom listener\nstd::abort]
Abort --> Core[Core dump\nbuild/dumps/]
TestRun --> XML[XML report\nbuild/test-reports/]
Core -->|gdb post-mortem| Inspect[Inspect dump\nin VS Code / terminal]
Symptom: Build fails with errors like:
undefined reference to `testing::internal::…[abi:cxx11]…`
Cause: Conan 1.x defaults to the old GCC ABI (libstdc++) but Ubuntu 20.04
system headers use the new ABI (libstdc++11). The mismatch is caught at link time.
Fix: Run the two profile commands (already in postCreateCommand, but
sometimes needed manually after a cache wipe):
conan profile new default --detect
conan profile update settings.compiler.libcxx=libstdc++11 defaultThen re-run the full build chain (Ctrl+Shift+B or conan install → cmake → make).
Symptom: conan install fails immediately with "Profile not found".
Cause: The Conan default profile has not been created yet (e.g. after a
Conan-cache wipe or a fresh container that skipped postCreateCommand).
Fix:
conan profile new default --detect
conan profile update settings.compiler.libcxx=libstdc++11 defaultSymptom: Running quiz_tests with GTEST_DUMP_ON_FAILURE=1 prints
Aborted but no core file appears in build/dumps/.
Cause A — ulimit is 0: The per-process core size limit is 0. Check with:
ulimit -c # should print "unlimited"If it prints 0, open a new terminal tab in the container so that
/etc/bash.bashrc (which runs ulimit -c unlimited) is sourced fresh.
Cause B — core_pattern pipes to apport / systemd-coredump: This is the
default on many Ubuntu hosts. Cores are swallowed by apport or
systemd-coredump instead of being written as plain files.
Fix (run on the host, not inside the container):
sudo sysctl -w kernel.core_pattern=core.%e.%pTo make it permanent:
echo 'kernel.core_pattern=core.%e.%p' | \
sudo tee /etc/sysctl.d/60-core-pattern.confCause C — container not --privileged: Without --privileged the
container cannot override core_pattern even from postCreateCommand.
Verify: cat /proc/sys/kernel/core_pattern inside the container should show
core.%e.%p. If it shows a pipe (|/usr/share/apport/…), rebuild the
container after confirming "--privileged" is present in
.devcontainer/devcontainer.json → runArgs.
Symptom: After attaching GDB, list or frame N says the source file
cannot be found, or breakpoints bind but show no source.
Cause A — binary built without debug symbols: The binary was compiled in
Release mode or without -g.
Fix: Rebuild with Debug type (already the default in cmake-configure):
cd build && cmake .. -DCMAKE_BUILD_TYPE=Debug && cmake --build .Cause B — GDB source path mismatch: Add the workspace root to GDB's
directory list at the (gdb) prompt:
(gdb) directory /workspaces/gtest-exampleSymptom: Every #include <gtest/gtest.h> is underlined red; hover shows
"cannot open source file".
Cause: build/compile_commands.json does not exist yet (project not built)
or IntelliSense has not re-indexed since the last build.
Fix:
- Build the project at least once (
Ctrl+Shift+B). - Open the Command Palette (
Ctrl+Shift+P) → C/C++: Reset IntelliSense Database. - The squiggles disappear once IntelliSense re-indexes (typically a few seconds).
Symptom: The VS Code Testing panel shows no tests or spins indefinitely.
Cause A — binary not built yet: The TestMate extension scans for the
executable at build/bin/quiz_tests. If it does not exist, discovery is
silently skipped.
Fix: Build once (Ctrl+Shift+B).
Cause B — extension not activated: matepek.vscode-catch2-test-adapter
may not have initialised yet.
Fix: Command Palette → Developer: Reload Window.
Symptom: A change to .devcontainer/devcontainer.json or Dockerfile
(e.g. adding --privileged, updating a build arg) has no visible effect
after reopening the container.
Cause: Reopen in Container reuses the cached Docker image. It does not rebuild the image.
Fix: Force a full image rebuild:
Command Palette (
Ctrl+Shift+P) → Dev Containers: Rebuild Container
This re-executes all RUN layers in the Dockerfile and re-runs
postCreateCommand.