Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 85

Unit Testing Thread-Safe Queues and Task Submission

Recommended resources

Hello! Welcome back to our course on C++ internals.

In the last lesson, we put the finishing touches on the core logic of our thread pool by implementing a graceful shutdown mechanism. We now have a system that can start workers, accept tasks, and terminate cleanly. While the logic appears correct on paper, the world of concurrent programming is fraught with subtle bugs that only manifest under specific, hard-to-predict conditions. How can we be confident that our implementation is truly robust?

This is where automated testing becomes indispensable. Today, we begin the final phase of our project: building a portfolio-worthy library. Our learning outcome is to set up a testing framework (Google Test) and write unit tests for the thread-safe queue and task submission. This will not only validate our existing code but also establish a critical foundation for safely adding new features.

Why Unit Testing is Essential

Unit tests are small, automated checks that verify a single "unit" of code—like a class or a function—in isolation. For a complex, concurrent system like our thread pool, they are not just a best practice; they are a necessity. They allow us to:

  • Isolate Components: Test our ThreadSafeQueue's logic separately from the thread management parts of the ThreadPool.
  • Catch Regressions: Ensure that future changes don't accidentally break existing functionality.
  • Build Confidence: Systematically prove that our code handles edge cases, concurrency, and error conditions correctly.

Before we dive into the technical setup, let's watch a short segment from a CppCon talk that introduces the core ideas behind unit testing and why it's so valuable.

Back to Basics: Unit Tests - Ben Saks - CppCon 2020

In this clip from 'Back to Basics: Unit Tests' by Ben Saks at CppCon 2020, he introduces the fundamental concepts of automated unit testing and outlines the properties of a good test.

Watch the first few minutes, from 00:47 to 04:09. Pay attention to the five properties of good unit tests: correctness, readability, completeness, demonstrativeness, and resilience.


Setting Up the Testing Environment with CMake and Google Test

We will use Google Test, a powerful and widely-used C++ testing framework. Integrating it into our build system, CMake, is the first step. The modern, professional way to do this is with CMake's FetchContent module, which automatically downloads and configures Google Test during the build process. This keeps our project self-contained and easy to set up on any machine.

Project Structure

First, let's organize our project directory. A clean separation between production code and test code is crucial. We will adopt the following structure:

ThreadPoolProject/
├── CMakeLists.txt         # Top-level CMake configuration
├── include/
│   └── ThreadPool.h       # Our thread pool header
├── src/
│   └── ThreadPool.cpp     # (If you have a separate .cpp file)
└── tests/
    ├── CMakeLists.txt     # CMake configuration for tests
    └── QueueTests.cpp     # Our first test file

You can create these directories and files now. We will write the code for them in the following sections.

Configuring CMake

We need to make changes to two CMakeLists.txt files.

  1. Top-Level CMakeLists.txt

    Update your main CMakeLists.txt to enable testing and fetch Google Test.

    cmake_minimum_required(VERSION 3.14)
    project(ThreadPool)
    
    set(CMAKE_CXX_STANDARD 17)
    set(CMAKE_CXX_STANDARD_REQUIRED ON)
    
    # --- Add these lines for testing ---
    
    # 1. Enable testing in CMake
    enable_testing()
    
    # 2. Add the tests subdirectory
    # This should be after you define your main library/executable
    add_subdirectory(tests)
    
    # 3. Fetch GoogleTest
    include(FetchContent)
    FetchContent_Declare(
      googletest
      URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.zip
    )
    # For projects that support it, GTest will be available as a target
    FetchContent_MakeAvailable(googletest)
    
  2. tests/CMakeLists.txt

    Create this new file inside your tests directory. This file defines our test executable and links it with Google Test and our thread pool code.

    # tests/CMakeLists.txt
    
    # 1. Define the test executable
    add_executable(
      run_tests
      QueueTests.cpp
      # Add other test files here as you create them
    )
    
    # 2. Link against your project's code.
    # If your thread pool is a library target named 'ThreadPool', you'd use that.
    # If it's header-only, you might just need to set include directories.
    # For simplicity, let's assume it's a library target from the parent scope.
    # target_link_libraries(run_tests PRIVATE ThreadPool) # Example
    
    # For a header-only ThreadPool.h, you just need to include the directory.
    target_include_directories(run_tests PRIVATE ${CMAKE_SOURCE_DIR}/include)
    
    # 3. Link against GoogleTest
    # gtest_main includes a pre-written main() function for the test runner.
    target_link_libraries(
      run_tests
      PRIVATE gtest_main
    )
    
    # 4. Discover tests and register them with CTest
    include(GoogleTest)
    gtest_discover_tests(run_tests)
    

For a more in-depth guide on the setup process, the following resource provides a complete walkthrough.

Mastering Unit Testing in Modern C++ with Google Test

The booklet 'Mastering Unit Testing in Modern C++ with Google Test' offers a detailed guide. We'll focus on the sections covering project structure and CMake integration.

Please review Chapter 2, 'Setting Up Google Test', focusing on sections 2.2 ('Integrating with CMake') and 2.3 ('Structure of a Basic Test Project'). Also, quickly look at Appendix D ('Example Project Structure with Full Tests') for a complete picture. This reinforces the structure and CMake configuration we've just discussed.

With this setup, you can now build your project from a build directory:
cmake .. will configure the project and download Google Test.
make (or your build tool) will compile both your library and the run_tests executable.
ctest will run the tests.


Writing Our First Tests for the Thread-Safe Queue

The heart of our thread pool's concurrency control is the thread-safe queue. Let's write unit tests to verify its core functionality. We'll start with simple, single-threaded scenarios.

First, let's create the file tests/QueueTests.cpp.

A basic test in Google Test is defined with the TEST() macro. However, when multiple tests need a common setup (like creating a queue object), we use a test fixture. This is a class that inherits from ::testing::Test.

// tests/QueueTests.cpp
#include <gtest/gtest.h>
#include "ThreadPool.h" // Assuming ThreadSafeQueue is in here

// A test fixture for our ThreadSafeQueue
class ThreadSafeQueueTest : public ::testing::Test {
protected:
    // Per-test-case set-up.
    // Called before each test in this test case.
    void SetUp() override {
        // You could initialize the queue here if needed
    }

    // Per-test-case tear-down.
    // Called after each test in this test case.
    void TearDown() override {
        // Cleanup if needed
    }

    // The object we are testing
    ThreadSafeQueue<int> q;
};

Now, we can write tests using TEST_F() (the 'F' stands for fixture). Each TEST_F has access to the members of the fixture class. Google Test creates a new ThreadSafeQueueTest object for each test, ensuring they are isolated.

// tests/QueueTests.cpp (continued)

// Test that the queue is initially empty.
TEST_F(ThreadSafeQueueTest, IsInitiallyEmpty) {
    ASSERT_TRUE(q.empty());
    EXPECT_EQ(q.size(), 0);
}

// Test pushing and then popping an element.
TEST_F(ThreadSafeQueueTest, PushAndTryPop) {
    q.push(42);
    ASSERT_FALSE(q.empty());
    EXPECT_EQ(q.size(), 1);

    int value;
    ASSERT_TRUE(q.try_pop(value));
    EXPECT_EQ(value, 42);

    ASSERT_TRUE(q.empty());
    EXPECT_EQ(q.size(), 0);
}

// Test trying to pop from an empty queue.
TEST_F(ThreadSafeQueueTest, TryPopOnEmpty) {
    int value;
    EXPECT_FALSE(q.try_pop(value));
}

EXPECT_* vs. ASSERT_*

Notice the use of EXPECT_ and ASSERT_.

  • EXPECT_*: A non-fatal assertion. If it fails, the test is marked as failed, but continues running.
  • ASSERT_*: A fatal assertion. If it fails, the current test function aborts immediately.

Best Practice: Use ASSERT_* for conditions that are critical for the rest of the test to make sense. For example, ASSERT_FALSE(q.empty()) before we try to pop an element. Use EXPECT_* for all other checks.


Testing Concurrent Behavior

Now for the interesting part: how do we test that wait_and_pop actually blocks? This requires us to orchestrate multiple threads within our test case.

The strategy is:

  1. Launch a "consumer" thread that calls wait_and_pop. It should block because the queue is empty.
  2. The main test thread will wait for a moment to ensure the consumer has time to block.
  3. The main thread will then act as a "producer," pushing an item into the queue.
  4. Join the consumer thread and verify it received the correct item.

This is a classic scenario for testing concurrent components.

// tests/QueueTests.cpp (continued)

TEST_F(ThreadSafeQueueTest, WaitAndPopBlocksAndReceives) {
    int popped_value = 0;
    std::promise<void> consumer_started; // To sync threads

    // 1. Launch consumer thread
    std::thread consumer([&]() {
        consumer_started.set_value(); // Signal that we have started
        q.wait_and_pop(popped_value);
    });

    // Wait for the consumer to actually start and likely block
    consumer_started.get_future().wait();
    
    // Give it a moment to ensure it's in the wait() call
    std::this_thread::sleep_for(std::chrono::milliseconds(50));

    // 2. Push an item from the main thread
    q.push(101);

    // 3. Join and verify
    consumer.join();
    EXPECT_EQ(popped_value, 101);
}

This test simulates the exact interaction our ThreadPool's worker threads have with the queue. Using a std::promise helps synchronize the start, making the test more reliable than just relying on sleep.

Test your understanding!

Why is testing concurrent code with std::this_thread::sleep_for sometimes problematic, and how does using a std::promise like we did help?

Show answer

Relying on sleep creates a timing dependency, which can lead to "flaky" tests. A test might pass on a fast machine but fail on a slow one (or a heavily loaded one) because the consumer thread might not have had time to call wait_and_pop before the sleep in the main thread finishes.

Using a std::promise creates an explicit synchronization point. The main thread will not proceed until the consumer thread explicitly signals consumer_started.set_value(). This makes the test deterministic and far more robust, as it no longer depends on arbitrary timing.


Testing Task Submission and Futures

Finally, let's add a test for the ThreadPool's main feature: submitting a task and getting a result via a std::future.

Create a new file tests/PoolTests.cpp and add it to tests/CMakeLists.txt.

// tests/PoolTests.cpp
#include <gtest/gtest.h>
#include "ThreadPool.h"

// For these tests, we can use a simple TEST() macro
// as each test will create its own pool.

TEST(ThreadPoolTest, SubmitTaskAndGetResult) {
    ThreadPool pool(2); // Create a pool with 2 threads

    // Submit a task that returns an integer
    std::future<int> result_future = pool.submit([]() {
        // Simulate some work
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
        return 42;
    });

    // The .get() call will block until the task is complete
    int result = result_future.get();

    EXPECT_EQ(result, 42);
}

TEST(ThreadPoolTest, SubmitMultipleTasks) {
    ThreadPool pool(4);
    std::atomic<int> counter{0};
    int num_tasks = 100;

    // Submit 100 tasks that each increment a counter
    for (int i = 0; i < num_tasks; ++i) {
        pool.submit([&counter]() {
            counter++;
        });
    }

    // The ThreadPool destructor will perform a graceful shutdown,
    // waiting for all tasks to complete.
    // So, we let the pool go out of scope here.
    // In a real scenario, you might have an explicit shutdown method.
    // Note: This test relies on the destructor's graceful shutdown.
    
    // We need to wait until the work is done. A simple but imperfect way:
    // A better way would be a dedicated wait/shutdown method.
    // For now, let's just let the destructor handle it.
    // After the pool is destroyed, all tasks should be complete.
    // (This requires our graceful shutdown to be correct!)
    // A robust test would use a barrier or count-down latch.
    
    // Let's refine this to be more robust for the test.
    {
        ThreadPool pool_local(4);
        for (int i = 0; i < num_tasks; ++i) {
            pool_local.submit([&counter]() {
                counter.fetch_add(1, std::memory_order_relaxed);
            });
        }
    } // pool_local is destroyed here, graceful shutdown runs.

    EXPECT_EQ(counter.load(), num_tasks);
}

The second test is particularly interesting because its correctness depends entirely on the graceful shutdown mechanism we implemented in the last lesson. If the destructor didn't wait for all tasks to finish, the EXPECT_EQ would likely fail. This demonstrates how unit tests can validate the interaction between different features.


Conclusion

Congratulations! You have successfully set up a professional testing environment for your project. This is a massive step towards building a reliable, production-quality library. You are no longer just hoping your code works; you are systematically proving it.

Key Takeaways:

  • Unit tests are crucial for verifying concurrent code by isolating components.
  • Google Test can be integrated cleanly with CMake using FetchContent.
  • A clean project structure separates production code (src, include) from test code (tests).
  • Test Fixtures (TEST_F) are used to share common setup code for multiple tests, ensuring isolation.
  • ASSERT_* macros should be used for fatal conditions that must pass for the test to continue, while EXPECT_* is for non-fatal checks.
  • Testing concurrent behavior often requires orchestrating multiple threads within the test itself, using synchronization primitives like std::promise to avoid flaky tests.

Next Lesson Preview:

With our core logic validated by tests, we can now confidently build upon it. In the next lesson, we'll start adding advanced features to make our thread pool truly stand out for your portfolio. We will begin by exploring how to implement dynamic thread pool sizing, allowing the pool to add or remove worker threads based on the current workload.