Using Bazel for an automated test suite

Umama Nasir
7 min readDec 2, 2023

An important component of shipping code to production is automated testing to ensure that the code works as expected. The use of automated tests increases in continuous delivery practices as new and newer versions of code are generated and continually tested.

An integral tool to aid in testing is Bazel. Bazel is an open source software tool developed by Google and used to automate software builds and test softwares for large projects with multi language dependencies.

Automated testing is an important component of software development. Not only does it allow us to save time and money by rapidly running tests, it improves software quality by allowing engineers to run lengthy and time consuming tests in the background.

This article will give an introduction to Bazel and introduce you to the basics of Bazels to allow you to run your own tests.

Why use Bazel

Time is of essence when running automated tests to aid in the continuous delivery process. Bazel is shown to have built and tested software swiftly and correctly. This is achieved through caching and parallel execution. Bazel rebuilds only the required files instead of the entire project. In case of large, resource-heavy projects, Bazel can greatly reduce time and resources required to build and run software.

Bazel caches all the passed tests. When these same tests are executed again, if no change is made to the file, they will be skipped and not run again. Parallel execution allows more efficient resource usage and increases throughput, running multiple jobs at the same time.

These features make Bazel an ideal choice to run automated tests, as it can greatly reduce run time and allow for code to be built and tested faster and thus ship to production faster as well.

Another feature of testing in Bazel is being able to specify a time limit or test timeout. If a test takes longer than the specific time, it is automatically aborted or failed. This can allow engineers to place checks and ensure various non functional requirements are met.

Another attractive quality of Bazel is that it can work with and build code for a variety of different languages. Unlike other software such as Maven, which is mainly designed for Java, Bazel can work with C++, Python, Android, and IOS development.

Bazel uses a common “workspace” that is shared among all projects in a monorepo. It also includes a powerful query language that can analyze build dependencies, a capability that is extremely useful when creating a centralized build system for a monorepo.

Bazel builds are reproducible which makes it reliable. Reproducibility refers to the idea that given the same input, a piece of code will always produce the same output. Reproducibility makes debugging more convenient. Bazel is also scalable. In fact, as mentioned previously, Bazel was designed to handle large projects. Thus the tool can easily cater to huge codebase. Although it advocates for the monorepo pattern, Bazel can also work with microservice architecture thus further supporting scalability.

Setting up bazel

  1. Download Bazel from Github.
  2. Get Bazel extensions for VS Code.
  3. Open an empty folder in VS code and create a WORKSPACE.bazel & a BUILD.bazel file.
  4. Run the command :

./bazelisk build //:all

or

./bazel build //:all

Whether you run the first or the second command depends on the exe file you download. Generally, for Windows or MacOS, you should download Bazelisk, as recommended by the creator.

Please note that the tutorial will run on Windows 10.

Understanding Bazel

Bazel tests have rules. A rule defines a series of actions that Bazel performs on inputs to produce a set of outputs. For a rule, you have an input, an action and an output. We must specify the input and output for each rule in Bazel.

An action describes how to generate an output from an input.

In addition to user inputs to a rule, you must also consider the libraries and tools required to execute the rule.

Each call to build rules returns no values but creates a new target. Targets often involve rules that govern the relationship between your input files and output files. That means the target will specify how Bazel will create the build files.

Rules are written in the .bzl file and loaded into the BUILD file.

You might be wondering, how to create rules? Let us now look into that:

Empty Rule

Create a file titled my_custom_rules.bzl.

Inside your file, create an empty rule:

def empty_rule_impl(ctx):
pass
empty_rule = rule(
implementation = empty_rule_impl,
)

As you can see, when you call the rule function, you must define a call back function empty_rule_impl in this case, where the logic will be. For now, it is empty, however, we can make changes to it later.

ctx provides information about the target.

All rules need an implementation function. The implementation function is where the actual logic of the rule is, however note that function cannot read or write to files. Rather, it’s mainly used to emit actions that will run later.

The implementation function takes one input, context or ctx. Context can be used to obtain handles on declared input and output files; access attribute values, pass information to other targets that depend on this target, or create actions.

In order to load and use the rule, you have to add it to the BUILD file. To do that, you write the code below:

load("//:my_custom_rules.bzl", "empty_rule")
empty_rule(name = "emp")

The first part of load refers to the file, while the second part refers to the specific rule.

The name will be used to identify each instance of when the rule is used.

To now build the target for this specific rule, we run the following command in the terminal:

./bazelisk build //:emp

Rule to create file

Let us now try and do something useful with all that we have learned above.

Delete the previous rule and create a new empty rule that looks like this:

def write_new_file_impl(ctx):
pass
write_new_file = rule(
implementation = write_new_file_impl,
)

Now let us update the rule above so that it generates a file.

In Bazel, we also have to specify how to generate the file. Our file name is the same as the target.

Let us now make a few more changes to the rule function:

write_new_file = rule(
implementation = write_new_file_impl, #implementation is in funcion above.
attrs = {
'my_input_file': attr.label(allow_single_file = True),
})

Make changes to the implementation function as below:

def write_new_file_impl(ctx):
out = ctx.actions.declare_file(ctx.label.name)
ctx.actions.write(
output = out,
content = "Hello\n",
)

Now if you run the terminal again, it will run however you will notice the file is not created. Bazel will not create the output file until we explicitly mention it.

So the above implementation code changes to:

def write_new_file_impl(ctx):
out = ctx.actions.declare_file(ctx.label.name)
ctx.actions.write(
output = out,
content = "Hello\n",
)
return DefaultInfo(files = depset([out]))

Now we will have a file output. The file will be in the bazel-out > x64_windows-fastbuild > bin.

Working more with files

Let us keep working with files in Bazel. We want to modify our code such that it creates a new file and copies the contents from an input file into the newly generated file.

First, let us make a few more changes to the rule function:

write_new_file = rule(
implementation = write_new_file_impl, #implementation is in funcion above.
attrs = {
'my_input_file': attr.label(allow_single_file = True),
'out_file_name' : attr.string()
})

Attrs is the attribute in the rule and it acts as a rule argument.

Our output file name and the input file will now be given as an input by the user.

Let us update the callback function as well. We want the code to copy the contents of the input file into the output file.

To do so, update the code as shown below:

def write_new_file_impl(ctx):
output_file = ctx.actions.declare_file(ctx.attr.out_file_name + '.txt')
ctx.actions.run(
outputs = [output_file],
inputs = [ctx.file.my_input_file],
executable = "cmd.exe",
arguments = ["/c", "type", ctx.file.my_input_file.path,">>", output_file.path ]
)
return DefaultInfo(files = depset([output_file]))

ctx.actions contain methods for declaring output files and the actions that produce them.

The arguments parameter of ctx.actions.run tells Bazel what the command line for the executable of the action is.

The inputs parameter tells Bazel what files to make available to the executable of the action when Bazel runs it.

outputs specify the output file whereas the executable specifies how to execute the action.

We create a sample input file, titled file.txt in the main directory and write some sentences in the file.

We also need to make appropriate changes to the BUILD file to give the input file path and output file name.

load("//:my_custom_rules.bzl", "write_new_file") #load rule.
write_new_file(
name = 'write_custom_msg_into_file',
my_input_file = '//:file.txt',
out_file_name = 'output_file'
)

Now if you build your rule:

./bazelisk build //:write_custom_msg_into_file

The output_file will be created as shown below:

And the contents of the file will be:

Conclusion

As we have seen, Bazel is an extremely fast and reliable tool to allow automated tests. It supports multiple languages. This can be useful if the app is created on different operating systems in different languages and thus we only have to write the code once. Bazel allows users to define rules which can be used to test the application.

It can also define custom rules and can thus support an even larger number of rules and have even more flexibility.

--

--