TDD How-to: Get your Legacy C Into a Test Harness

You are getting started with TDD, but have existing code? You want to get some of your challenging C/C++ code under test? You have run into some apparent show stoppers? Don't give up! This article contains a step by step recipe to help get your code into a test harness. It also contains a series of C/C++ code problems that get in the way of unit testing. Each problem named comes with one or more suggested solutions. Also, many of the solutions provide links to articles with more detail.

Many of the problems described arise from trying to get embedded systems code that has only been compiled with the target hardware cross-compiler, to compile off-target. These problems are not unheard of for non-embedded C/C++, so any C/C++ programmer can get some insight into getting your legacy code under test using this approach. Test problems come from dependencies, so If you can relate the C/C++ specific advice to your language, there is something here for non-C programmers as well.

To help you get started, I've created a CppUTest Starter Kit on github that you can use to help get started. The started project describes how to setup your environment and has example tests, code, and mocks.

The approach the solving these problems follows the Crash to Pass algorithm, described in my book and in the linked article. To summarize, Crash to Pass describes a step by step approach to getting problem code under test.

  • Create a test file. Build it; force a failure; force success. You are ready to go
  • Adjust your error output. Your test build should be set to only report the first error. This saves on scrolling and accidentally chasing a side-effect error.
  • Choose the function you want to test. You C++ programmers, choose the class to create.
  • Call the code to test from the test case. Don't worry about initialization, yet.
  • Make the test file compile. This can take a while an be discouraging in a crusty old code base. Getting the test to compile means adding #includes and adjusting the include path in the unit test build. This can be frustrating.
    • Fix problems one at a time; only fix the first error that is staring you in the face. Try to take satisfaction in changing the error message.
    • Don't panic! Look at the Road Blocks below as you discover problems you want some help with.
    • You know you are done with 'make it compile' (probably temporarily) once you get a linker error.
    • Why start with compiling the test case instead of the production code? It is a smaller first step. The dependencies needed for compiling a call to the production code from the test case is a subset of the dependencies needed to compile the production code. You may discover some show stoppers too.
  • Make the code link. The first pass through this you will need to compile the source file that contains the function you are trying to test. You will likely cycle back to 'make the test file' compile. Once its compile time dependencies are satisfied, you are rewarded with more linker errors.
  • Stub problem dependencies or compile depended upon production code. When stubbing, do the simplest stub that satisfies the compiler and linker
    • Make the dumbest fake like int my_problem_depedndency(int p) { return -1; } .
    • Use my exploding fake generator. Its a bash script you can find in my github CppUTest Starter Kit. It can create an 'exploding fake' for each unresolved external reference.
    • You know you are done once the test builds and runs; you will likely see the test crash.
  • Track down the crash. Why am I such a pessimist? You should have ignored initialization up to this point. So if you choose code with dependencies a crash is a likely outcome for a C or C++ program. Do the needed initialization, again solving one crash at a time.
    • You know you are done once you get the test runner to say OK.
  • Finally, make the test more interesting.
    • Add some checks to you test. Limit the focus of the test
    • Copy, Paste, THINK! As you add the next test, don't just copy, paste, tweak. Make sure to think, extracting common helper functions with descriptive names.
    • You know you are done... Left as an exercise to the reader. Congratulations if you got this far.
  • Tell me your story

Road Blocks -- Don't Panic

Along the way, you may run into one of more of these specific road-blocks to getting your code under test.

Problem - Non-portable Header File

The legacy code depends on a target specific header file that won’t compile off target.

Solution

Introduce a #include Test Double See #include Test Double. By the way, always prefer to use the real header. Only do this after discovering that using the real header would require changing it. If that file is from a third party, changing it is not really sustainable.

Problem - Header File Leads to Dependency Explosion

The legacy code depends on a header file with many outgoing problem dependencies that the code under test needs very little from.

Solution

Introduce a #include Test Double Again, prefer the real header, fake it if you must. See #include Test Double

Problem - Non-standard Keywords

The legacy code uses non-standard keywords that won’t compile off-target

Solution

Make the non-standard keywords go away with a forced include. See Hiding Non-standard C Keywords for Off-Target Testing.

Problem - asm

The legacy code has asm instructions that won’t compile off-target

Solution - 1

Make asm go away using forced include

Solution - 2

Introduce an AsmSpy to capture the instruction stream and check it in a test case See Spying on Embedded ‘asm’ directives

Problem - OS Dependency

The legacy code interacts with OS concurrency functions.

Solution

Create stub implementation for the OS calls. Keep the stubs very simple at first. Then evolve them to meet the needs of the test case. See three part article series: Unit testing RTOS dependent code – RTOS Test-Double

Problem - #pragma

The legacy code has #pragma instructions that won’t compile off-target

Solution

Adjust the compiler settings to ignore unknown #pragmas. For gcc use: CFLAGS += -Wno-unknown-pragmas

Problem - restrict keyword won't compile with gnu g++

The legacy code uses restrict and won’t compile in a C++ test case

Solution

Adjust the gnu g++ compiler settings to : CXXFLAGS += -Drestrict=__restrict__

Problem - statics are making testing difficult

You have static functions and data, that your tests can't see. If you need to directly access hidden functions and data, your code is telling you it is not modular. But you first have to add tests before you change it.

Solution - 1

Use preprocessor to make static go away. Now there are in the global namespace, but are not advertised in a header file.

Solution - 2

Create a test case and #include the c file in the test, giving full access. See Accessing static Data and Functions in Legacy C — Part 1.

Solution - 3

Create a test adaptor that #includes the c file. See Accessing static Data and Functions in Legacy C — Part 2.

Problem - Platform Dependencies

The legacy code calls library functions that are not available on the test platform.

Solution

Create a set of test stubs for the production code library. Link with that library for development system tests. Make some of the test doubles spies, mocks, etc. JIT as needed.

Problem - Hardware Dependencies

A legacy code file has a few functions that make testing difficult. They may have hardware or OS dependencies.

Solution

Extract the problem functions declarations into a separate header file. Include it from the original file. Extract the problem function implementations into a separate source file. Create a replacement source file made up of test stubs for the problem functions. Link with the replacement for test.

Problem - I need the real C function sometimes and test double other times

If you try to use the linker for test double substitution, you cannot have the original code in the same executable. You need the production code in some tests.

Solution

Create a function pointer with the same signature as the problem function. By default initialize the pointer with the problem function. Change clients to call through the function pointer. Override the function in the tests where needed.

Gotcha

Make sure code runs warning free, if the caller does not see the declaration, C will assume it is a direct function call.

Solution for Linux gcc

Use gcc linker wrapping. See option --wrap symbol of this Stack Overflow article.

Problems

  • Not available be universally available. It was not on Mac OSX, cygwin, or MinGW at the time of this writing.
  • Does not work for for public symbols referenced in the same compilation unit.

You've gotten this far, and none of these techniques helped

There is a limit to what can be done with the preprocessor and the linker.

Solution

Make careful changes to the code that enable testing or off-target compilation.

You've gotten this far, take The Legacy C Challenge

  • git clone and review the code from https://github.com/jwgrenning/problems-with-extended-c/releases/tag/v0.0
  • Make a list of the non-standard C extensions that will cause you trouble.
  • Name a solution to each problem
  • See a working solution
  • https://github.com/jwgrenning/problems-with-extended-c


Send me an email about how it went for you. Did you discover any new and interesting 'show-stoppers'? Tweet it, or tell your friends.

Published: April 06, 2014