单元测试¶

截至2007年11月,我们要求进入master的所有新特性都要附带一个单元测试。最初,我们将此需求限制在QGIS核心上,一旦人们熟悉下面章节中解释的单元测试过程,我们将把此需求扩展到代码库的其他部分。

QGIS测试框架-概述

单元测试是使用qtestlib(qt测试库)和ctest(编译和运行测试的框架,作为cmake构建过程的一部分)的组合进行的。在深入了解详细信息之前,让我们先概述一下流程:

正是基于这个概述,我将深入研究一些细节。我已经在cmake和源代码树的其他地方为您完成了很多配置,所以您所需要做的只是简单的位编写单元测试!

创建单元测试

创建单元测试很容易——通常您只需创建一个.cpp文件(而不是使用.h文件),并将所有测试方法实现为返回void的公共方法。我将在接下来的部分中为qgsrasterlayer使用一个简单的测试类进行说明。按照惯例,我们将使用与测试类相同的名称命名测试,但前缀为“test”。所以我们的测试实现放在一个名为testqgsrasterlayer.cpp的文件中,类本身就是testqgsrasterlayer。首先,我们添加标准版权横幅:

/***************************************************************************
 testqgsvectorfilewriter.cpp
 --------------------------------------
  Date : Friday, Jan 27, 2015
  Copyright: (C) 2015 by Tim Sutton
  Email: tim@kartoza.com
 ***************************************************************************
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 ***************************************************************************/

接下来,我们使用start-our-include来运行我们计划运行的测试。有一个特殊的包括所有测试应该有:

#include <QtTest/QtTest>

除此之外,您只需按照常规继续实现类,拉入您可能需要的任何头:

//Qt includes...
#include <QObject>
#include <QString>
#include <QObject>
#include <QApplication>
#include <QFileInfo>
#include <QDir>

//qgis includes...
#include <qgsrasterlayer.h>
#include <qgsrasterbandstats.h>
#include <qgsapplication.h>

因为我们将类声明和实现组合在一个文件中,所以接下来是类声明。我们从我们的doxygen文档开始。每个测试用例都应该有适当的文档记录。我们使用doxygen-ingroup指令,以便所有单元测试在生成的doxygen文档中显示为一个模块。之后是单元测试的简短描述,类必须继承自QObject并包含Q_对象宏。

/** \ingroup UnitTests
 * This is a unit test for the QgsRasterLayer class.
 */

class TestQgsRasterLayer: public QObject
{
    Q_OBJECT

我们所有的测试方法都是作为私有槽实现的。qtest框架将依次调用测试类中的每个私有槽方法。有四个“特殊”方法,如果实现了这些方法,将在单元测试(initestcase)开始时、单元测试(cleanuptestcase)结束时调用。在调用每个测试方法之前,将调用init()方法,在调用每个测试方法之后,将调用cleanup()方法。这些方法很方便,因为它们允许您在运行每个测试之前分配和清理资源,以及整个测试单元。

private slots:
  // will be called before the first testfunction is executed.
  void initTestCase();
  // will be called after the last testfunction was executed.
  void cleanupTestCase(){};
  // will be called before each testfunction is executed.
  void init(){};
  // will be called after every testfunction.
  void cleanup();

然后是您的测试方法,所有这些方法都不需要参数,应该返回void。方法将按声明的顺序调用。我在这里实现了两种方法,它演示了两种类型的测试。在第一种情况下,我一般想测试类的各个部分是否正常工作,我可以使用功能测试方法。同样,极端的程序员会提倡在实现类之前编写这些测试。然后,当您通过类实现工作时,您会迭代地运行单元测试。随着类实现工作的进展,越来越多的测试函数应该成功地完成,当整个单元测试通过时,新的类就完成了,现在用一种可重复的方法来验证它。

通常,单元测试只覆盖类的公共API,并且通常不需要为访问器和转换器编写测试。如果一个accessor或mutator不能像预期的那样工作,那么您通常会实现一个回归测试来检查这一点(请参见下)。

//
// Functional Testing
//

/** Check if a raster is valid. */
void isValid();

// more functional tests here ...

接下来,我们实现回归测试。应该执行回归测试来复制特定bug的条件。例如,我最近收到一封电子邮件报告,Rasters的细胞计数为1,丢弃了栅格带的所有统计数据。我打开了一个bug(ticket 832),然后创建了一个回归测试,使用一个小的测试数据集(10x10栅格)复制了这个bug。然后我运行测试并运行它,验证它确实失败了(单元格计数是99而不是100)。然后我去修复这个bug,重新运行单元测试,回归测试通过了。我提交了回归测试和错误修复。现在,如果将来有人在源代码中再次打破这一点,我们可以立即确定代码已经回归。更好的是,在将来提交任何更改之前,运行我们的测试将确保我们的更改不会产生意外的副作用,比如破坏现有的功能。

回归测试还有一个好处——它们可以节省您的时间。如果您修复了一个涉及对源代码进行更改的bug,然后运行应用程序并执行一系列复杂的步骤来复制该问题,那么很明显,在修复bug之前简单地实现回归测试将使您以有效的方式自动进行bug解决测试。

要实现回归测试,您应该遵循回归<ticketid>的命名约定来实现测试函数。如果不存在用于回归的Redmine票证,则应首先创建一个。使用这种方法,运行一个失败的回归测试的人可以很容易地去发现更多的信息。

//
// Regression Testing
//

/** This is our second test case...to check if a raster
 *  reports its dimensions properly. It is a regression test
 *  for ticket #832 which was fixed with change r7650.
 */
void regression832();

// more regression tests go here ...

最后,在我们的测试类声明中,您可以私下声明单元测试可能需要的任何数据成员和助手方法。在我们的例子中,我将声明一个qgsrasterlayer*,它可以被我们的任何测试方法使用。栅格层将在inittestcase()函数中创建,该函数在任何其他测试之前运行,然后使用cleanuptestcase()销毁,该函数在所有测试之后运行。通过私下声明helper方法(可以由各种测试函数调用),可以确保它们不会由编译测试时创建的qtest可执行文件自动运行。

  private:
    // Here we have any data structures that may need to
    // be used in many test cases.
    QgsRasterLayer * mpLayer;
};

这就结束了我们的类声明。实现只是在同一个较低的文件中内联的。首先,我们的init和cleanup函数是:

void TestQgsRasterLayer::initTestCase()
{
  // init QGIS's paths - true means that all path will be inited from prefix
  QString qgisPath = QCoreApplication::applicationDirPath ();
  QgsApplication::setPrefixPath(qgisPath, TRUE);
#ifdef Q_OS_LINUX
  QgsApplication::setPkgDataPath(qgisPath + "/../share/qgis");
#endif
  //create some objects that will be used in all tests...

  std::cout << "PrefixPATH: " << QgsApplication::prefixPath().toLocal8Bit().data() << std::endl;
  std::cout << "PluginPATH: " << QgsApplication::pluginPath().toLocal8Bit().data() << std::endl;
  std::cout << "PkgData PATH: " << QgsApplication::pkgDataPath().toLocal8Bit().data() << std::endl;
  std::cout << "User DB PATH: " << QgsApplication::qgisUserDbFilePath().toLocal8Bit().data() << std::endl;

  //create a raster layer that will be used in all tests...
  QString myFileName (TEST_DATA_DIR); //defined in CmakeLists.txt
  myFileName = myFileName + QDir::separator() + "tenbytenraster.asc";
  QFileInfo myRasterFileInfo ( myFileName );
  mpLayer = new QgsRasterLayer ( myRasterFileInfo.filePath(),
  myRasterFileInfo.completeBaseName() );
}

void TestQgsRasterLayer::cleanupTestCase()
{
  delete mpLayer;
}

上面的init函数说明了一些有趣的事情。

  1. 我需要手动设置qgis应用程序数据路径,以便正确地找到srs.db等资源。
  2. 其次,这是一个数据驱动的测试,因此我们需要提供一种方法来一般地定位 tenbytenraster.asc 文件。这是通过使用编译器定义 TEST_DATA_PATH . 定义是在 CMakeLists.txt 配置文件位于 <QGIS Source Root>/tests/CMakeLists.txt 可用于所有QGIS单元测试。如果您的测试需要测试数据,请在 <QGIS Source Root>/tests/testdata . 您应该只在这里提交非常小的数据集。如果您的测试需要修改测试数据,它应该首先对其进行复制。

Qt还为数据驱动测试提供了一些其他有趣的机制,因此如果您有兴趣了解更多关于这个主题的内容,请参考Qt文档。

接下来,让我们看看我们的功能测试。isvalid()测试只需检查栅格层是否正确加载在initestcase中。qverify是一个qt宏,可用于评估测试条件。还有一些其他的宏qt在测试中使用,包括:

  • ` Q比较(实际,预期)`
  • ` Qexpect eu失败(数据索引、注释、模式)`
  • ` qfail(消息)`
  • ` qfetch(类型、名称)`
  • ` qskip(描述、模式)`
  • ` qtest(实际,测试元素)`
  • ` qtest_appless_main(测试类)`
  • ` qtest_main(测试类)`
  • ` qtest ou noop ou main()测试`
  • ` qverify2(条件、消息)`
  • ` Qverify(条件)`
  • ` qwarn(消息)`

其中一些宏只有在使用qt框架进行数据驱动测试时才有用(有关详细信息,请参阅qt文档)。

void TestQgsRasterLayer::isValid()
{
  QVERIFY ( mpLayer->isValid() );
}

通常,在可行的情况下,您的功能测试将涵盖类公共API的所有功能范围。有了我们的功能测试,我们就可以看看我们的回归测试示例了。

由于bug 832中的问题是错误报告的单元计数,编写我们的测试只是使用qverify检查单元计数是否符合预期值的问题:

void TestQgsRasterLayer::regression832()
{
  QVERIFY ( mpLayer->getRasterXDim() == 10 );
  QVERIFY ( mpLayer->getRasterYDim() == 10 );
  // regression check for ticket #832
  // note getRasterBandStats call is base 1
  QVERIFY ( mpLayer->getRasterBandStats(1).elementCountInt == 100 );
}

在实现了所有单元测试函数之后,最后一件事是我们需要添加到我们的测试类中:

QTEST_MAIN(TestQgsRasterLayer)
#include "testqgsrasterlayer.moc"

这两行的目的是向qt的moc发出信号,表明his是qtest(它将生成一个主方法,该方法依次调用每个测试函数。最后一行是moc生成的源的include。您应该用小写的类名替换“testqgsrasterlayer”。

比较图像以进行渲染测试

由于平台特定的实现(例如不同的字体渲染和抗锯齿算法)、系统上可用的字体以及其他模糊原因,在不同环境中渲染图像可能会产生细微的差异。

当一个渲染测试在Travis上运行并失败时,请在Travis日志的最底部查找dash链接。此链接将带您到一个CDASH页面,在那里您可以看到渲染图像与预期图像,以及一个“差异”图像,该图像以红色突出显示与参考图像不匹配的任何像素。

QGIS单元测试系统支持添加“遮罩”图像,用于指示渲染图像与参考图像之间的差异。遮罩图像是一个图像(与参考图像同名,但包含一个“_mask.png”后缀),并且应与参考图像具有相同的尺寸。在遮罩图像中,像素值指示单个像素与参考图像的差异程度,因此黑色像素指示渲染图像中的像素必须与参考图像中的相同像素完全匹配。带有RGB 2、2、2的像素表示渲染图像与其参考图像的RGB值最多可以相差2,而全白像素(255、255、255)表示在比较参考图像和渲染图像时,该像素被有效忽略。

生成遮罩图像的实用程序脚本可用为 scripts/generate_test_mask_image.py . 通过将引用映像的路径(例如 tests/testdata/control_images/annotations/expected_annotation_fillstyle/expected_annotation_fillstyle.png )以及渲染图像的路径。

例如。

scripts/generate_test_mask_image.py tests/testdata/control_images/annotations/expected_annotation_fillstyle/expected_annotation_fillstyle.png /tmp/path_to_rendered_image.png

您可以通过传递测试名称的一部分来将路径快捷方式传递到引用映像,例如

scripts/generate_test_mask_image.py annotation_fillstyle /tmp/path_to_rendered_image.png

(此快捷方式仅在找到单个匹配的参考图像时有效。如果找到多个匹配项,则需要提供参考图像的完整路径。)

脚本还接受渲染图像的HTTP URL,因此您可以直接从CDASH结果页复制渲染图像的URL并将其传递给脚本。

生成遮罩图像时要小心-应始终查看生成的遮罩图像并查看图像中的任何白色区域。由于忽略了这些像素,请确保这些白色图像不覆盖参考图像的任何重要部分,否则单元测试将毫无意义!

类似地,如果您有意想从测试中排除这些部分,那么可以手动“清除”部分的遮罩。这对于混合符号和文本呈现(如图例测试)的测试很有用,其中单元测试不用于测试呈现的文本,并且您不希望测试受到跨平台文本呈现差异的影响。

要比较qgis单元测试中的图像,应使用类 QgsMultiRenderChecker 或者它的一个子类。

为了提高测试的健壮性,这里有几个技巧:

  1. 如果可以,请禁用抗锯齿,因为这样可以最小化跨平台渲染差异。
  2. 确保你的参考图片是“厚重的”…也就是说,没有1像素宽的线条或其他精细的特征,使用大的粗体字体(建议使用14点或更多)。
  3. 有时,测试会生成大小稍有不同的图像(例如,图例呈现测试,其中图像大小取决于字体呈现大小-这取决于跨平台差异)。为此,请使用 QgsMultiRenderChecker::setSizeTolerance() 并指定渲染图像的宽度和高度与参考图像不同的最大像素数。
  4. 不要在参考图像中使用透明背景(cdash不支持)。相反,使用 QgsMultiRenderChecker::drawBackground() 为参考图像背景绘制棋盘图案。
  5. 如果需要字体,请使用中指定的字体。 QgsFontUtils::standardTestFontFamily() (qgis vera sans)。

将单元测试添加到cmakelists.txt

将单元测试添加到构建系统只需编辑测试目录中的cmakelists.txt,克隆现有测试块之一,然后将测试类名替换到其中。例如:

# QgsRasterLayer test
ADD_QGIS_TEST(rasterlayertest testqgsrasterlayer.cpp)

添加qgis测试宏解释

我将简要介绍这些行来解释它们的作用,但是如果您不感兴趣,只需执行上面部分和部分中解释的步骤。

MACRO (ADD_QGIS_TEST testname testsrc)
SET(qgis_${testname}_SRCS ${testsrc} ${util_SRCS})
SET(qgis_${testname}_MOC_CPPS ${testsrc})
QT4_WRAP_CPP(qgis_${testname}_MOC_SRCS ${qgis_${testname}_MOC_CPPS})
ADD_CUSTOM_TARGET(qgis_${testname}moc ALL DEPENDS ${qgis_${testname}_MOC_SRCS})
ADD_EXECUTABLE(qgis_${testname} ${qgis_${testname}_SRCS})
ADD_DEPENDENCIES(qgis_${testname} qgis_${testname}moc)
TARGET_LINK_LIBRARIES(qgis_${testname} ${QT_LIBRARIES} qgis_core)
SET_TARGET_PROPERTIES(qgis_${testname}
PROPERTIES
# skip the full RPATH for the build tree
SKIP_BUILD_RPATHTRUE
# when building, use the install RPATH already
# (so it doesn't need to relink when installing)
BUILD_WITH_INSTALL_RPATH TRUE
# the RPATH to be used when installing
INSTALL_RPATH ${QGIS_LIB_DIR}
# add the automatically determined parts of the RPATH
# which point to directories outside the build tree to the install RPATH
INSTALL_RPATH_USE_LINK_PATH true)
IF (APPLE)
# For Mac OS X, the executable must be at the root of the bundle's executable folder
INSTALL(TARGETS qgis_${testname} RUNTIME DESTINATION ${CMAKE_INSTALL_PREFIX})
ADD_TEST(qgis_${testname} ${CMAKE_INSTALL_PREFIX}/qgis_${testname})
ELSE (APPLE)
INSTALL(TARGETS qgis_${testname} RUNTIME DESTINATION ${CMAKE_INSTALL_PREFIX}/bin)
ADD_TEST(qgis_${testname} ${CMAKE_INSTALL_PREFIX}/bin/qgis_${testname})
ENDIF (APPLE)
ENDMACRO (ADD_QGIS_TEST)

让我们更详细地看一下单独的行。首先,我们定义测试的源列表。因为我们只有一个源文件(按照我上面描述的方法,类声明和定义在同一个文件中),所以它是一个简单的语句:

SET(qgis_${testname}_SRCS ${testsrc} ${util_SRCS})

由于我们的测试类需要通过qt元对象编译器(moc)运行,因此我们需要提供几行代码来实现这一点:

SET(qgis_${testname}_MOC_CPPS ${testsrc})
QT4_WRAP_CPP(qgis_${testname}_MOC_SRCS ${qgis_${testname}_MOC_CPPS})
ADD_CUSTOM_TARGET(qgis_${testname}moc ALL DEPENDS ${qgis_${testname}_MOC_SRCS})

接下来,我们告诉cmake它必须从测试类中生成一个可执行文件。请记住,在上一节中,在类实现的最后一行,我将moc输出直接包含到我们的测试类中,这样将为它(除其他外)提供一个主要方法,以便将类编译为可执行文件:

ADD_EXECUTABLE(qgis_${testname} ${qgis_${testname}_SRCS})
ADD_DEPENDENCIES(qgis_${testname} qgis_${testname}moc)

接下来,我们需要指定任何库依赖项。目前,类已经用catch all qt_库依赖性实现,但是我将努力用每个类只需要的特定qt库来替换它。当然,您还需要根据单元测试的需要链接到相关的QGIS库。

TARGET_LINK_LIBRARIES(qgis_${testname} ${QT_LIBRARIES} qgis_core)

接下来,我告诉cmake将测试安装到与qgis二进制文件本身相同的位置。这是我计划在将来删除的内容,这样测试就可以直接从源代码树中运行。

SET_TARGET_PROPERTIES(qgis_${testname}
PROPERTIES
# skip the full RPATH for the build tree
SKIP_BUILD_RPATHTRUE
# when building, use the install RPATH already
# (so it doesn't need to relink when installing)
BUILD_WITH_INSTALL_RPATH TRUE
# the RPATH to be used when installing
INSTALL_RPATH ${QGIS_LIB_DIR}
# add the automatically determined parts of the RPATH
# which point to directories outside the build tree to the install RPATH
INSTALL_RPATH_USE_LINK_PATH true)
IF (APPLE)
# For Mac OS X, the executable must be at the root of the bundle's executable folder
INSTALL(TARGETS qgis_${testname} RUNTIME DESTINATION ${CMAKE_INSTALL_PREFIX})
ADD_TEST(qgis_${testname} ${CMAKE_INSTALL_PREFIX}/qgis_${testname})
ELSE (APPLE)
INSTALL(TARGETS qgis_${testname} RUNTIME DESTINATION ${CMAKE_INSTALL_PREFIX}/bin)
ADD_TEST(qgis_${testname} ${CMAKE_INSTALL_PREFIX}/bin/qgis_${testname})
ENDIF (APPLE)

最后上述用途 ADD_TEST 向cmake/ctest注册测试。这里是最好的魔法发生的地方-我们在CTEST注册课程。如果您还记得我在本节开头给出的概述,我们将同时使用qtest和ctest。简单地说,qttest向测试单元添加了一个主方法,并处理调用类中的测试方法。它还提供一些宏,比如 QVERIFY 可用于测试使用条件的失败。qttest单元测试的输出是一个可执行文件,您可以从命令行运行它。但是,如果您有一套测试,并且希望依次运行每个可执行文件,并且更好地将运行的测试集成到构建过程中,那么CTest就是我们使用的工具。

构建单元测试

要构建单元测试,只需确保 ENABLE_TESTS=true 在cmake配置中。有两种方法可以做到这一点:

  1. ccmake .. (或) cmakesetup .. 并以交互方式设置 ENABLE_TESTS 旗到 ON .
  2. 向cmake添加命令行标志,例如 cmake -DENABLE_TESTS=true ..

除此之外,只需按常规构建QGIS,测试也应该构建。

运行测试

运行测试的最简单方法是作为正常构建过程的一部分:

make && make install && make test

make test命令将调用ctest,该命令将运行使用上述添加测试cmake指令注册的每个测试。make测试的典型输出如下:

Running tests...
Start processing tests
Test project /Users/tim/dev/cpp/qgis/build
## 13 Testing qgis_applicationtest***Exception: Other
## 23 Testing qgis_filewritertest *** Passed
## 33 Testing qgis_rasterlayertest*** Passed

## 0 tests passed, 3 tests failed out of 3

The following tests FAILED:
## 1- qgis_applicationtest (OTHER_FAULT)
Errors while running CTest
make: *** [test] Error 8

如果测试失败,可以使用ctest命令更仔细地检查失败的原因。使用 -R 选项指定要为其运行测试的regex,以及 -V 要获取详细输出:

$ ctest -R appl -V

Start processing tests
Test project /Users/tim/dev/cpp/qgis/build
Constructing a list of tests
Done constructing a list of tests
Changing directory into /Users/tim/dev/cpp/qgis/build/tests/src/core
## 13 Testing qgis_applicationtest
Test command: /Users/tim/dev/cpp/qgis/build/tests/src/core/qgis_applicationtest
********* Start testing of TestQgsApplication *********
Config: Using QTest library 4.3.0, Qt 4.3.0
PASS : TestQgsApplication::initTestCase()
PrefixPATH: /Users/tim/dev/cpp/qgis/build/tests/src/core/../
PluginPATH: /Users/tim/dev/cpp/qgis/build/tests/src/core/..//lib/qgis
PkgData PATH: /Users/tim/dev/cpp/qgis/build/tests/src/core/..//share/qgis
User DB PATH: /Users/tim/.qgis/qgis.db
PASS : TestQgsApplication::getPaths()
PrefixPATH: /Users/tim/dev/cpp/qgis/build/tests/src/core/../
PluginPATH: /Users/tim/dev/cpp/qgis/build/tests/src/core/..//lib/qgis
PkgData PATH: /Users/tim/dev/cpp/qgis/build/tests/src/core/..//share/qgis
User DB PATH: /Users/tim/.qgis/qgis.db
QDEBUG : TestQgsApplication::checkTheme() Checking if a theme icon exists:
QDEBUG : TestQgsApplication::checkTheme()
/Users/tim/dev/cpp/qgis/build/tests/src/core/..//share/qgis/themes/default//mIconProjectionDisabled.png
FAIL!: TestQgsApplication::checkTheme() '!myPixmap.isNull()' returned FALSE. ()
Loc: [/Users/tim/dev/cpp/qgis/tests/src/core/testqgsapplication.cpp(59)]
PASS : TestQgsApplication::cleanupTestCase()
Totals: 3 passed, 1 failed, 0 skipped
********* Finished testing of TestQgsApplication *********
-- Process completed
***Failed

## 0 tests passed, 1 tests failed out of 1

The following tests FAILED:
## 1- qgis_applicationtest (Failed)
Errors while running CTest

调试单元测试

对于C++单元测试,QtC造饰器会自动添加运行目标,因此可以在调试器中启动它们。

也可以使用gdb从qtcreator启动python单元测试。为此,你需要去 Projects 并选择 Run 在下面 Build & Run . 然后添加一个新的 Run configuration 使用可执行文件 /usr/bin/python3 以及设置为单元测试python文件路径的命令行参数,例如 /home/user/dev/qgis/QGIS/tests/src/python/test_qgsattributeformeditorwidget.py .

现在也改变 Run Environment 并添加3个新变量:

变量 价值
PYTHONPATH [build]/output/python/:[build]/output/python/plugins:[source]/tests/src/python
QGIS_PREFIX_PATH [构建] /输出
LD_LIBRARY_PATH [生成]/输出/lib

替换 [build] 与您的构建目录和 [source] 使用源目录。

玩得高兴

好吧,这部分是关于在QGIS中编写单元测试的总结。我们希望您养成编写测试来测试新功能和检查回归的习惯。测试系统的某些方面(特别是cmakelists.txt部分)仍在研究中,以便测试框架以真正独立于平台的方式工作。