[C++] Typed Tests For Interfaces With Googletest

Find this example here on GitHub.

 

It's always pretty nice to have encapsulated code where interfaces are used properly. And even better: When the unittests as clean as the according code. Therefore I'm writing about typed tests with Googletest.

Some information upfront about this article:

  • We are creating an interface for a book store to read books from a configuration file (similar to the example from my last article)
  • Assume we know all derived classes
  • All derived classes shall run the same test suite
  • This article is about setting up the test environment for the interface
  • For better reading I simplified the code snippets in this article

Example Test Case

We are using XML and JSON configuration files to add books to a book store. Therefore we are creating two configuration readers: xml_book_reader and json_book_reader. Both inhereting from book_reader.

To keep this example simple:

  • To add books void add_books() needs to be implemented
  • All books are stored in a map books in the base class book_reader
  • book_reader has implemented public getters to access added books
 

The interface and the book data struct

For this example I defined a Book with the following members and below the book_reader interface.

struct Book {
    std::string author;
    std::string title;
    int published;
};
class book_reader {
protected:
    std::unordered_map<std::size_t, Book> books;
public: 
    virtual ~book_reader() = default;
    
    virtual void add_books() = 0;

    Book get_book(const std::size_t id) const;
    const auto get_books() const noexcept;
};
 

Derived xml_book_reader and json_book_reader

Now we have the derived classes xml_book_reader and json_book_reader. Note that in each constructor we pass the according xml/json file. Both of them implement the add_books() method to parse the desired configuration file.

 // xml_book_reader.hpp 
class xml_book_reader : public book_reader {
private:
    boost::property_tree::ptree pt;
public: 
    xml_book_reader(const std::string& file);
    void add_books() override;
};

// json_book_reader.hpp
class json_book_reader : public book_reader {
private:
    boost::property_tree::ptree pt;
public: 
    json_book_reader(const std::string& file);
    void add_books() override;
};
 

Test Data

We have one xml configuration and one json configuration. Both contain the same books. These two files will be read in our unittest.

<!-- example.xml -->
<books>
    <book>
        <id>123456</id>
        <author>Buzz Michelangelo</author>
        <title>The Story Of Buzz Michelangelo</title>
        <published>2019</published>
    </book>
    <book>
        <id>456789</id>
        <author>Moxie Crimefighter</author>
        <title>How To Fight Crimes</title>
        <published>2005</published>
    </book>
</books>
// example.json
{
    "books": [
        {
            "id": "123456",
            "author": "Buzz Michelangelo",
            "title": "The Story Of Buzz Michelangelo",
            "published": "2019"
        },
        {
            "id": "456789",
            "author": "Moxie Crimefighter",
            "title": "How To Fight Crimes",
            "published": "2005"
        }
    ]
}

In addition we need sort of ground truth data, to verify if the xml/json parsing works correct. We create the namespace book_tests, where we have the same two books as test_book in a container expected_books

namespace book_tests {
    const std::string xml_file{"/absolute/path/to/example.xml"};
    const std::string json_file{"/absolute/path/to/example.json"};

    struct test_book {
        std::size_t id;
        Book book;
    };
    const std::map<std::size_t, Book> expected_books = {
        {123456, Book{"Buzz Michelangelo","The Story Of Buzz Michelangelo",2019}},
        {456789, Book{"Moxie Crimefighter","How To Fight Crimes",2005}}      
    };
} 

This file is created with the a template by CMake's configure_file(..). With that we can run the unittests independently from the working directory and machine. CMake will take care of this file.

 

Setting Up The Test Suite

First of all we need functions to create concrete book_readers. Note that the xml_book_reader receives the xml file and the json_book_reader the json file in it's constructor.

template <class T>
std::unique_ptr<book_reader> create_book_reader();

template <>
std::unique_ptr<book_reader> create_book_reader<xml_book_reader>() {
  return std::make_unique<xml_book_reader>(book_tests::xml_file);
}
template <>
std::unique_ptr<book_reader> create_book_reader<json_book_reader>() {
  return std::make_unique<json_book_reader>(book_tests::json_file);
}

Then we continue with the test fixture class. This class inherits from testing::Test to overwrite the SetUp() and TearDown() method. The templated type will be xml_book_reader and json_book_reader.

In the constructor we call the just created function create_book_reader with respect to the templated type. Class member books is then set to xml_reader or json_reader.

template <class T>
class book_reader_tests : public testing::Test {

protected:
    book_reader_tests() : books(create_book_reader<T>()) {}
    
    void SetUp() override {
        books->add_books();
    }
    void TearDown() override {
        books.reset();
    }
    std::unique_ptr<book_reader> books;
};

We are almoste done setting up the test suite, we need to list all the types we want to test, by creating a type definition which is passed to Googletest:

typedef Types<xml_book_reader, json_book_reader> Implementations;
TYPED_TEST_SUITE(book_reader_tests, Implementations);
 

Writing The Actual Unittest

Now we are done setting up the test suite. All tests we'll write will performed on the xml_reader and the json_reader.

The test signature has to be: TYPED_TEST(TestCaseName, TestName).

To illustrate this example I added one test to the test suite:

  1. Add all books from the configuration
  2. Loop over all books which shall be added (from book_tests::expected_books)
  3. Get the book from the book_container
  4. Verify if the book is the expected one

I printed some output to the terminal to visualize the Book content.

TYPED_TEST(book_reader_tests, find_all_books) {
    const auto books_container = this->books->get_books();
    
    for (const auto& expected : book_tests::expected_books) {
        
        const auto found = books_container.find(expected.first);

        std::cout << "expected book: " << expected.second.author << '\n';
        std::cout << "found book: " << found->second.author << "\n\n";

        EXPECT_EQ(expected.first, found->first);
        EXPECT_EQ(expected.second, found->second);       
    }
}

And now sit back and relax. We can run all tests with all classes which inherit from book_reader.

We can see both tests running. XML and JSON files are parsed and checked against the hardcoded test books. If we’d have errors in the XML/JSON parsing the test would fail.

Conclusion

From my point of view there is no discussion about it: When you have interfaces, test the interfaces and run the same tests for all derived classes. Also in the long run, when other concrete implementations are needed or some old ones are replaced, you always can use the same test suite. For some more information about the typed test, check out googletests repository.

Find this example here on GitHub.

That's it for now.

Best Thomas

Previous
Previous

[C++] An Eventsystem Without std::bind / std::function

Next
Next

Use Frameworks But Don’t Marry Them