Creating Bazel C++ project with Google Test & Google Benchmark

Check out this blog post on creating a Bazel C++ project with Google Test and Google Benchmark! πŸš€ Learn how to leverage these powerful tools for efficient testing and performance analysis in your projects.

Creating Bazel C++ project with Google Test & Google Benchmark

It has been quite a long time since I wrote a thing in C++ language. Such a long break allowed me to explore different languages and technologies and see what "the other" world looks like. I have to admit that I am really thrilled with how fast one can have a working application written in JAVA. I also admire building systems and how everything can be automated. Maybe Ant or Maven is not my first choice, but Gradle is pretty neat. It is not light-speed fast, but the single command can get needed dependencies, compile everything and produce something executable. Need another dependency? You just add a few lines in Gradle files. No need to separately download, compile and install them in the operating system. And that's pretty charming. I believe that tools such as Make or CMake are pretty nice but are quite mature as a lot of time has passed since their initial release. And IDEs? Hmmm... Eclipse? It has nice features but so so slow (it's JAVA, right? πŸ˜‚). QtCreator? Fewer features than Eclipse, but pretty stable though. CLion? Well, it's a nice but paid application. So maybe the old school way - grep, sed, vim, and ctags? Such a pain in the ... neck. But actually, I know people who still do that. 🀣 So I started to wonder how things have changed. I decided to experiment with the build system used by Google called Bazel.

Build with Bazel

As per the authors' claim, Bazel gives the ability to build and test software of any size, quickly and reliably. Big tech companies like Google, Str,ipe and Dropbox use Bazel to build heavy-duty, mission-critical infrastructure, services, and applications. For more information please refer to Bazel's website.

Bazel
Use the Bazel Open Source Project to scalably build and test massive, multi-language, multi-platform codebases.

Bazel can be installed on macOS using homebrew as follows.

brew install bazel

Once installation completes, we should be able to issue bazel command in the terminal.

❯ bazel
WARNING: Invoking Bazel in batch mode since it is not invoked from within a workspace (below a directory having a WORKSPACE file).
                                                  [bazel release 5.3.2-homebrew]
Usage: bazel <command> <options> ...

Available commands:
  analyze-profile     Analyzes build profile data.
  aquery              Analyzes the given targets and queries the action graph.
  build               Builds the specified targets.
  canonicalize-flags  Canonicalizes a list of bazel options.
  clean               Removes output files and optionally stops the server.
  coverage            Generates code coverage report for specified test targets.
  cquery              Loads, analyzes, and queries the specified targets w/ configurations.
  dump                Dumps the internal state of the bazel server process.
  fetch               Fetches external repositories that are prerequisites to the targets.
  help                Prints help for commands, or the index.
  info                Displays runtime info about the bazel server.
  license             Prints the license of this software.
  mobile-install      Installs targets to mobile devices.
  print_action        Prints the command line args for compiling a file.
  query               Executes a dependency graph query.
  run                 Runs the specified target.
  shutdown            Stops the bazel server.
  sync                Syncs all repositories specified in the workspace file
  test                Builds and runs the specified test targets.
  version             Prints version information for bazel.

Getting more help:
  bazel help <command>
                   Prints help and options for <command>.
  bazel help startup_options
                   Options for the JVM hosting bazel.
  bazel help target-syntax
                   Explains the syntax for specifying targets.
  bazel help info-keys
                   Displays a list of keys used by the info command.

Now we need to create a workspace for our application and at the same time, I strongly advise you to version it, however, I will skip that part. So let's create a new directory and create WORKSPACE.bazel file in it.

❯ mkdir bazel-sandbox
❯ cd bazel-sandbox
❯ touch WORKSPACE.bazel
πŸ’‘
Please note that a .bazel extension is not required - it can be omitted.

Issuing the build command will start the local server and run the build.

❯ bazel build
Starting local Bazel server and connecting to it...
WARNING: Usage: bazel build <options> <targets>.
Invoke `bazel help build` for full description of usage and options.
Your request is correct, but requested an empty set of targets. Nothing will be built.
INFO: Analyzed 0 targets (0 packages loaded, 0 targets configured).
INFO: Found 0 targets...
INFO: Elapsed time: 2.172s, Critical Path: 0.02s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action

So good so far, Bazel is working, but it has nothing to build. Now we can add some C++ code and configure targets.

Hello world

Let's start with creating a directory structure and a simple hello world application.

❯ mkdir main
❯ cd main
❯ touch main.cc
❯ touch BUILD.bazel

Now we can print "Hello, world!" in main.cc.

#include <iostream>

int main(int argc, char* argv[]) {
    std::cout << "hello, world!" << std::endl;
    return 0;
}

main/main.cc

To be able to run the build in Bazel, we have to create now BUILD.bazel file with a defined executable target.

cc_binary(
    name = "main",
    srcs = ["main.cc"],
)

main/BUILD.bazel

Having those artifacts we are ready to go with the build.

❯ bazel build main:main
INFO: Analyzed target //main:main (36 packages loaded, 166 targets configured).
INFO: Found 1 target...
INFO: From Linking main/main:
ld: warning: -undefined dynamic_lookup may not work with chained fixups
Target //main:main up-to-date:
  bazel-bin/main/main
INFO: Elapsed time: 19.891s, Critical Path: 1.23s
INFO: 8 processes: 6 internal, 2 darwin-sandbox.
INFO: Build completed successfully, 8 total actions

Build has finished and produced executable binary! We can also run it using Bazel.

❯ bazel run main:main
INFO: Analyzed target //main:main (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
Target //main:main up-to-date:
  bazel-bin/main/main
INFO: Elapsed time: 0.156s, Critical Path: 0.00s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Build completed successfully, 1 total action
hello, world!

hello, world indeed! :)

That has been pretty easy and straightforward. However, in real-life cases, a project won't be as simple as this example and we will probably have multiple libraries that compile and link to the executable. So let's try with multiple targets.

Multi-target project

First, let's create a lib directory in the project's root and add the necessary files there.

❯ mkdir lib
❯ cd lib
❯ touch BUILD.bazel
❯ touch word_generator.h
❯ touch word_generator.cc

We will create a word generator that will provide hello, world! :) Let's start with the header file.

#pragma once

#include <memory>
#include <string>

namespace sandbox {

struct word_generator {
    virtual ~word_generator() = default;

    virtual std::string next() const = 0;
};

std::unique_ptr<word_generator> create_generator();
};

lib/word_generator.h

And implementation of course.

#include "word_generator.h"

namespace sandbox {
namespace {

class helloworld_generator : public word_generator {
public:
    std::string next() const {
        return "hello, world!";
    };
};
}

std::unique_ptr<word_generator> create_generator() {
    return std::make_unique<helloworld_generator>();
};
}

lib/word_generator.cc

We have created helloworld_generator that implements word_generator and provides a "hello, world!" string. The last bit is to provide the Bazel build file.

load("@rules_cc//cc:defs.bzl", "cc_library")

cc_library(
    name = "lib",
    hdrs = glob(["**/*.h"]),
    srcs = glob(["**/*.cc"]),
    visibility = ["//main:__pkg__"],
    copts = [
        "-std=c++20",
    ],
)

lib/BUILD.bazel

First of all, we have to load the cc_library module and then use it to create lib. We provide a name for it, paths for headers and source files as well as define its visibility (i.e. it will be visible to the main module only) and provide additional compilation parameters. In our case, we enable C++20 features.

Now we will slightly modify our main module to make use of our brand-new generator.

#include <iostream>
#include "lib/word_generator.h"

int main(int argc, char* argv[]) {
    auto&& generator = sandbox::create_generator();
    std::cout << generator->next() << std::endl;
    return 0;
}

main/main.cc

Now we use the generator to provide the string for output. Next, we have to update the module's dependencies in BUILD.bazel file.

cc_binary(
    name = "main",
    srcs = ["main.cc"],
    copts = [
        "-std=c++20",
    ],
    deps = [
        "//lib:lib",
    ],
)

main/BUILD.bazel

We added deps section to provide explicitly what's needed to create an executable binary. Additionally, we build the main module using the C++20 standard. Now we can rerun the build.

❯ bazel build main:main
INFO: Analyzed target //main:main (37 packages loaded, 169 targets configured).
INFO: Found 1 target...
INFO: From Linking main/main:
ld: warning: -undefined dynamic_lookup may not work with chained fixups
Target //main:main up-to-date:
  bazel-bin/main/main
INFO: Elapsed time: 17.007s, Critical Path: 10.52s
INFO: 12 processes: 8 internal, 4 darwin-sandbox.
INFO: Build completed successfully, 12 total actions

And then execute the binary.

❯ bazel run main:main
INFO: Analyzed target //main:main (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
Target //main:main up-to-date:
  bazel-bin/main/main
INFO: Elapsed time: 0.137s, Critical Path: 0.00s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Build completed successfully, 1 total action
hello, world!

So good so far. We have a main target, a lib, now let's add a target for testing.

Integrating Google Test

Let's start with creating a dedicated directory for tests.

❯ mkdir test
❯ cd test
❯ touch word_generator_test.cc
❯ touch BUILD.bazel

We have to write a simple test that will verify whether provided string is correct.

#include "gtest/gtest.h"
#include "lib/word_generator.h"

namespace sandbox {
TEST(hello_world_test, hello) {
  auto&& generator = sandbox::create_generator();
  EXPECT_EQ(generator->next(), "Hello, world!");
};
}

test/word_generator_test.cc

To be able to run tests, we have to create now BUILD.bazel file with a defined executable test target.

cc_test(
    name = "test",
    srcs = ["word_generator_test.cc"],
    copts = ["-Iexternal/gtest/include"],
    deps = [
        "//lib:lib",
        "@gtest//:gtest_main",
    ],
)

test/BUILD.bazel

We name the target test, provide word_generator_test.cc as the source and refer to the external dependency which is Google Test. Now we have to get the dependency. Therefore we will update WORKSPACE.bazel file.

load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")

http_archive(
    name = "gtest",
    url = "https://github.com/google/googletest/archive/release-1.10.0.zip",
    sha256 = "94c634d499558a76fa649edb13721dce6e98fb1e7018dfaeba3cd7a083945e91",
    strip_prefix = "googletest-release-1.10.0",
)

WORKSPACE.bazel

Firstly we load http_archive to be able to fetch archives from GitHub for instance. Then we define one as gtest. strip_prefix is very useful as we don't pollute code with release name, but rather use gtest/gtest.h in code.

The last step is to update the lib module. We have to make it visible to the test module.

load("@rules_cc//cc:defs.bzl", "cc_library")

cc_library(
    name = "lib",
    hdrs = glob(["**/*.h"]),
    srcs = glob(["**/*.cc"]),
    visibility = [
        "//main:__pkg__",
        "//test:__pkg__"],
    copts = [
        "-std=c++20",
    ],
)

lib/BUILD.bazel

Having all those steps done, we are ready to execute the first test.

❯ bazel run test:test
INFO: Analyzed target //test:test (51 packages loaded, 667 targets configured).
INFO: Found 1 target...
INFO: From Linking lib/liblib.so:
ld: warning: -undefined dynamic_lookup may not work with chained fixups
INFO: From Linking external/gtest/libgtest_main.so:
ld: warning: -undefined dynamic_lookup may not work with chained fixups
INFO: From Linking external/gtest/libgtest.so:
ld: warning: -undefined dynamic_lookup may not work with chained fixups
INFO: From Linking test/test:
ld: warning: -undefined dynamic_lookup may not work with chained fixups
Target //test:test up-to-date:
  bazel-bin/test/test
INFO: Elapsed time: 6.707s, Critical Path: 6.02s
INFO: 35 processes: 15 internal, 20 darwin-sandbox.
INFO: Build completed successfully, 35 total actions
INFO: Build completed successfully, 35 total actions
exec ${PAGER:-/usr/bin/less} "$0" || exit 1
Executing tests from //test:test
-----------------------------------------------------------------------------
Running main() from gmock_main.cc
[==========] Running 1 test from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 test from hello_world_test
[ RUN      ] hello_world_test.hello
test/word_generator_test.cc:7: Failure
Expected equality of these values:
  generator->next()
    Which is: "hello, world!"
  "Hello, world!"
[  FAILED  ] hello_world_test.hello (0 ms)
[----------] 1 test from hello_world_test (0 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test suite ran. (0 ms total)
[  PASSED  ] 0 tests.
[  FAILED  ] 1 test, listed below:
[  FAILED  ] hello_world_test.hello

 1 FAILED TEST
external/bazel_tools/tools/test/test-setup.sh: line 351: 16637 Killed: 9               sleep 10

And the test fails! πŸ˜‚

Integrating Google Benchmark

C++ is mainly used to write highly efficient, high-performance applications. So during development, it happens that we want to compare different implementations and choose a faster one. To address that need really neat library has been created, called Google Benchmark.

Let's modify word_generator a bit and introduce another method that will provide the ability to create a string with multiple hello, world!s.

struct word_generator {
    virtual ~word_generator() = default;

    virtual std::string next() const = 0;

    virtual std::string next(unsigned times) const = 0;
};

lib/word_generator.h

Now the most straightforward way to implement the method is to call the next() method a couple of times.

class helloworld_generator : public word_generator {
public:
    std::string next() const {
        return "hello, world!";
    };

    std::string next(unsigned times) const {
        std::string output;
        while(times) {
            output += next();
            times--;
        }
        return output;
    }
};

lib/word_generator.cc

Again, let's create a dedicated directory for a benchmark test.

❯ mkdir benchmark
❯ cd benchmark
❯ touch benchmark_test.cc
❯ touch BUILD.bazel

We have to write a simple benchmark - we measure the cost of generating multiple strings. We will start by passing 1 into the method and ending with 100. The range multiplier is set to 2, so measurements will be taken for the following numbers: 1, 2, 4, 8, 16, 32, 64, and 100.

#include "benchmark/benchmark.h"
#include "lib/word_generator.h"

static const auto generator = sandbox::create_generator();

static void benchmark_helloworld_generator(benchmark::State& state) {
  for (auto _ : state) {
    benchmark::DoNotOptimize(generator->next(state.range(0)));
  }
}

BENCHMARK(benchmark_helloworld_generator)->RangeMultiplier(2)->Range(1, 100);

benchmark/benchmark_test.cc

To be able to run the benchmark, we have to create now BUILD.bazel file with a defined executable test target.

load("@rules_cc//cc:defs.bzl", "cc_test")

cc_test(
    name = "benchmark",
    srcs = ["benchmark_test.cc"],
    copts = ["-Iexternal/benchmark/include"],
    deps = [
        "//lib:lib",
        "@benchmark//:benchmark_main",
    ],
)

benchmark/BUILD.bazel

Defining this test target is similar to Google Test one. The major difference here is we have to provide Google Benchmark as a dependency.

To get the Google benchmark dependency we will update WORKSPACE.bazel file again.

load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")

http_archive(
    name = "gtest",
    url = "https://github.com/google/googletest/archive/release-1.10.0.zip",
    sha256 = "94c634d499558a76fa649edb13721dce6e98fb1e7018dfaeba3cd7a083945e91",
    strip_prefix = "googletest-release-1.10.0",
)

http_archive(
    name = "benchmark",
    url = "https://github.com/google/benchmark/archive/refs/tags/v1.7.1.zip",
    sha256 = "aeec52381284ec3752505a220d36096954c869da4573c2e1df3642d2f0a4aac6",
    strip_prefix = "benchmark-1.7.1",
)

WORKSPACE.bazel

And the last step is to update the lib module and make it visible to the benchmark module.

load("@rules_cc//cc:defs.bzl", "cc_library")

cc_library(
    name = "lib",
    hdrs = glob(["**/*.h"]),
    srcs = glob(["**/*.cc"]),
    visibility = [
        "//main:__pkg__",
        "//test:__pkg__",
        "//benchmark:__pkg__"
    ],
    copts = [
        "-std=c++20",
    ],
)

lib/BUILD.bazel

Now we are ready to execute the benchmark.

❯ bazel run  benchmark:benchmark
INFO: Analyzed target //benchmark:benchmark (49 packages loaded, 655 targets configured).
INFO: Found 1 target...
INFO: From Linking lib/liblib.so:
ld: warning: -undefined dynamic_lookup may not work with chained fixups
INFO: From Linking external/benchmark/libbenchmark_main.so:
ld: warning: -undefined dynamic_lookup may not work with chained fixups
INFO: From Linking benchmark/benchmark:
ld: warning: -undefined dynamic_lookup may not work with chained fixups
Target //benchmark:benchmark up-to-date:
  bazel-bin/benchmark/benchmark
INFO: Elapsed time: 51.476s, Critical Path: 5.60s
INFO: 45 processes: 18 internal, 27 darwin-sandbox.
INFO: Build completed successfully, 45 total actions
INFO: Build completed successfully, 45 total actions
exec ${PAGER:-/usr/bin/less} "$0" || exit 1
Executing tests from //benchmark:benchmark
-----------------------------------------------------------------------------
2023-04-02T18:34:02+00:00
Running /private/var/tmp/_bazel_slysj/87594810adcd72e15cf8187345326b1b/execroot/__main__/bazel-out/darwin-fastbuild/bin/benchmark/benchmark.runfiles/__main__/benchmark/benchmark
Run on (16 X 2300 MHz CPU s)
CPU Caches:
  L1 Data 32 KiB
  L1 Instruction 32 KiB
  L2 Unified 256 KiB (x8)
  L3 Unified 16384 KiB
Load Average: 2.53, 2.06, 1.88
***WARNING*** Library was built as DEBUG. Timings may be affected.
-----------------------------------------------------------------------------
Benchmark                                   Time             CPU   Iterations
-----------------------------------------------------------------------------
benchmark_helloworld_generator/1          142 ns          142 ns      4823362
benchmark_helloworld_generator/2          293 ns          293 ns      2429181
benchmark_helloworld_generator/4          526 ns          526 ns      1339636
benchmark_helloworld_generator/8          985 ns          984 ns       699559
benchmark_helloworld_generator/16        1870 ns         1869 ns       371402
benchmark_helloworld_generator/32        3448 ns         3448 ns       204941
benchmark_helloworld_generator/64        6257 ns         6255 ns       113871
benchmark_helloworld_generator/100       9176 ns         9173 ns        80898

Increasing the number of repeated strings increases the execution time. And actually, that was expected. πŸ˜‚

Final remarks

Setting up a new project using Bazel is pretty straightforward. I really love the experience where you just add dependency and tooling does the rest for you, no need to download it or compile, etc. It simply worked with Google Test and Google Benchmark frameworks, but I am unsure how easy it is to integrate other libraries. One issue I came across was when I was trying to bump the Googe Test version to something more recent. The recent version requires at least C++14. Even though I set the language version correctly, Bazel was not able to pass this flag to external dependency and compilation failed.

GitHub - jakub-k-slys/bazel-cpp-sandbox
Contribute to jakub-k-slys/bazel-cpp-sandbox development by creating an account on GitHub.

What’s next?

If you are further interested in Bazel, grab the next post!

Bazel C++ 20 project with (micro) unit testing framework, FakeIt, and nanobench
Create well well-structured Bazel C++ 20 project incorporating custom libraries like (micro) Unit Testing Framework or FakeIt. Dig in! πŸš€