过去两天,我们回顾我们过去5天学过的内容,增加和修改了一些功能。在这个过程中, 我们也接触到symfony一些高级功能。
今天我们将学习一些和前学到的、完全不同的内容:自动化测试。因为将包括很多内容, 我们需要花费2天的时间学习这些内容。
symfony中的测试
symfony有2种不同种类的自动化测试:单元测试(unit tests)和功能测试( functional tests)。
单元测试检验每个方法和函数是否正常工作,每个测试必须尽可能的独立。
功能测试检验整体程序运行过程是否正确。
symfony所有测试都在test/
目录下,有两个子目录。一个存储单元测试文件
(test/unit/
),另一个存储功能测试文件(test/functional/
)。
今天的课程讲解单元测试,明天讲功能测试。
单元测试
写单元测试是网站开发中最艰难环节之一,也是最好的习惯之一。当网页开发者没有 真正检验自己的工作,就会出现问题: 我添加功能前写测试程序吗?我需要测试什么? 我的测试是否需要涵盖各种特殊情况?我如何确定所有内容都通过了测试?但通常一个 最基础的问题是:从哪开始?
symfony提倡的是一种实用主义的方式:有一点总比没有强。你有许多没有测试的代码吗? 没问题,你不需要一套完整测试套件,就可以从测试中获益。你可以在发现bug时,再开始 添加测试。久而久之,你的代码将变得更好,代码覆盖度将提高,你对代码将更有信心。 以实用主义的方式开始测试程序,不久之后你就会感到适应的。下面我们为新功能写测试。 我保证你很快就会上瘾的。
大部分测试库存在的问题是,巨大的学习成本。这也是symfony为什么提供非常简单的 测试库——lime的原因,它将让编写测试程序非常轻松。
note
Even if this tutorial describes the lime built-in library extensively, you can use any testing library, like the excellent PHPUnit library.
lime
测试框架
所有使用lime框架进行的单元测试,都以相同的代码开始:
require_once dirname(__FILE__).'/../bootstrap/unit.php'; $t = new lime_test(1, new lime_output_color());
首先,引用unit.php
引导文件进行简单的初始化。然后,创建实例化lime_test
对象,
该对象第一个参数表示需要执行多个测试项。
note
如果最终执行测试的数量与该参数值不同,lime会输出一条警告信息。(例如 一个测试产生了php致命错误)。
测试程序调用方法或函数,并让其使用预定义值执行,然后将执行结果与预期的 结果相比较 以判断测试是否通过。
为更容易进行结果比较,lime_test
对象提供了几个方法:
方法 | 描述 |
---|---|
ok($test) |
测试一个条件如果为true ,测试通过。 |
is($value1, $value2) |
比较两个值如果相等(== ),测试通过。 |
isnt($value1, $value2) |
比较两个值如果不相等,测试通过。 |
like($string, $regexp) |
一个字符串与正则表达式匹配,测试通过。 |
unlike($string, $regexp) |
一个字符串与正则表达式不匹配,测试通过。 |
is_deeply($array1, $array2) |
检查两个数组有相同值 |
tip
你可能想知道为什么lime定义这么多测试方法,因为所有的测试都可以通过ok()
一个方法实现。采取供选择的方法的好处在于,假如测试失败会有更明确的错误信息,
可以提高测试可读性。
lime_test
对象也提供了其他方便的测试方法:
方法 | 描述 |
---|---|
fail() |
总是失败,用于测试异常。 |
pass() |
总是通过,用于测试异常。 |
skip($msg, $nb_tests) |
作为$nb_tests 计数,用于条件测试。 |
todo() |
作为一个测试的计数,用于测试仍然被写。 |
如果没有进行任何测试,comment($msg)
方法最后会输出一条注释。
运行单元测试
所有的单元测试文件存储在test/unit/
目录下。按约定,测试文件命名方式是”类名+Test”。
你可以按喜欢的方式组织目录中文件,但我们还是建议你仿照lib/
的目录结构。
To illustrate unit testing, we will test the Jobeet
class.
创建test/unit/JobeetTest.php
文件,复制以下内容到文件中:
// test/unit/JobeetTest.php require_once dirname(__FILE__).'/../bootstrap/unit.php'; $t = new lime_test(1, new lime_output_color()); $t->pass('This test always passes.');
你可以直接运行文件,来执行测试:
$ php test/unit/JobeetTest.php
或使用test:unit
命令:
$ php symfony test:unit Jobeet
note
Windows命令行没有代码高亮显示。
测试slugify
方法
让我们以Jobeet::slugify()
单元测试,作为我们的开始。
我们在第5天创建slugify()
方法来清理字符串,让它可以安全地包含在URL中。这个方法
包含一些基础的转化,如将非ASCII字符转换成(-
)或将字符串转换为小写字母。
Input | Output |
---|---|
Sensio Labs | sensio-labs |
Paris, France | paris-france |
用下面的代码替换测试文件中的内容:
// test/unit/JobeetTest.php require_once dirname(__FILE__).'/../bootstrap/unit.php'; $t = new lime_test(6, new lime_output_color()); $t->is(Jobeet::slugify('Sensio'), 'sensio'); $t->is(Jobeet::slugify('sensio labs'), 'sensio-labs'); $t->is(Jobeet::slugify('sensio labs'), 'sensio-labs'); $t->is(Jobeet::slugify('paris,france'), 'paris-france'); $t->is(Jobeet::slugify(' sensio'), 'sensio'); $t->is(Jobeet::slugify('sensio '), 'sensio');
如果你仔细观察我们写到测试,你将注意到每行代码只测试一种情况。你必须记住这条规则: 一个测试项只测试一种情况。
现在可以运行测试文件。如果所有测试项通过,最后将显示“绿色条”表示通过,否则出现 “红色条”警告你有没通过的测试项。
如果一个测试项测试失败,将输出一些提示信息,描述失败原因;但如果文件中有上百个 测试项,从中找出错误信息将是很难的一件事。
所有lime测试方法都有一个字符串作为最后一个参数,这个参数用来显示说明信息。用来
添加该测试项的描述信息。它也可以作为方法预期行的格式文档,让我们添加一些描述信息
到slugify
测试文件:
require_once dirname(__FILE__).'/../bootstrap/unit.php'; $t = new lime_test(6, new lime_output_color()); $t->comment('::slugify()'); $t->is(Jobeet::slugify('Sensio'), 'sensio', '::slugify() converts all characters to lower case'); $t->is(Jobeet::slugify('sensio labs'), 'sensio-labs', '::slugify() replaces a white space by a -'); $t->is(Jobeet::slugify('sensio labs'), 'sensio-labs', '::slugify() replaces several white spaces by a single -'); $t->is(Jobeet::slugify(' sensio'), 'sensio', '::slugify() removes - at the beginning of a string'); $t->is(Jobeet::slugify('sensio '), 'sensio', '::slugify() removes - at the end of a string'); $t->is(Jobeet::slugify('paris,france'), 'paris-france', '::slugify() replaces non-ASCII characters by a -');
这些说明字符串在你试图指出该测试项的功能时,尤其有用。你可以观察出这些字符的 输出格式:以方法名开头,后接该方法应如何行为的描述。
为新功能添加测试
假如给slugify()
传递一个空的字符串,它将返回一个空字符串。你可以添加这样的
测试,它也会通过测试。但是一个空字符串在URL中没有意义。让我们修改一下
slugify()
方法,假如是空字符的话返回n-a
字符串。
你可以先写测试代码,然后更新方法,或者相反。先写测试代码会给你信心:即将 加入的功能的确可以运行。
$t->is(Jobeet::slugify(''), 'n-a', '::slugify() converts the empty string to n-a');
This development methodology, where you first write tests then implement features, is known as Test Driven Development (TDD).
如果你现在运行测试,会显示一条红色的错误提示。因为这个新功能还没有添加到
slugify()
方法中,或者已经添加的新功能没有通过测试。
现在更新Jobeet
类,在开始处加入下面的条件语句:
// lib/Jobeet.class.php static public function slugify($text) { if (empty($text)) { return 'n-a'; } // ... }
现在你必须更新测试计划中测试量,这个测试才可以通过。否则,你将看到一条类似的 提示:你计划进行6个测试,1个额外运行。更新计划测试量很重要,因为如果测试脚本 提前结束,你会马上知道。
为调试bug添加测试
让我们讨论这种情况,测试已经通过了不过一个用户向你提交了bug:一些招聘信息指向404页面。
你经过调查,错误的原因是这些招聘信息含有空公司名、职位或本地化字符。这不可能啊?因为你
从头到尾检查了整个数据库,并没有发现空字段。最后你发现,当字符串中全部都是非ASCII字符时,
slugify()
会将它转换成空字符串。然后你很高兴地打开Jobeet
类修改代码…不过这不是个好注意。
在修复bug之前,我们应该先进行一下测试:
$t->is(Jobeet::slugify(' - '), 'n-a', '::slugify() converts a string that only contains non-ASCII characters to n-a');
如我们所料,测试没有通过,现在修改slugify()
方法,将空字符检查添加到后面:
static public function slugify($text) { // ... if (empty($text)) { return 'n-a'; } return $text; }
新的测试也通过。现在,虽然我们的测试代码的覆盖率已经达到了100%,但slugify()
仍然存在bug。
当你写测试代码的时候,你不可能考虑到所有的特殊情况,而且测试时它们也没有出现问题。 但是一旦你发现bug时,一定要在修复之前先写测试代码。这意味着随着时间的推移,你的 代码将变得越来越好。
Doctrine单元测试
数据库配置
因为需要数据库连接,所以Doctrine模型类的单元测试稍微复杂一些。你已经有了开发用的数据库, 但作为良好习惯,你应该创建一个专用的测试数据库。
在第1天的课程中,我们介绍过环境是改变程序设置一种手段。默认的,所有测试程序都运行在测试 环境中,所以我们需要给测试环境配置一个不同的数据库:
$ php symfony configure:database --name=doctrine --class=sfDoctrineDatabase --env=test "mysql:host=localhost;dbname=jobeet_test" root mYsEcret
env
选项告诉命令,这个数据库配置只用于测试(test
)环境。我们在第3天使用这个命令时没有
带任何env
选项,所以那个数据库配置被应用到所有环境。
note
如果你好奇,打开config/databases.yml
配置文件,看看symfony改变依赖的环境是多么容易。
现在我们已经配置了数据库,现在用doctrine:insert-sql
导入:
$ mysqladmin -uroot -pmYsEcret create jobeet_test $ php symfony doctrine:insert-sql --env=test
测试数据
我们有了专用测试数据库,现在需要导入一些数据。第3天时候我们用doctrine:data-load
导入过数据。但是对测试环境来说,这些数据需要在每次使用时自动加载。
doctrine:data-load
内部使用Doctrine::loadData()
方法加载数据:
Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures');
note
sfConfig
对象可以用来获取项目子目录的完整路径。使用它来引用路径,
要比硬编码路径更容易修改目录结构。
loadData()
方法将一个目录或文件作为第一个参数。也可以用包含多个目录、文件的数组作为参数。
我们已经在data/fixtures/
目录下创建了一些初始数据。单元测试和功能测试使用的初始化数据
保存test/fixtures/
目录下。
现在复制data/fixtures/
文件到test/fixtures/
目录中。
测试JobeetJob
我们为JobeetJob
模型类添加一些单元测试。
我们所有的Doctrine单元测试将以相同的代码开始,在test/bootstrap/
下创建Doctrine.php
文件:
// test/bootstrap/Doctrine.php include(dirname(__FILE__).'/unit.php'); $configuration = ProjectConfiguration::getApplicationConfiguration( 'frontend', 'test', true); new sfDatabaseManager($configuration); Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures');
这些代码的功能显而易见:
对于前端控制器,我们为测试环境(
test
environment)初始化一个配置对象:$configuration = ProjectConfiguration::getApplicationConfiguration( 'frontend', 'test', true);
我们创建数据库管理器。通过读取
databases.yml
配置初始化Doctrine连接:new sfDatabaseManager($configuration);
我们使用
Doctrine::loadData()
读取测试数据:Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures');
note
只有存在需要运行的SQL语句时,Doctrine才会连接数据库。
现在万事俱备,我们可以开始测试JobeetJob
类啦。
首先,我们需要创建在test/unit/model
目录下JobeetJobTest.php
文件:
// test/unit/model/JobeetJobTest.php include(dirname(__FILE__).'/../../bootstrap/Doctrine.php'); $t = new lime_test(1, new lime_output_color());
然后,我们给getCompanySlug()
添加测试:
$t->comment('->getCompanySlug()'); $job = Doctrine::getTable('JobeetJob')->createQuery()->fetchOne(); $t->is($job->getCompanySlug(), Jobeet::slugify($job->getCompany()), '->getCompanySlug() return the slug for the company');
注意,我们值测试getCompanySlug()
方法生成slug是否正确,因为其它测试项刚才已经测试过了。
save()
测试,稍微有些复杂:
$t->comment('->save()'); $job = create_job(); $job->save(); $expiresAt = date('Y-m-d', time() + 86400 * sfConfig::get('app_active_days')); $t->is(date('Y-m-d', strtotime($job->getExpiresAt())), $expiresAt, '->save() updates expires_at if not set'); $job = create_job(array('expires_at' => '2008-08-08')); $job->save(); $t->is(date('Y-m-d', strtotime($job->getExpiresAt())), '2008-08-08', '->save() does not update expires_at if set'); function create_job($defaults = array()) { static $category = null; if (is_null($category)) { $category = Doctrine::getTable('JobeetCategory') ->createQuery() ->limit(1) ->fetchOne(); } $job = new JobeetJob(); $job->fromArray(array_merge(array( 'category_id' => $category->getId(), 'company' => 'Sensio Labs', 'position' => 'Senior Tester', 'location' => 'Paris, France', 'description' => 'Testing is fun', 'how_to_apply' => 'Send e-Mail', 'email' => 'job@example.com', 'token' => rand(1111, 9999), 'is_activated' => true, ), $defaults)); return $job; }
note
每次添加测试,不要忘记更新lime_test
构造方法中的计划测试量。对于JobeetJobTest
你需要
将它从1
改为3
。
测试其他Doctrine类
你现在可以为其它Doctrine类添加测试,因为你现在已经习惯了编写单元测试的过程, 它非常简单。你可以通过SVN得到我们今天初始化数据和相关的单元测试(release_day_08标签下)。
打包测试
可以用test:unit
命令(task)执行全部单元测试:
$ php symfony test:unit
命令输出每个测试文件是否通过:
tip
If the test:unit
task returns a "dubious status" for a
file, it indicates that the script died before end. Running the test file
alone will give you the exact error message.
明天见
虽然测试程序非常重要,但可能有些人跳过了今天的课程。我很高兴你没有。
当然,学习symfony意味着要学习它提供的所有好的功能,同时也要学习基本的开发原理(philosophy) 和良好的编程习惯(best practices),测试就是其中之一。或快或慢,单元测试会为你节省开发时间。 让你对自己的代码有坚定信心,不再害怕重构代码。单元测试是一个安全守卫,它会在 你出现问题时提醒你。symfony框架本身的测试超过9000条。
明天我们为job
和category
模块写一些功能测试。在这之前花些时间,给Jobeet模型类
写更多的单元测试。
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.