曾经我很不喜欢“软件测试”这个行当。因为曾经听某个老板这样宣扬:古代修城墙分两拨人,一拨人筑墙,一拨人检查,检查的人用长矛去戳城墙,如果戳的进去,筑墙的人砍头,戳不进去,检查的人砍头。——显然这个故事是瞎编的,但是这么拿来说,显然是想利用人性的弱点。事实上,有些公司就是把 BUG 数量指标加在开发人员和测试人员的头上,让这两拨人去斗争。如果仅仅把软件测试视为是“质量控制”的手段,那么显然是非常浅薄的。
现代的软件测试,已经是软件开发效率提升的重要手段。
每一个程序,如果写完之后仅仅是通过编译,而没有运行过,其实根本不算写完了程序。所以运行程序本身,就是软件开发的一部分。利用手工或者自动的方法,去运行程序,我们往往称为“软件测试”。——现代软件测试,是希望尽量多的减轻软件测试的人力负担,多让程序来做这个事情。软件工程的目的是提高软件的生产效率,软件测试技术正是达到这个目的的一个手段。
很多年前,我曾经需要为一个新建的办公室安装20台PC兼容机。老板给了我20台PC的所有零件,包括主板、CPU、内存、显示卡等等,这些零件一共有三四个大纸皮箱。于是我立刻开始动手,一套配件开始一顿叮咣安装了3台PC,一接上点源,结果一台PC的显示器都不亮。主板、CPU、内存、显示卡任何一个配件有问题,都会导致显示器不亮——这一下该怎么办呢?总不能全部装好20台之后再一台台的去排除故障吧?(有一种维修专用卡,插在主板上,可以提示故障的配件,不过那个时候手上并没这种高档货)后来我就想了个办法:首先装一台正常的 PC,然后把所有的配件,都换上去测试一遍。很快我就把所有的内存、显卡、CPU都测试了一遍,果然里面有15%左右的配件是有故障的。后续就很好办了,拿那些好的配件去插上主板,如果有问题就是主板的了。这样大部分新装的PC都是能正常工作的,同时剩下一堆有故障的配件,就让老板去退货,换回来的零件,依然是先“测试一遍”,然后再组装。
其实,开发软件和上面安装电脑的例子有一定的类似,一个软件可能由很多个部分(模块)组成,如果每个部分能单独的进行测试,确保自身是工作正常的,那么整个软件运行起来,出故障的概率就会小很多。单元测试,指的就是单独对某个模块进行测试:
写一个程序,去调用测试目标软件的某个函数,然后得到返回值,用程序判断一下返回值是否符合预期,来宣告测试是否完成。
除了用来确保软件各部分的正确,单元测试还有其他的用途:从前我最开始做网络游戏的时候,其中有一个环节是很费时间的,那就是客户端和服务器端联调协议。那个时候 ProtocolBuffer 还没有诞生,协议的定义完全是靠文档和人力沟通。每个协议联调都需要两个程序员同时运行程序,然后反复手工运行程序、进行某些操作,才能一点点调试通过。后来,我们把客户端程序员和服务器端程序的座位搬到一起,这样沟通效率高了很多。再后来,我们让游戏客户端和服务器端的程序 IDE 都同时在一个电脑上打开,然后两边直接下断点调试,又进一步的提高了联调的效率。但是当我掌握了单元测试的技术之后,完全可以自己写一个程序来完成这种测试,我只要运行一个 mock 的客户端或者服务器,就能很准确的发现协议定义不一致的地方了。
我们用单元测试的程序,去确保另外一个程序运行正确的做法,对于某些特别的项目,还有额外的意义。我参加过一些“库”的编写,这些程序没有自己的 main 函数,完全是用于客户程序员调用的。当我写完一个“库”里面的功能之后,需要运行这个代码才能知道是否正确。在这种情况下,使用单元测试技术,不但能完成这个代码的测试运行,而且还可以把测试代码,作为库的“示例”代码进行积累。
对于有比较完整的测试代码的项目,在后续的工作中,我可以放心大胆的对各种细节进行重构。只要最后单元测试都通过,我就不必过于担心自己写的程序是否有问题。也不用太害怕让使用中的项目进行升级会不会出问题。甚至我丢失了全部的正式库代码,只要单元测试代码还在,我都可以很快的重新把这个库写出来。
可以逐个部分验证程序是否正确,降低查找问题的难度
可以用作程序之间调用的模拟程序,减少开发工作中的沟通难度
可以作为复用代码(库)的示例程序,同时也是开发者对用户程序员的使用流程的一种设计过程
可以用程序来描述需求,真实快速的确保程序是否完成了需求
用来作为重构的保障机制,提高开发效率
因此,人们也开发了各种帮助我们编写单元测试的工具,这些工具一般包含了以下几个部分:
框架 :一个可以独立运行的程序,通常是带 main() 函数的,这个程序可以由用户去填写一系列回调函数,用来对测试的整个流程进行填充,一般来说测试过程都需要有这些回调函数:开始测试、每个用例开始前、每个用例的调用、每个用例结束后、全体结束后。 一般对于一系列预期的函数进行调用,称之为“用例”。 之所以需要这么多过程,是因为想要真实的运行一个函数,往往需要先准备好这个函数运行所需的内存环境,而且每个用例所需的环境是不一样的,所以结束一次用例之后,还需要清理环境。
Mock : 对于各种数据库、网站数据源(如 Restful API)等外部依赖,提供本地内存中就可以使用的模拟。我们称之为 Mock 程序。这样单元测试才能在最简单的环境下快速运行。
Assert : 把运行结果和期望值对比的函数——如果对比不同则显示对比的结果,并且宣告测试失败;如果全部结果对比成功就宣布一个用例测试通过。
JUnit 使用“注解”(annotation)来标注框架所需的回调函数,最简单的就是在你的方法上标注 @Test 。除此以外,JUnit 还提供了很多其他注解,用以构建整个测试流程,譬如初始化测试环境、准备测试条件、清理测试结果环境等等,这些可以在手册上查到。
// 一个简单的单元测试程序
import org.junit.Test;
import static org.junit.Assert.assertEquals;
public class TestJunit {
String message = "Hello World";
MessageUtil messageUtil = new MessageUtil(message); // 这个 MessageUtil 就是你要测试的对象
@Test
public void testPrintMessage() {
assertEquals(message,messageUtil.printMessage()); // 断言,报告测试成功或者失败
}
}
然后你可以自己写一个简单的 main 入口启动测试,或者使用各种 IDE 来启动单元测试。所有标注了是单元测试的方法都会被调用,并且输出测试结果。
// 使用 main 函数自己启动 TestJunit 类的单元测试
import org.junit.runner.JUnitCore;
import org.junit.runner.Result;
import org.junit.runner.notification.Failure;
public class TestRunner {
public static void main(String[] args) {
Result result = JUnitCore.runClasses(TestJunit.class);
for (Failure failure : result.getFailures()) {
System.out.println(failure.toString());
}
System.out.println(result.wasSuccessful());
}
}
如果测试失败,屏幕上会显示结果,并且说明哪个“用例”失败了,期望值和实际值是什么:
Hello World
testPrintMessage(TestJunit): expected:<[New Wor]d> but was:<[Hello Worl]d>
false
如果你是使用 IDE,单元测试的运行往往不需要去写 main 函数了,很多 IDE 都提供了直接运行你的用例,并且显示结果的友好界面。
Junit 提供了挺多的断言函数,都是为了方便我们写最后的测试结果判断。
Assert.assertEquals("预期为1,相等啦", 1, 1);
Assert.assertNotEquals("预期为1,不相等", 1, 2);
Assert.assertTrue("真的", true);
Assert.assertNull("空", someVar);
大部分的单元测试框架,都提供了这些断言函数,所以后面介绍就会略过这一部分咯。
对于编写单元测试来说,Mock 模拟程序运行环境,一直是一个难点。首先说说为什么需要模拟环境,而不是直接在真实环境下运行要测试的程序:假设你的程序是一个对 MySQL 数据进行操作的程序,如果要测试运行,是需要有一个能连接上的 MySQL 数据库服务器的。你当然可以在开发环境下安装一套开发用的 MySQL,确保测试运行的时候这个服务器是正常运行的。但是我们的单元测试有时候需要在 CI 持续集成系统上运行,那个环境未必安装了一套 MySQL 程序,如果要确保单元测试能运行,你就必须在每一个可能运行单元测试的地方都安装 MySQL,这个安装和维护的工作量会非常繁重;另外一个原因是,就算有了 MySQL 服务器,你还需要数据库里有合适的数据表和数据记录,当然你也可以在测试用例中的准备阶段进行删表重建、插入数据等等——但是这么一来,编写的代码量也很大了,而且如果有大量的用例需要运行,真实的数据库操作会让单元测试运行非常缓慢。如果依赖的仅仅是 MySQL 还好一点,但如果是一些非标准的程序,譬如某个 RESTful 的数据接口,你根本无法自己安装一套,那就只能用模拟 mock 了。
JUnit 本身并不提供 Mock 的功能,但是我们可以找到其他帮助软件,譬如 Mockito
@RunWith(MockitoJUnitRunner.class)
public class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
public void testGetUserById () {
// 安排
User mockUser = new User ( 1 , "John Doe" );
Mockito.when(userRepository.findById( 1 )).thenReturn(Optional.of(mockUser));
// 行动
User result = userService.getUserById( 1 );
// 断言
assertEquals( "John Doe" , result.getName());
}
}
Mockito 提供 @Mock 和 @InjectMocks 等注释,有助于简化模拟对象的创建及其向被测类的注入。
@Mock :此注释用于创建指定类的模拟实例。在上面的示例中,使用此注释模拟了 UserRepository。在我们的测试目标 userService 对象的代码中,是会调用 UserRepository 类来获取数据的,所以我们模拟了这个 UserRepository 的行为用于测试,而不是真正的去查数据库。
@InjectMocks :此注解告诉 Mockito 将模拟(如 UserRepository)注入到被测试的类(UserService)中,从而有效地自动连接模拟依赖关系。
一般的 Mock 框架,都会提供一种技术,去让某些已经写好的类(或者接口)产生固定的行为,譬如返回预定的结果值。这样被测试的代码,在运行时并不知道自己所依赖的类已经被替换。这样就避免了这些被依赖的类去连接真正的数据库或者其他不好在单元测试中运行的代码。
Google 的 C++ 单元测试框架 gtest,需要用户自己写个 main 函数:
#include "gtest/gtest.h"
#include <iostream>
#include <string>
int add(int a, int b)
{
return a + b;
}
TEST(fun, add_a)
{
EXPECT_EQ(-3, add(-2,-1));
EXPECT_EQ(-2, add(1,-3));
}
int main(int argc, char **argv){
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
return 0;
}
这里的大写 TEST RUN_ALL_TESTS 都是 C++ 框架常用的“宏”(地精魔法),按照规矩写问题也不大就是了。
gtest 的 TEST() 定义的是一个用例,可以通过参数标注这个用例的目标、用例的名字,例子中 fun 就是用例目标,add_a 就是用例名字。 EXPECT_EQ() 就是断言的宏函数了,和之前 JUnit 的 Asserts 类似,也是有一批各种判断断言。
c++ 单元测试也有 mock 工具,譬如 gmock,它可以模拟一个已经定义的类的行为,例子:
class FooInterface {
public:
virtual ~FooInterface() {}
public:
virtual std::string getArbitraryString() = 0;
};
#include <gmock/gmock.h>
class MockFoo: public FooInterface {
public:
MOCK_METHOD0(getArbitraryString, std::string());
};
我们再次看到宏魔法 MOCK_METHOD0() ,用于定义需要模拟的方法。编写单元测试的时候可以直接定义模拟行为:
#include <cstdlib>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <iostream>
#include <string>
#include "MockFoo.h"
using namespace seamless;
using namespace std;
using ::testing::Return;
int main(int argc, char** argv) {
::testing::InitGoogleMock(&argc, argv);
string value = "Hello World!";
MockFoo mockFoo;
EXPECT_CALL(mockFoo, getArbitraryString()).Times(1).
WillOnce(Return(value));
string returnValue = mockFoo.getArbitraryString();
cout << "Returned Value: " << returnValue << endl;
return EXIT_SUCCESS;
}
这里通过 EXPECT_CALL(mockFoo, getArbitraryString()).Times(1).WillOnce(Return(value)) 表示模拟的类对象 mockFoo 的 getArbitraryString() 会返回 value 变量的值。这里的 Times(1) 表示模拟只被运行 1 次, WillOnce() 表示被运行这一次的时候会做出一次性的行为。实际上可以定义模拟方法多次调用应该做些什么事,返回什么值:
EXPECT_CALL(mockTurtle, getX()).Times(testing::AtLeast(5)).
WillOnce(testing::Return(100)).WillOnce(testing::Return(150)).
WillRepeatedly(testing::Return(200))
调用mockTurtle的getX()方法
这个方法会至少调用5次
第一次被调用时返回100
第2次被调用时返回150
从第3次被调用开始每次都返回200
go 可以不写断言,而是先自己用 if 判断情况,然后用单元测试函数的入参 (t *testing.T) 来宣告测试失败:
在有 xxx_test.go 的目录下,直接运行 go test 就可以运行所有单元测试文件里面的测试用例函数。
=== RUN TestSum
--- PASS: TestSum (0.00s)
=== RUN TestAbs
--- PASS: TestAbs (0.00s)
PASS
运行结果会显示在命令行界面。go 的单元测试还会统计覆盖率,以及很多其他功能,可以通过手册进行探索。
现代大多数的语言,都会支持单元测试。说明单元测试已经是一个“标配”功能,是业界公认的一个重要事情。
这一术语源自硬件行业。对一个硬件或硬件组件进行更改或修复后,直接给设备加电。如果没有冒烟,则该组件就通过了测试。微软公司在《微软项目求生法则》一书中提出的一种功能测试,目的是对一个新编译需要正式测试的软件版本,确认软件的基本功能是正常的,可以进行后续的测试工作。
“冒烟测试”这个名称,其实不是一个非常准确的说法,大概的意思,就是一个快速运行的测试过程,来确保软件的主要功能是可用的。在一些比较大型的项目中,测试团队在启动任何测试计划之前,都会先快速的执行一次“冒烟测试”,如果出现问题,就停止测试计划打回给开发团队,避免浪费测试时间。这种测试可以是自动化的,也可以是手工的。冒烟测试主要包含两个含义:
我们都知道详细的测试能带来很多的好处,但是成本也相当的高,譬如需要大量的测试代码、测试环境、运行测试的时间等等。但如果我们只是改了一行代码,运行一次全面的测试就很不划算了,所以应该运行一次“冒烟测试”。我们修改软件的时候,常常会有意想不到的“副作用”,常常会造成明显的整程序挂了。冒烟测试可以保障我们的软件在最低程度上是可用的。
冒烟测试可以由一些单元测试组成,也可以和单元测试无关。但是通过自动化的手段,写一个程序来进行测试,显然是最好的做法,因为这可以放在 CI 持续集成的流水线中,当每次发布完成,都自动的运行一次。如果有严重的问题,就可以立刻被知道了。
我在某一个游戏项目里,使用了 gtest 框架写了一个对这个游戏服务器的冒烟测试:这个程序会启动游戏服务器,然后向这个服务器监听的网络端口发送网络包,模拟客户端的注册、登录、选人、匹配、开始游戏、结算这套基本流程。每当我开发完一个功能,都会首先运行这个测试,当然这个过程比单元测试要长,而且也对数据库和网络有依赖,不过确实帮我发现了很多程序的问题,在和游戏客户端联调之前,通过了这个冒烟测试后,问题会少很多。
压力测试可以写代码来模拟很多请求。同时也可以通过录制真实用户的输入,然后并发执行来模拟。我曾经经历过一个网络游戏项目,曾经对用户的访问数据包,都旁路了一份进行存储。每个用户的每次登录,都建立一个文件,本次“会话”的所有之类都会记录在同一个文件里。我们把这些文件用程序同时打开,然后一起发送用户的指令,就非常真实的模拟了多人对游戏服务器的压力。
大部分语言,都有工具可以记录运行时的“火焰图”,这个图会根据程序调用函数的深度,生成火焰的高度,而函数运行的时间则生成火焰的宽度。这个图能非常直观的看到程序运行时间到底被什么函数使用了。
内存的使用,也有类似 pprof 的工具,可以展示程序运行过程中内存的分布情况
程序员都普遍很喜欢搞性能优化,因为这似乎能说明自己的技术水平。有一些大厂,也很喜欢拿这些来评判程序员的水平。但是我认为,大部分的性能优化,是通过时间-空间转换来进行优化的。除此之外的优化,往往需要在数学上使用更突破的方法,才能真正提高性能,而这并不是很常见的情况。当然也有专门利用新的硬件、操作系统功能来进行优化的,还有就是把功能设计成可以多个电脑并行处理。——这些都是技术水平的体现,但开发效率的提升往往更加急迫,而且开发效率的提升,也能提高性能优化的速度。
通过测试,我们会发现软件的问题,这些问题如何被管理起来,是需要一些工具。最古老的工具就是一个 Excel 电子表格,高级一点的可以用一个云文档来记录。有些公司针对缺陷管理,开发了一些软件工具。我最早接触的是 JIRA,这个工具的界面有点奇怪,但是用来记录和跟踪 bug 还是足够的。之前也尝试拿它当项目管理工具,但是它没有“甘特图”,所以最后还是不够方便。另外我们还有一些,譬如 TAPD,Redmine。另外诸如 GitLab 也可以在作为源码服务器的同时,承担缺陷跟踪的功能。
测试驱动开发(英语:Test-driven development,缩写为TDD)是一种软件开发过程中的应用方法,由极限编程中倡导,以其倡导先写测试程序,然后编码实现其功能得名。测试驱动开发始于20世纪90年代。
关于测试驱动开发,曾经在概念上流行一时,但是实际上使用的项目却不多。原因有很多,其中主要的有:
不过,有自动化测试程序的项目,质量是明显比没有的项目,要高很多。随着测试用例的积累和维护,这种质量还能一直保持,这比起靠人工测试的责任心驱动,显然是好很多的。可能我们的项目,不是每个都适合使用测试驱动开发,但是用测试用例来描述需求,通过运行测试来检查需求是否完成,显然自动化程度远比人工验收来的快速和全面。
前面说了大量的工具向的软件工程知识,下来想讲讲代码层面的软件工程知识,也是我最喜欢的部分。
评论区
共 18 条评论热门最新