Boost Test 嘗試整合 template 與 dataset

| | 0 Comments| 08:28
Categories:

年中的時候有整理了幾篇關於 Boost Test 這個建立 C++ 單元測試的函式庫的使用方法了,當時基本上是把簡單的使用情境都做了一些說明;當然,其實 Boost Test 還有很多東西,所以這邊基本上不太可能寫完,像是 fixture 這東西(官方文件)Heresy 就還沒有提過。 :p

而這一篇呢,則是 Heresy 這邊自己在想,如果有一個 template 的測試函式:

template<typename T1, typename T2, typename T3>
void test_func(T1 v1, T2 v2) {}

那要怎麼樣可以快速地針對不同的型別組合、還有數值資料的組合、來建立出多個 test case 呢?

雖然最後寫出來的東西感覺不是很實用,不過基本上還是來記錄一下吧~ XD


多型別的 template test case

首先,相較於資料導向的測試來說,Boost Test 在 template test case 提供的功能算是相對少的,而且似乎也沒辦法針對多組型別來展開,算是滿可惜的。

當然啦,這邊還是可以透過巢狀式的 std::tuple參考)來完成來多個型別的玩法,但是就是比較麻煩一點了。

下面算是一個玩法:

#define BOOST_TEST_MODULE example
#include <boost/test/included/unit_test.hpp>
#include <tuple>
#include <typeinfo>
 
typedef std::tuple<
  std::tuple<int, float>,
  std::tuple<int, double>,
  std::tuple<float, int>
> test_types;
 
BOOST_AUTO_TEST_CASE_TEMPLATE(TypedTest, T, test_types)
{
  using T1 = std::tuple_element<0, T>::type;
  using T2 = std::tuple_element<1, T>::type;
  
  std::cout << typeid(T1).name() << "/" << typeid(T2).name() << "\n";
  BOOST_TEST(sizeof(T1) == (unsigned)4);
}

透過這個方法,就可以處理多種型別的組合了。

而如果要更簡單地產生型別的排列組合其實也是做得到的,但是就是相對更麻煩一點了,所以這邊先跳過。


使用 C++ API 建立 test case

至於有沒有辦法把 dataset 和 template 混合使用呢?以 Boost Test 提供的巨集來看應該是沒辦法的。

所以如果真的有碰到這種需求,大概就得放棄這種以巨集為主的方法,而是改用 C++ API 來自己建立 test case 了~而這個過程…其實還滿麻煩的。

如果要自己建立 test case 的話,基本的範例會是像下面這樣:

#include <boost/test/included/unit_test.hpp>
using namespace boost::unit_test;
 
void test_case1() {}
 
test_suite* init_unit_test_suite(int /*argc*/, char* /*argv*/[])
{
  test_suite* ts1 = BOOST_TEST_SUITE("test_suite1");
  ts1->add(BOOST_TEST_CASE(&test_case1));
  ts1->add(BOOST_TEST_CASE_NAME([]() {}, "lambda"));
  return ts1;
}

在只有一個 test suite 的狀況下,只要定義 init_unit_test_suite() 這個函式、回傳 test_suite 的指標就可以了。

test_suite 的建立,則可以靠 BOOST_TEST_SUITE 這個巨集完成,之後再透過他的 add() 函式加入個別的 test case。

至於 test case 的建立,則可以透過 BOOST_TEST_CASE_NAME 來產生,如果不想自己命名的話,也可以改用 BOOST_TEST_CASE,這樣它會用傳入的可呼叫物件名稱作為 test case 的名字。

以上面的程式碼來說,產生出來的 test case 結構會是:

test_suite1*
    test_case1*
    lambda*

而如果有多個 test suite 的話,似乎則就需要自己先加到 master test suite 裡了?下面是官方的範例:

#include <boost/test/included/unit_test.hpp>
using namespace boost::unit_test;
 
void test_case1() { /* ... */ }
void test_case2() { /* ... */ }
void test_case3() { /* ... */ }
void test_case4() { /* ... */ }
 
test_suite* init_unit_test_suite(int /*argc*/, char* /*argv*/[])
{
  test_suite* ts1 = BOOST_TEST_SUITE("test_suite1");
  ts1->add(BOOST_TEST_CASE(&test_case1));
  ts1->add(BOOST_TEST_CASE(&test_case2));
 
  test_suite* ts2 = BOOST_TEST_SUITE("test_suite2");
  ts2->add(BOOST_TEST_CASE(&test_case3));
  ts2->add(BOOST_TEST_CASE(&test_case4));
 
  framework::master_test_suite().add(ts1);
  framework::master_test_suite().add(ts2);
 
  return 0;
}

透過這樣的方法,基本上就可以手動建立出各有兩個 test case 的 test suite 了。

test_suite1*
    test_case1*
    test_case2*
test_suite2*
    test_case3*
    test_case4*

根據 tuple 型別來建立 template test case

在知道怎麼透過 C++ API 來建立 test case 後,接下來就是來試試看要怎麼樣用這樣的形式來建立出 template 的 test case 了~

如果以這種手動建立 test case 的方法為基礎,要透過 std::tuple 來建立多組型別的 test case 的話,可以寫成下面的形式:

#include <boost/test/included/unit_test.hpp>
 
#include <tuple>
#include <typeinfo>
#include <sstream>
 
using namespace boost::unit_test;
 
// Test function
template<typename T1, typename T2>
void test_func() {}
 
// Type set to test
std::tuple<
  std::tuple<int, float>,
  std::tuple<int, double>
> testTypes;
 
// generate test cases
test_suite* init_unit_test_suite(int, char* [])
{
  test_suite* ts = BOOST_TEST_SUITE("test_suite");
  std::apply([ts](auto& ... types) {
    ([ts]<typename ...Ts>(const std::tuple<Ts...>&) {
      // generate test name
      std::ostringstream oss;
      oss << "test<";
      ((oss << typeid(Ts).name() << "-"), ...);
      oss << ">";
 
      // generate test function
      auto f = []() {test_func<Ts...>(); };
 
      // add test case
      ts->add(BOOST_TEST_CASE_NAME(f, oss.str()));
    }(types), ...);
  }, testTypes);
  return ts;
}

這邊的 test case 是 test_func<>() 這個 templae 函式需要兩個型別;而 testTypes 就是透過巢狀的 tuple 定義要有那些型別的組合,這邊是兩組。

之後,就是透過 std::apply()testTypes 的資料展開。這邊由於 std::tuple<> 包了兩層、所以這邊也要透過兩層 lambda 來展開。

第一層的部分是:

std::apply([ts](auto& ... types) { ... }, testTypes);

他得到的引數是 parameter pack 的形式,根據上面的定義會是兩個 std::tuple<>

第二層的部分則是在 Fold Expression 內,它的內容基本上是:

([ts]<typename ...Ts>(const std::tuple<Ts...>&) { ... }(types), ...);

他會被執行兩次、第一次得到的引數型別是 std::tuple<int, float>、第二次則是
std::tuple<int, double>

這邊會先拿引數的型別 std::tuple<Ts...> 來建立 test case 的名稱、然後建立出一個去執行指定型別的 test_func<>() 的 lambda f,並把他加到 test suite 裡面。

這樣的程式就會產生兩個指定型別組合的 test case 了~

> .\Test.exe --list_content
test_suite*
    test<int-float->*
    test<int-double->*

而理論上,他也可以對應任何數量的 template 函式。


加入要測試的資料

型別的問題解決了,接下來就是把測試資料也加進來了。由於 Boost Test 的 dataset 算是滿好用的,可以簡單地組合,所以這邊是打算延用他的形式。

最後寫出來的結果是:

#include <boost/test/included/unit_test.hpp>
#include <boost/test/data/test_case.hpp>
 
#include <tuple>
#include <typeinfo>
#include <sstream>
 
// Test function
template<typename T1, typename T2, typename T3>
void test_func(T1 v1, T2 v2) {}
 
// Type set to test
std::tuple<
  std::tuple<int, char, float>,
  std::tuple<uint8_t, char, double>
> testTypes;
 
// Data set to test
namespace btd = boost::unit_test::data;
auto testData = btd::xrange(0, 3) ^ btd::make({ 'a', 'b', 'c' });
 
// generate test cases
using namespace boost::unit_test;
test_suite* init_unit_test_suite(int /*argc*/, char* /*argv*/[])
{
  test_suite* ts = BOOST_TEST_SUITE("test_suite1");
  std::apply([ts](auto& ... types) {
    ([ts]<typename ...Ts>(const std::tuple<Ts...>&) {
      // Generate test name with types
      std::ostringstream oss;
      oss << "test<";
      ((oss << typeid(Ts).name() << "-"), ...);
      oss << ">";
      std::string sName = oss.str();
 
      // for each test data
      auto it = testData.begin();
      for (size_t i = 0; i < testData.size(); ++i)
      {
        auto tupleData = *it;
        ++it;
 
        // Generate test name with values
        std::ostringstream ossv;
        ossv << sName << "(";
        std::apply([&ossv](const auto& ... Vs) {
          ((ossv << Vs << "-"), ...);
        }, tupleData);
        ossv << ")";
 
        // generate test function
        auto f = [tupleData]() {
          std::apply([](const auto& ... Vs) {
            test_func<Ts...>(Vs...);
          }, tupleData);
        };
 
        // add test case
        ts->add(BOOST_TEST_CASE_NAME(f, ossv.str()));
      }
    }(types), ...);
  }, testTypes);
  return ts;
}

這邊和前面的差別,就在於在確定型別後、會再針對 testData 裡面的資料、用迴圈的形式來建立出 test case;這樣建立出來的 test case 會如下:

test_suite1*
    test<int-char-float->(1-a-)*
    test<int-char-float->(2-b-)*
    test<int-char-float->(3-c-)*
    test<unsigned char-char-double->(1-a-)*
    test<unsigned char-char-double->(2-b-)*
    test<unsigned char-char-double->(3-c-)*

這邊其實也會發現,程式中很大一部分都是在處理 test case 的名稱,而且如果要更好看的話,可能還得寫得更細緻一點。


雖然到這邊,算是可以透過 testData 來指定要測試的資料、並透過 testTypes 來指定要測試的型別、藉此展開多個 test case 了,但是老實說,程式碼的複雜度比想像的高了不少、可讀性也不是很好。

而且,這邊其實還有一個比較大的問題,是一個 template 函式在沒有明確指定型的情況下,是相當難以傳遞的…也就是說,這邊沒辦法把實際要執行的測試函式「test_func()」參數化。

也由於這個問題,所以上面 init_unit_test_suite() 中這串內容很難抽出來變成一個函式、讓他可以再多個地方使用…

個人覺得真的要讓他可以真的通用的話,可能也是得搭配巨集來寫了吧?當然啦,應該也還有一些稍微簡單一點的玩法可以繞過這個問題,但是就會變得相對地沒有那麼直覺了。

不過…Heresy 這部分就不太想玩下去了…這也是為什麼一開始說這邊寫出來的東西實用性不高的原因了。


參考:Test suites with manual registration

Leave a Reply

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *