Test-driven development is a great way to preserve the freedom to refactor your code as your project grows in complexity.
Whenever you find yourself pondering a question like below, TDD gives you a set of tests to run after every change -- minor or major:
I think I could use this trick to handle all those cases with a single, well-defined abstraction. Can I be sure, though, I will handle every single important case right this way?
Whenever you find a test doesn't run as planned, you have the choice to either fix the code, or update the tests to consciously change the behavior of the code -- perhaps letting its users know that a change has been made, and the reasons behind the change.
TDD is also possible in C.
First, create the following Makefile:
out_file=test_lib base_path=https://github.com/doctest/doctest/releases/latest/download doctest_url=$(base_path)/doctest.h
all: test
test: doctest.h $(out_file)
./$(out_file); rm $(out_file)
doctest.h:
wget -cq $(doctest_url)
$(out_file):
clang++ $(out_file).cpp -o $(out_file)
Note: sequences of 4 spaces in a row should be changed to single tab characters for Make to be able to interpret the file correctly. It likely won't work otherwise!
Next, write the following test file:
//usr/bin/clang++ -o ${0%.*} "$0" && ./${0%.*}; rm -f ${0%.*}; exit
// test_lib.cpp
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"
extern "C" {
#include "lib.h"
}
TEST_CASE("Returns a string.") {
CHECK(hello() == "Hello, world!");
}
Finally, create the lib file lib.h
:
// lib.h
#include<stdio.h>
const char* hello() {
return "Hello, world!";
}
Why const char*
? Flavio Copes explains [1]:
Strings in C are arrays of char elements, so we can’t really return a string - we must return a pointer to the first element of the string.
This is why we need to use
const char*
.
To run the test:
- Make the main test file executable:
chmod +x test_lib.cpp
. - Run
make
to download the most recent release ofdoctest.h
. - Run
./test_lib.cpp
.
Result will be similar to:
Running via ./test_lib.cpp
doesn't work? With the Makefile
presented above, you can also run the tests with the command make
.
Why use DocTest for this?
I know DocTest as the lightest, fastest, and most convenient C++ testing library available. For this reason, I consider it perfect for getting started.
Whenever you find DocTest's features don't satisfy you, you can switch to Google Test or another framework.
Changing the test framework should be easier for you than writing all the tests after developing a library without tests at all.
DocTest is a C++ testing library. Why are we using it to test a program in C?
I haven't yet found a satisfactory test framework for just C.
What I found to be recommended by Tyler Hoffman, was to use a mature C++ testing framework instead [2].
Why use the
./test_lib.cpp
for running the tests?
Without this construct, you'd need to run make && ./test_lib
every time. I found this rather tedious for initial development.
Once you have a considerable amount of code to compile, you remain free to switch to using more "natural" calls like make && ./test_lib
for invoking the tests.
I work on a large project in C/C++ and most of the code is already there. I was asked to extend the functionality of that code. Is there any space for TDD in such a project?
Good question! You can't always load the entire project into your test framework. What you can do, is to start with TDD for the code you're going to add.
Once you ship the code, archive it together with the tests. They will be your personal specification for the code you added, and will be very useful when the user returns to you asking for changes on that.
The main program can remain untested for now. With time, if you turn out to have contributed a range of updates to it, you will find that the project now has a pretty comprehensive test suite. That should be your aim.
Wouldn't it be a waste of time to write tests for a program when the user doesn't require or endorse them?
It depends. If your goal is to develop the program and be done with it, without consideration for the person that receives future requests to fix/update it, you'll probably spend much less time developing it without unit tests.
If you suspect you may be the person needing to fix/update it in the future, or would like somebody else to have runnable examples to run on your code to check it behaves as it should, then using TDD makes more sense than adding tests later.
That is because writing the test first is a form of "beginning with the end in mind", listed among the "7 Habits of Highly Effective People". This way, you start from the perspective of your user. This means that the code you will write will be convenient for the user and testable, before it is optimized from the programmer's perspective.
Having a well-defined interface, you are free to go on optimizing the internals of your code. While optimization can be carried on indefinitely, an interface is either convenient, or not.
By using TDD, you increase the chances the interface will be convenient to you first, and this in turn should serve the users much better than code that only rarely gets run from the user's perspective.