Tóm tắt
Trong những ngày cuối tuần vừa qua, chúng ta đã chỉnh sửa lại những tính năng đã có và thêm một tính năng mới. Trong quá trình đó, chúng ta cũng học được nhiều kiến thức về symfony.
Hôm nay, chúng ta sẽ nói về một chủ đề hoàn toàn khác: test tự động. Vì vấn đề này rất rộng nên chúng ta sẽ dành trọn 2 ngày để nói về nó.
Test trong symfony
Có hai loại automated test trong symfony: unit tests và functional tests.
Unit test để đảm bảo rằng mỗi method và function đều hoạt động đúng. Các test phải độc lập với nhau.
Còn functional test là để đảm bảo rằng kết quả trả về của ứng dụng đúng như mong đợi.
Tất cả test của symfony đều nằm trong thư mục test/
của project.
Nó chứa 2 thư mục con, một cho unit tests (test/unit/
) và một cho functional tests (test/functional/
).
Unit test sẽ được đề cập đến trong ngày hôm nay, ngày mai chúng ta sẽ nói về functional test.
Unit Tests
Viết unit tests có lẽ là một trong những cần thiết nhất đề có thể phát triển một ứng dụng tốt. Đối với một web developer chưa từng sử dụng test trong công việc, sẽ xuất hiện rất nhiều câu hỏi: Tôi phải viết test trước khi thực hiện một chức năng? Tôi cần test để làm gì? Test của tôi cần bao quát mọi trường hợp có thể? Làm thế nào để chắc rằng mọi thứ được test đúng? Nhưng thường có một câu hỏi cơ bản hơn: Bắt đầu như thế nào?
Mặc dù chúng tôi tán thành việc test, nhưng symfony cũng khá thực tế: có một vài test tốt hơn là không có test nào. Bạn đã viết rất nhiều code mà không có bất kì test nào? Không sao cả. Bạn không cần thiết phải có tất cả bộ test để hiểu lợi ích của việc test. Hãy bắt đầu thêm tests khi bạn tìm thấy một bug trong code của bạn. Sau đó, code của bạn sẽ trở nên tốt hơn. Bắt đầu theo cách thực dụng như vậy, bạn sẽ cảm thấy quen với việc tests hơn. Sau đó, chúng ta có thể viết test cho một tính năng mới trước khi thực hiện nó. Không lâu sau, bạn sẽ trở thành một con nghiện test :)
Một vấn đề với phần lớn các thư viện test là sự phức tạp của nó. Vì thế symfony cung cấp một thư viện test đơn giản, lime, khiến cho việc viết test trở nên dễ dàng hơn.
note
Mặc dù hướng dẫn này nói về thư viện có sẵn lime , nhưng bạn hoàn toàn có thể sử dụng bất kì thư viện test nào khác, như PHPUnit chẳng hạn.
lime
Testing Framework
Tất cả unit test viết trong lime framework đều bắt đầu như sau:
require_once dirname(__FILE__).'/../bootstrap/unit.php'; $t = new lime_test(1, new lime_output_color());
Đầu tiên, file bootstrap unit.php
được include để khởi tạo một vài thứ.
Sau đó, một đối tượng lime_test
mới được tạo ra kèm theo số lượng test dự định thực hiện.
note
Việc khai báo số test dự định cho phép lime trả về thông báo lỗi trong trường hợp có quá nhiều test được chạy (ví dụ khi test được tạo bởi một PHP fatal error).
Việc test được thực hiện bằng cách gọi một method hay một function với đầu vào xác định và so sánh kết quả trả về với kết quả mong đợi. Sự so sánh này quyết định một test là passes hay fails.
Để tiện cho việc so sánh, đối tượng lime_test
cung cấp một số phương thức:
Phương thức | Mô tả |
---|---|
ok($test) |
Test một mệnh đề và passes nếu nó đúng |
is($value1, $value2) |
So sánh 2 giá trị và passes nếu chúng bằng nhau |
(== ) |
|
isnt($value1, $value2) |
So sánh 2 giá trị và passes nếu chúng |
không bằng nhau | |
like($string, $regexp) |
So sánh một chuỗi với một biểu thức chính quy |
unlike($string, $regexp) |
Kiểm tra xem chuỗi có không match với một |
regular expression | |
is_deeply($array1, $array2) |
Kiểm tra xem 2 array có cùng giá trị |
tip
Bạn có thể thắc mắc tại sao lime lại tạo ra nhiều method test đến vậy,
trong khi tất cả chúng đều có thể được viết từ method ok()
.
Lợi ích nằm ở chỗ sẽ có các thông báo lỗi khác nhau cho từng trường hợp
failed , giúp dễ hiểu hơn.
Đối tượng lime_test
cũng cung cấp các phương thức test tiện lợi khác:
Phương thức | Mô tả |
---|---|
fail() |
luôn trả về fails--thường dùng cho test exceptions |
pass() |
luôn trả về passes--thường dùng cho test exceptions |
skip($msg, $nb_tests) |
Counts as $nb_tests tests--useful for conditional |
tests | |
todo() |
Counts as a test--useful for tests yet to be |
written |
Cuối cùng, phương thức comment($msg)
trả về một comment và không chạy test nào.
Running Unit Tests
Tất cả unit tests được chứa trong thư mục test/unit/
. Thông thường,
tên file test là tên của lớp mà nó cần test kèm theo cụm Test
. Mặc dù bạn có thể tổ chức file trong thư mục test/unit/
như thế nào cũng được, nhưng chúng tôi khuyên bạn sử dụng cấu trúc của thư mục lib/
.
Tạo file test/unit/JobeetTest.php
và copy đoạn code sau:
// 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.');
Để chạy file test, bạn có thể gọi trực tiếp:
$ php test/unit/JobeetTest.php
Hoặc dùng lệnh test:unit
:
$ php symfony test:unit Jobeet
note
Dòng lệnh trong Windows không thể highlight kết quả test với màu đỏ hoặc xanh.
Test phương thức slugify
Hãy bắt đầu khám phá thế giới unit test bằng cách viết tests cho phương thức Jobeet::slugify()
.
Chúng ta tạo phương thức slugify()
ở ngày 5 thực hiện việc format lại chuỗi để an toàn hơn khi đưa lên URL. Nó chuyển tất cả các kí tự non-ASCII thành kí tự (-
) và chuyển kí tự viết hoa thành viết thường:
Input | Output |
---|---|
Sensio Labs | sensio-labs |
Paris, France | paris-france |
Thay nội dung của file test bằng đoạn code sau:
// 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');
Nếu bạn để ý đoạn code trên bạn sẽ thấy rằng mỗi dòng chỉ test một thứ. Đó là điều bạn cần nhớ khi viết unit tests. Chỉ test một thứ ở một thời điểm.
Bây giờ, bạn có thể chạy file test. Nếu tất cả đều pass, như chúng ta mong đợi, bạn sẽ thấy một "green bar". Nếu không, "red bar" sẽ cảnh báo bạn rằng có một số test không pass và bạn cần fix chúng.
Nếu test fail, màn hình sẽ chỉ rõ là test nào failed; nhưng nếu bạn có hàng trăm test trong một file, thật khó để xác định test fails là test về cái gì.
Tất cả các phương thức test đều có tham số cuối là một chuỗi mô tả nội dung test. Hãy thêm một vài thông báo vào file test 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 -');
Thêm Tests cho một tính năng mới
Hàm slug đối với một chuỗi rỗng là một chuỗi rỗng. Bạn có thể test điều này, và hàm này thực hiện đúng như vậy. Nhưng một chuỗi rỗng không được tốt khi đưa lên URL. Hãy sửa phương thức slugify()
để nó trả về "n-a" trong trường hợp chuỗi rỗng.
Bạn có thể viết test trước, sau đó sửa lại phương thức, hoặc ngược lại. Chọn cách nào là tùy bạn, tuy nhiên viết test trước đem lại cho bạn cảm giác tin cậy rằng code của bạn thực thi đúng những gì bạn dự định:
$t->is(Jobeet::slugify(''), 'n-a', '::slugify() converts the empty string by n-a');
Nếu bạn chạy test bây giờ, bạn sẽ thấy red bar. Nếu không, tức là tính năng này đã được thực thi hoặc chương trình test bị sai!
Bây giờ, sửa lại lớp Jobeet
và thêm đoạn điều kiện sau ở đầu:
// lib/Jobeet.class.php static public function slugify($text) { if (empty($text)) { return 'n-a'; } // ... }
Test bây giờ sẽ pass như mong đợi, nhưng bạn phải chắc rằng đã sửa lại số lượng test dự kiến. Nếu không, bạn sẽ nhận được thông báo rằng bạn dự định thực hiện 6 test và bạn đã thêm một test ngoài. Dự định trước số lượt test là quan trọng, bạn có thể kiểm soát được nếu test script die sớm.
Thêm Tests khi có một Bug
Khi có một người dùng thông báo một lỗi kì lạ: một vài link đến job chuyển sang trang lỗi 404. Sau khi tìm hiểu, bạn nhận ra rằng vì lý do nào đó, những jobs này có company, position, hay location slug là rỗng.
Sao lại có thể như vậy được? Bạn kiểm tra lại những giá trị này trong database và thấy rằng chúng không rỗng. Bạn đau đầu vì nó, và thật mau mắn, cuối cùng bạn cũng tìm ra lý do. Khi một chuỗi chỉ chứa kí tự non-ASCII, phương thức
slugify()
sẽ biến nó thành một chuỗi rỗng. Vui sướng vì tìm ra lý do, bạn mở lớp Jobeet
và sửa vấn đề ngay lập tức. Đó không phải là cách làm tốt. Đầu tiên, bạn hãy thêm một test:
$t->is(Jobeet::slugify(' - '), 'n-a', '::slugify() converts a string that only contains non-ASCII characters by n-a');
Sau khi kiểm tra rằng test này không pass, sửa lại lớp Jobeet
và chuyển đoạn kiểm tra chuỗi rỗng xuống cuối phương thức:
static public function slugify($text) { // ... if (empty($text)) { return 'n-a'; } return $text; }
Bây giờ test vừa thêm vào đã passes. Phương thức slugify()
bây giờ đã được sửa hết các lỗi.
Bạn không thể nghĩ hết các trường hợp khi viết tests, và điều đó không có vấn đề gì cả.Bất cứ khi nào bạn nghĩ ra thêm một trường hợp, bạn cần thêm vào file test và test nó trước khi sửa lỗi trong code. Code của bạn sẽ ngày càng trở nên tốt hơn.
Doctrine Unit Tests
Cấu hình Database
Unit testing cho một lớp Doctrine model thì phức tạp hơn một chút, nó yêu cầu kết nối tới database. Bạn đã có một kết nối dùng cho development, nhưng tốt hơn là nên tạo một database riêng dành cho việt tests.
Trong ngày 1, chúng ta đã giới thiệu về các môi trường khác nhau của ứng dụng.
Mặc định, tất cả các symfony tests đều chạy trong môi trường test
, vì thế cần cấu hình một database khác cho môi trường test
:
$ php symfony configure:database --name=doctrine --class=sfDoctrineDatabase --env=test "mysql:host=localhost;dbname=jobeet_test" root mYsEcret
Lựa chọn env
chỉ rõ cấu hình database là dành cho môi trường nào.
Khi chúng ta thực hiện lệnh này trong ngày 3, chúng ta không cung cấp lựa chọn
env
, do đó cấu hình của chúng ta dành cho mọi môi trường.
note
Nếu bạn tò mò, bạn có thể mở file config/databases.yml
để xem cách
symfony thay đổi cấu hình cho từng môi trường.
Bây giờ, chúng ta đã có cấu hình tới database, chúng ta có thể bắt đầu tạo cơ sở dữ liệu:
$ mysqladmin -uroot -pmYsEcret create jobeet_test $ php symfony doctrine:insert-sql --env=test
Dữ liệu Test
Bây giờ chúng ta đã có một cơ sở dữ liệu riêng cho việc test, chúng ta cần load một vài dữ liệu test. Trong ngày 3, bạn đã học cách dùng lệnh doctrine:data-load
, nhưng để test, chúng ta cần nạp lại dữ liệu mỗi khi chúng ta chạy. Lệnh doctrine:data-load
sử dụng lớp sfDoctrineData
để load dữ liệu:
Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures');
note
Đối tượng sfConfig
có thể dùng để lấy đường dẫn đầy đủ của một thư mục con của project. Có thể sử dụng nó để sửa lại cấu trúc thư mục mặc định.
Phương thức loadData()
nhận tham số là một thư mục hoặc một file. Nó cũng có thể nhận một mảng các thư mục hoặc files.
Chúng ta đã tạo một vài dữ liệu trong thư mục data/fixtures/
.
Để test, chúng ta sẽ đặt các fixture trong thư mục test/fixtures/
. Những fixture này sẽ được sử dụng cho Doctrine unit và functional tests.
Bây giờ, copy các file từ thư mục data/fixtures/
vào thư mục test/fixtures/
.
Testing JobeetJob
Ta tạo một vài unit tests cho lớp JobeetJob
model.
Tất cả các Doctrine unit tests đều bắt đầu bằng đoạn code giống nhau, do đó chúng ta tạo file Doctrine.php
trong bootstrap/
của thư mục test:
// 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');
Ý nghĩa của đoạn code này như sau:
Như đối với front controllers, chúng ta khởi tạo một đối tượng cấu hình cho môi trường
test
:$configuration = ProjectConfiguration::getApplicationConfiguration('frontend', 'test', true);
Chúng ta tạo một database manager. Nó khởi tạo Doctrine connection bằng cách load file cấu hình
databases.yml
.new sfDatabaseManager($configuration);
Chúng ta dùng
Doctrine::loadData()
để load dữ liệu test:Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures');
note
Doctrine chỉ kết nối với cơ sở dữ liệu chỉ khi một câu lệnh SQL được thực thi.
Bây giờ chúng ta đã có thể bắt đầu test cho lớp JobeetJob
.
Đầu tiên, chúng ta cần tạo file JobeetJobTest.php
trong thư mục test/unit/model
:
// test/unit/model/JobeetJobTest.php include(dirname(__FILE__).'/../../bootstrap/Doctrine.php'); $t = new lime_test(1, new lime_output_color());
Sau đó, thêm một test cho phương thức 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');
Viết test cho phương thức save()
thì phức tạp hơn một chút:
$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
Mỗi khi bạn thêm một test, đừng quên update số test bạn dự định trong phương thức khởi tạo lime_test
.
Test các lớp Doctrine khác
Bây giờ bạn có thể thêm test cho tất cả các lớp Doctrine khác. Bạn đã quen dần với cách viết unit tests, nó đã trở nên thật dễ dàng. Kiểm tra trong kho chứa ngày hôm nay để xem file fixture chúng tôi đã tạo, và các unit test liên quan (ở tag release_day_08
).
Unit Tests Harness
Lệnh test:unit
cũng có thể được dùng để chạy tất cả các unit test cho một project:
$ php symfony test:unit
Lệnh này sẽ hiển thị xem mỗi file test là passes hay fails:
Hẹn gặp lại ngày mai
Mặc dù test cho một ứng dụng là rất quan trọng, tôi biết rằng một vài bạn có thể tạm thời bỏ qua hướng dẫn ngày hôm nay. Thật mừng vì bạn không làm như vậy.
Sure, embracing symfony is about learning all the great features the framework provides, but it's also about its philosophy of development and the best practices it advocates. And testing is one of them. Sooner or later, unit tests will save the day for you. They give you a solid confidence about your code and the freedom to refactor it without fear. Unit tests are a safe guard that will alert you if you break something. The symfony framework itself has more than 9000 tests.
Tomorrow we will write some functional tests for the job
and category
modules.
Until then, take some time to write more unit tests for the Jobeet model
classes.
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.