Magellan: A Simple xUnit Test Framework in Modern C++11

TDD · horance · Created at · Last by horance Replied at · 1369 hits
3

动机

实现Magellan的动机,请参阅:你无法忍受 Google Test 的 9 个特性

灵感

Magellan是一个简单的、可扩展的、使用C++11实现的xUnit测试框架。Magellan设计灵感来自于Java社区著名的测试框架JUnit。

安装

GitHub

地址:https://github.com/horance/magellan
作者:刘光聪
Email:horance@outlook.com

编译环境

支持的平台:
* [MAC OS X] supported
* [Linux] supported
* [Windows] not supported

支持的编译器:
* [CLANG] 3.4 or later.
* [GCC] 4.8 or later.
* [MSVC] not supported.

安装CMake

CMake的下载地址:http://www.cmake.org

安装Magellan

$ git clone https://gitlab.com/horance/magellan.git
$ cd magellan
$ git submodule init
$ git submodule update
$ mkdir build
$ cd build
$ cmake ..
$ make
$ sudo make install

测试Magellan

$ cd magellan/build
$ cmake -DENABLE_TEST=on ..
$ make
$ test/magellan-test
$ lib/l0-infra/l0-infra-test
$ lib/hamcrest/hamcrest-test

使用Rake

使用Rake可简化Magelan的构建和测试过程,并且使得Magellan自我测试变成可能。

$ rake           # build, install, and test using clang
$ rake clang     # build, install, and test using clang
$ rake gcc       # build, install, and test using gcc
$ rake clean     # remove temp directory, and uninstall magellan
$ rake uninstall # uninstall magellan only

破冰之旅

物理目录
quantity
├── include
│   └── quantity
├── src
│   └── quantity
└── test
│   ├── main.cpp
└── CMakeLists.txt
main函数
#include "magellan/magellan.hpp"


int main(int argc, char** argv)
{
    return magellan::run_all_tests(argc, argv);
}
CMakeList脚本
project(quantity)

cmake_minimum_required(VERSION 2.8)

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++0x")

include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include)

file(GLOB_RECURSE all_files
src/*.cpp
src/*.cc
src/*.c
test/*.cpp
test/*.cc
test/*.c)

add_executable(quantity-test ${all_files})

target_link_libraries(quantity-test magellan hamcrest l0-infra)
构建
$ mkdir build
$ cd build
$ cmake ..
$ make
运行
$ ./quantity-test

[==========] Running 0 test cases.
[----------] 0 tests from All Tests
[----------] 0 tests from All Tests

[==========] 0 test cases ran.
[  TOTAL   ] PASS: 0  FAILURE: 0  ERROR: 0  TIME: 0 us

体验Magellan

第一个用例

#include <magellan/magellan.hpp>

#include "quantity/Length.h"

USING_HAMCREST_NS

FIXTURE(LengthTest)
{
    TEST("1 FEET should equal to 12 INCH")
    {
        ASSERT_THAT(Length(1, FEET), eq(Length(12, INCH)));
    }
};

使用 Magellan,只需要包含 magellan.hpp 一个头文件即可。Magellan 使用 Hamcrest 的断言机制,
使得断言更加统一、自然,且具有良好的扩展性;使用 USING_HAMCREST_NS,从而可以使用 eq
hamcrest::eq,简短明确;除非出现名字冲突,否则推荐使用简写的 eq

Length实现

// quantity/Length.h
#include "quantity/Amount.h"

enum LengthUnit
{
    INCH = 1,
    FEET = 12 * INCH,
};

struct Length
{
    Length(Amount amount, LengthUnit unit);

    bool operator==(const Length& rhs) const;
    bool operator!=(const Length& rhs) const;

private:
    const Amount amountInBaseUnit;
};
// quantity/Length.cpp
#include "quantity/Length.h"

Length::Length(Amount amount, LengthUnit unit)
  : amountInBaseUnit(unit * amount)
{
}

bool Length::operator==(const Length& rhs) const
{
    return amountInBaseUnit == rhs.amountInBaseUnit;
}

bool Length::operator!=(const Length& rhs) const
{
    return !(*this == rhs);
}
构建
$ mkdir build
$ cd build
$ cmake ..
$ make
运行
$ ./quantity-test

[==========] Running 1 test cases.
[----------] 1 tests from All Tests
[----------] 1 tests from LengthTest
[ RUN      ] LengthTest::1 FEET should equal to 12 INCH
[       OK ] LengthTest::1 FEET should equal to 12 INCH(13 us)
[----------] 1 tests from LengthTest

[----------] 1 tests from All Tests

[==========] 1 test cases ran.
[  TOTAL   ] PASS: 1  FAILURE: 0  ERROR: 0  TIME: 13 us

Fixture

FIXTURE的参数可以是任意的C++标识符。一般而言,将其命名为CUT(Class Under Test)的名字即可。根据作用域的大小,Fixture可分为三个类别:独立的Fixture,共享的Fixture,全局的Fixture。

支持BDD风格

xUnit BDD
FIXTURE CONTEXT
SETUP BEFORE
TEARDOWN AFTER
ASSERT_THAT EXPECT

独立的Fixture

#include <magellan/magellan.hpp>

FIXTURE(LengthTest)
{
    Length length;

    SETUP()
    {}

    TEARDOWN()
    {}

    TEST("length test1")
    {}

    TEST("length test2")
    {}
};

执行序列为:
1. Length 构造函数
2. SETUP
3. TEST("length test1")
4. TEARDOWN
5. Length 析构函数
6. Length 构造函数
7. SETUP
8. TEST("length test2")
9. TEARDOWN
10. Length 析构函数

共享的Fixture

#include <magellan/magellan.hpp>

FIXTURE(LengthTest)
{
    Length length;

    BEFORE_CLASS()
    {}

    AFTER_CLASS()
    {}

    BEFORE()
    {}

    AFTER()
    {}

    TEST("length test1")
    {}

    TEST("length test2")
    {}
};

执行序列为:
1. BEFORE_CLASS
2. Length 构造函数
3. BEFORE
4. TEST("length test1")
5. AFTER
6. Length 析构函数
7. Length 构造函数
8. BEFORE
9. TEST("length test2")
10. AFTER
11. Length 析构函数
12. AFTER_CLASS

全局的Fixture

有时候需要在所有用例启动之前完成一次性的全局性的配置,在所有用例运行完成之后完成一次性的清理工作。Magellan则使用BEFORE_ALLAFTER_ALL两个关键字来支持这样的特性。

#include <magellan/magellan.hpp>

BEFORE_ALL("before all 1")
{
}

BEFORE_ALL("before all 2")
{
}

AFTER_ALL("after all 1")
{
}

AFTER_ALL("after all 2")
{
}

BEFORE_ALLAFTER_ALL向系统注册Hook即可,Magellan便能自动地发现它们,并执行它们。犹如C++不能保证各源文件中全局变量初始化的顺序一样,避免在源文件之间的BEFORE_ALLAFTER_ALL设计不合理的依赖关系。

#include <magellan/magellan.hpp>

FIXTURE(LengthTest)
{
    Length length;

    BEFORE_CLASS()
    {}

    AFTER_CLASS()
    {}

    BEFORE()
    {}

    AFTER()
    {}

    TEST("length test1")
    {}

    TEST("length test2")
    {}
};
#include <magellan/magellan.hpp>

FIXTURE(VolumeTest)
{
    Volume volume;

    BEFORE_CLASS()
    {}

    AFTER_CLASS()
    {}

    BEFORE()
    {}

    AFTER()
    {}

    TEST("volume test1")
    {}

    TEST("volume test1")
    {}
};

Magellan可能的一个执行序列为:

  1. BEFORE_ALL("before all 1")
  2. BEFORE_ALL("before all 2")
  3. LengthTest::BEFORE_CLASS
  4. Length构造函数
  5. LengthTest::BEFORE
  6. TEST("length test1")
  7. LengthTest::AFTER
  8. Length析构函数
  9. Length构造函数
  10. LengthTest::BEFORE
  11. TEST("length test2")
  12. LengthTest::AFTER
  13. Length析构函数
  14. LengthTest::AFTER_CLASS
  15. VolumeTest::BEFORE_CLASS
  16. Volume构造函数
  17. LengthTest::BEFORE
  18. TEST("volume test1")
  19. LengthTest::AFTER
  20. Volume析构函数
  21. Volume构造函数
  22. LengthTest::BEFORE
  23. TEST("volume test2")
  24. LengthTest::AFTER
  25. Volume析构函数
  26. VolumeTest::AFTER_CLASS
  27. AFTER_ALL("after all 2")
  28. AFTER_ALL("after all 1")

用例设计

自动标识

Magellan能够自动地实现测试用例的标识功能,用户可以使用字符串来解释说明测试用例的意图,使得用户在描述用例时更加自然和方便。

#include <magellan/magellan.hpp>
#include "quantity/length/Length.h"

USING_HAMCREST_NS

FIXTURE(LengthTest)
{
    TEST("1 FEET should equal to 12 INCH")
    {
        ASSERT_THAT(Length(1, FEET), eq(Length(12, INCH)));
    }

    TEST("1 YARD should equal to 3 FEET")
    {
        ASSERT_THAT(Length(1, YARD), eq(Length(3, FEET)));
    }

    TEST("1 MILE should equal to 1760 YARD")
    {
        ASSERT_THAT(Length(1, MILE), eq(Length(1760, YARD)));
    }
};

面向对象

Magellan实现xUnit时非常巧妙,使得用户设计用例时更加面向对象。RobotCleaner robot在每个用例执行时都将获取一个独立的、全新的实例。

#include "magellan/magellan.hpp"
#include "robot-cleaner/RobotCleaner.h"
#include "robot-cleaner/Position.h"
#include "robot-cleaner/Instructions.h"

USING_HAMCREST_NS

FIXTURE(RobotCleanerTest)
{
    RobotCleaner robot;

    TEST("at the beginning, the robot should be in at the initial position")
    {
        ASSERT_THAT(robot.getPosition(), is(Position(0, 0, NORTH)));
    }

    TEST("left instruction: 1-times")
    {
        robot.exec(left());
        ASSERT_THAT(robot.getPosition(), is(Position(0, 0, WEST)));
    }

    TEST("left instruction: 2-times")
    {
        robot.exec(left());
        robot.exec(left());
        ASSERT_THAT(robot.getPosition(), is(Position(0, 0, SOUTH)));
    }
};

函数提取

提取的相关子函数,可以直接放在Fixture的内部,使得用例与其的距离最近,更加体现类作用域的概念。

#include "magellan/magellan.hpp"
#include "robot-cleaner/RobotCleaner.h"
#include "robot-cleaner/Position.h"
#include "robot-cleaner/Instructions.h"

USING_HAMCREST_NS

FIXTURE(RobotCleanerTest)
{
    RobotCleaner robot;

    void WHEN_I_send_instruction(Instruction* instruction)
    {
        robot.exec(instruction);
    }

    void AND_I_send_instruction(Instruction* instruction)
    {
        WHEN_I_send_instruction(instruction);
    }

    void THEN_the_robot_cleaner_should_be_in(const Position& position)
    {
        ASSERT_THAT(robot.getPosition(), is(position));
    }

    TEST("at the beginning")
    {
        THEN_the_robot_cleaner_should_be_in(Position(0, 0, NORTH));
    }

    TEST("left instruction: 1-times")
    {
        WHEN_I_send_instruction(left());
        THEN_the_robot_cleaner_should_be_in(Position(0, 0, WEST));
    }

    TEST("left instruction: 2-times")
    {
        WHEN_I_send_instruction(repeat(left(), 2));
        THEN_the_robot_cleaner_should_be_in(Position(0, 0, SOUTH));
    }

    TEST("left instruction: 3-times")
    {
        WHEN_I_send_instruction(repeat(left(), 3));
        THEN_the_robot_cleaner_should_be_in(Position(0, 0, EAST));
    }

    TEST("left instruction: 4-times")
    {
        WHEN_I_send_instruction(repeat(left(), 4));
        THEN_the_robot_cleaner_should_be_in(Position(0, 0, NORTH));
    }
};

断言

ASSERT_THAT

Magellan只支持一种断言原语:ASSERT_THAT, 从而避免用户在选择ASSERT_EQ/ASSERT_NE, ASSERT_TRUE/ASSERT_FALSE时的困扰,使其断言更加具有统一性,一致性。

此外,ASSERT_THAT使得断言更加具有表达力,它将实际值放在左边,期望值放在右边,更加符合英语习惯。

#include <magellan/magellan.hpp>

FIXTURE(CloseToTest)
{
    TEST("close to double")
    {
        ASSERT_THAT(1.0, close_to(1.0, 0.5));
        ASSERT_THAT(0.5, close_to(1.0, 0.5));
        ASSERT_THAT(1.5, close_to(1.0, 0.5));
    }
};

Hamcrest

Hamcrest是Java社区一个轻量级的,可扩展的Matcher框架,曾被Kent Beck引入到JUnit框架中,用于增强断言的机制。Magellan引入了Hamcrest的设计,实现了一个C++移植版本的Hamcrest,使得Magellang的断言更加具有扩展性和可读性。

结构

anything
匹配器 说明
anything 总是匹配
_ anything语法糖
#include <magellan/magellan.hpp>

USING_HAMCREST_NS

FIXTURE(AnythingTest)
{
    TEST("should always be matched")
    {
        ASSERT_THAT(1, anything<int>());
        ASSERT_THAT(1u, anything<unsigned int>());
        ASSERT_THAT(1.0, anything<double>());
        ASSERT_THAT(1.0f, anything<float>());
        ASSERT_THAT(false, anything<bool>());
        ASSERT_THAT(true, anything<bool>());
        ASSERT_THAT(nullptr, anything<std::nullptr_t>());
    }

    TEST("should support _ as syntactic sugar")
    {
        ASSERT_THAT(1u, _(int));
        ASSERT_THAT(1.0f, _(float));
        ASSERT_THAT(false, _(int));
        ASSERT_THAT(nullptr, _(std::nullptr_t));
    }
};
比较器
匹配器 说明
eq 相等
ne 不相等
lt 小于
gt 大于
le 小于或等于
ge 大于或等于
#include <magellan/magellan.hpp>

USING_HAMCREST_NS

FIXTURE(EqualToTest)
{
    TEST("should allow compare to integer")
    {
        ASSERT_THAT(0xFF, eq(0xFF));
        ASSERT_THAT(0xFF, is(eq(0xFF)));

        ASSERT_THAT(0xFF, is(0xFF));
        ASSERT_THAT(0xFF == 0xFF, is(true));
    }

    TEST("should allow compare to bool")
    {
        ASSERT_THAT(true, eq(true));
        ASSERT_THAT(false, eq(false));
    }

    TEST("should allow compare to string")
    {
        ASSERT_THAT("hello", eq("hello"));
        ASSERT_THAT("hello", eq(std::string("hello")));
        ASSERT_THAT(std::string("hello"), eq(std::string("hello")));
    }
};

FIXTURE(NotEqualToTest)
{
    TEST("should allow compare to integer")
    {
        ASSERT_THAT(0xFF, ne(0xEE));

        ASSERT_THAT(0xFF, is_not(0xEE));
        ASSERT_THAT(0xFF, is_not(eq(0xEE)));
        ASSERT_THAT(0xFF != 0xEE, is(true));
    }

    TEST("should allow compare to boolean")
    {
        ASSERT_THAT(true, ne(false));
        ASSERT_THAT(false, ne(true));
    }

    TEST("should allow compare to string")
    {
        ASSERT_THAT("hello", ne("world"));
        ASSERT_THAT("hello", ne(std::string("world")));
        ASSERT_THAT(std::string("hello"), ne(std::string("world")));
    }
};
修饰器
匹配器 说明
is 可读性装饰器
is_not 可读性装饰器
#include <magellan/magellan.hpp>

USING_HAMCREST_NS

FIXTURE(IsNotTest)
{
    TEST("integer")
    {
        ASSERT_THAT(0xFF, is_not(0xEE));
        ASSERT_THAT(0xFF, is_not(eq(0xEE)));
    }

    TEST("string")
    {
        ASSERT_THAT("hello", is_not("world"));
        ASSERT_THAT("hello", is_not(eq("world")));

        ASSERT_THAT("hello", is_not(std::string("world")));
        ASSERT_THAT(std::string("hello"), is_not(std::string("world")));
    }
};
空指针
匹配器 说明
nil 空指针
#include <magellan/magellan.hpp>

USING_HAMCREST_NS

FIXTURE(NilTest)
{
    TEST("equal_to")
    {
        ASSERT_THAT(nullptr, eq(nullptr));
        ASSERT_THAT(0, eq(NULL));
        ASSERT_THAT(NULL, eq(NULL));
        ASSERT_THAT(NULL, eq(0));
    }

    TEST("is")
    {
        ASSERT_THAT(nullptr, is(nullptr));
        ASSERT_THAT(nullptr, is(eq(nullptr)));

        ASSERT_THAT(0, is(0));
        ASSERT_THAT(NULL, is(NULL));
        ASSERT_THAT(0, is(NULL));
        ASSERT_THAT(NULL, is(0));
    }

    TEST("nil")
    {
        ASSERT_THAT((void*)NULL, nil());
        ASSERT_THAT((void*)0, nil());
        ASSERT_THAT(nullptr, nil());
    }
};
字符串
匹配器 说明
contains_string 断言是否包含子串
contains_string_ignoring_case 忽略大小写,断言是否包含子
starts_with 断言是否以该子串开头
starts_with_ignoring_case 忽略大小写,断言是否以该子串开头
ends_with 断言是否以该子串结尾
ends_with_ignoring_case 忽略大小写,断言是否以该子串结尾
#include <magellan/magellan.hpp>

USING_HAMCREST_NS

FIXTURE(StartsWithTest)
{
    TEST("case sensitive")
    {
        ASSERT_THAT("ruby-cpp", starts_with("ruby"));
        ASSERT_THAT("ruby-cpp", is(starts_with("ruby")));

        ASSERT_THAT(std::string("ruby-cpp"), starts_with("ruby"));
        ASSERT_THAT("ruby-cpp", starts_with(std::string("ruby")));
        ASSERT_THAT(std::string("ruby-cpp"), starts_with(std::string("ruby")));
    }

    TEST("ignoring case")
    {
        ASSERT_THAT("ruby-cpp", starts_with_ignoring_case("Ruby"));
        ASSERT_THAT("ruby-cpp", is(starts_with_ignoring_case("Ruby")));

        ASSERT_THAT(std::string("ruby-cpp"), starts_with_ignoring_case("RUBY"));
        ASSERT_THAT("Ruby-Cpp", starts_with_ignoring_case(std::string("rUBY")));
        ASSERT_THAT(std::string("RUBY-CPP"), starts_with_ignoring_case(std::string("ruby")));
    }
};
浮点数
匹配器 说明
close_to 断言浮点数近似等于
nan 断言浮点数不是一个数字
#include <magellan/magellan.hpp>
#include <math.h>

USING_HAMCREST_NS

FIXTURE(IsNanTest)
{
    TEST("double")
    {
        ASSERT_THAT(sqrt(-1.0), nan());
        ASSERT_THAT(sqrt(-1.0), is(nan()));

        ASSERT_THAT(1.0/0.0,  is_not(nan()));
        ASSERT_THAT(-1.0/0.0, is_not(nan()));
    }
};

程序选项

TestOptions::TestOptions() : desc("magellan")
{
    desc.add({
        {"help,     h",   "help message"},
        {"filter,   f",   "--filter=pattern"},
        {"color,    c",   "--color=[yes|no]"},
        {"xml,      x",   "print test result into XML file"},
        {"list,     l",   "list all tests without running them"},
        {"progress, p",   "print test result in progress bar"},
        {"verbose,  v",   "verbosely list tests processed"},
        {"repeat,   r",   "how many times to repeat each test"}
    });

    // default value
    options["color"]  = "yes";
    options["repeat"] = "1";
}

设计与实现

核心领域

Magellan整体的结构其实是一棵树,用于用例的组织和管理。

struct TestResult;

DEFINE_ROLE(Test)
{
    ABSTRACT(const std::string& getName () const);
    ABSTRACT(int countTestCases() const);
    ABSTRACT(int countChildTests() const);
    ABSTRACT(void run(TestResult&));
};

适配

如何让FIXTURE中一个普通的成员函数TEST在运行时表现为一个TestCase呢?在C++的实现中,似乎变得非常困难。Magellan的设计非常简单,将TEST的元信息在编译时注册到框架,简单地使用了C++元编程的技术,及其C++11的一些特性保证,从而解决了C++社区一直未解决此问题的关键。

TEST的运行时信息由TestMethod的概念表示,其代表FIXTURE中一个普通的成员函数TEST,它们都具有同样的函数原型: void Fixture::*)(); TestMethod是一个泛型类,泛型参数是Fixture;形式化地描述为:

template <typename Fixture>
struct TestMethod
{    
    using Method = void(Fixture::*)();
};

TestCaller也是一个泛型类,它将一个TestMethod适配为一个普通的TestCase

template <typename Fixture>
struct TestCaller : TestCase
{    
    using Method = void(Fixture::*)();

    TestCaller(const std::string& name, Method method)
        : TestCase(name), fixture(0), method(method)
    {}

private:
    OVERRIDE(void setUp())
    {
        fixture = new Fixture;
        fixture->setUp();
    }

    OVERRIDE(void tearDown())
    {
        fixture->tearDown();        
        delete fixture;
        fixture = 0;
    }

    OVERRIDE(void runTest())
    {
        (fixture->*method)();
    }

private:
    Fixture* fixture;
    Method method;
};

装饰

TestDecorator其实是对Magellan核心领域的一个扩展,从而保证核心领域的不变性,而使其具有最大的可扩展性和灵活性。

工厂

在编译时通过测试用例TEST的元信息的注册,使用TestFactory很自然地将这些用例自动生成出来了。因为Magallan组织用例是一刻树,TestFactory也被设计为一棵树,从而使得其与框架核心领域保持高度的一致性,更加自然、漂亮。

监听状态

Magellan通过TestListener对运行时的状态变化进行监控,从而实现了Magellan不同格式报表打印的变化。


「软件匠艺社区」旨在传播匠艺精神,通过分享好的「工作方式」和「习惯」以帮助程序员更加快乐高效地编程。
共收到 3 条回复
5
kaelzhang · #1 ·

大作,支持!

1
hkliya · #2 ·

每一篇都如此用心,光这种态度就值得我们学习!

3

谢谢大家!在之前的团队里,大家基本都在使用C/C++开发嵌入式系统。每当去到一个团队推广TDD实践时,似乎只能选择Google Test;但一件最痛苦的事始终缠绕着我:命名。我早已习惯Given-When-Then的风格,但Google Test又逼着你使用严格的标志符命名:TEST(Fixture, given_xxx_when_xxx_then_xxx),这快让我窒息了。我清晰的记得那一天,那是一个周五的下午,我脑海中闪过TestMethod,及其TestCaller(当时我命名为TestAdapter,一个太直接、不好听、不恰当的命名)的设计,我基本肯定我可以解决这个问题,所以我花费了一个周末的休息时间,基本完成magellan的core的部分,过了一个充实的周末(_)

需要 Sign In 后回复方可回复, 如果你还没有账号你可以 Sign Up 一个帐号。