Caution: You are browsing the legacy symfony 1.x part of this website.

Ngày 8: Unit Test

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 testsfunctional 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

Tests on the command line

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.

slugify() tests

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 -');

slugify() tests with messages

sidebar

Code Coverage

When you write tests, it is easy to forget a portion of the code.

To help you check that all your code is well tested, symfony provides the test:coverage task. Pass this task a test file or directory and a lib file or directory as arguments and it will tell you the code coverage of your code:

$ php symfony test:coverage test/unit/JobeetTest.php lib/Jobeet.class.php

If you want to know which lines are not covered by your tests, pass the --detailed option:

$ php symfony test:coverage --detailed test/unit/JobeetTest.php lib/Jobeet.class.php

Keep in mind that when the task indicates that your code is fully unit tested, it just means that each line has been executed, not that all the edge cases have been tested.

As the test:coverage relies on XDebug to collect its information, you need to install it and enable it first.

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');

slugify() bug

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.

sidebar

Cải tiến phương thức slugify

Hãy thử thêm một test với tiếng Việt có dấu:

$t->is(Jobeet::slugify('Lập trình  Web'), 'lap-trinh-web', '::slugify() removes accents');

Test này sẽ fail. Thay vì thay thế bởi a, ì bởi i, phương thức slugify() lại thay chúng bởi kí tự (-). Đó là một vấn đề liên quan đến ngôn ngữ. Nếu bạn có cài "iconv" , nó sẽ thực hiện việc chuyển đổi này giúp chúng ta:

// code derived from http://php.vrana.cz/vytvoreni-pratelskeho-url.php
static public function slugify($text)
{
  // replace non letter or digits by -
  $text = preg_replace('~[^\\pL\d]+~u', '-', $text);
 
  // trim
  $text = trim($text, '-');
 
  // transliterate
  $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
 
  // lowercase
  $text = strtolower($text);
 
  // remove unwanted characters
  $text = preg_replace('~[^-\w]+~', '', $text);
 
  if (empty($text))
  {
    return 'n-a';
  }
 
  return $text;
}

Hãy luôn nhớ lưu tất cả các file PHP với encode UTF-8

Sửa lại file test để chạy test chỉ khi có "iconv":

if (function_exists('iconv'))
{
  $t->is(Jobeet::slugify('Développeur Web'), 'developpeur-web', '::slugify() removes accents');
}
else
{
  $t->skip('::slugify() removes accents - iconv not installed');
}

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

sidebar

Nguyên tắc cấu hình trong symfony

Trong ngày 4, chúng ta đã biết rằng cấu hình trong các file cấu hình khác nhau được xác định ở những mức độ khác nhau.

Những thiết lập đó cũng phụ thuộc môi trường. Điều đó là đúng với phần lớn những file cấu hình chúng ta đã dùng cho đến nay: databases.yml, app.yml, view.yml, và settings.yml. Trong tất cả những file này, key là tên môi trường, key all chỉ ra rằng cấu hình này là cho mọi môi trường:

# config/databases.yml
dev:
  doctrine:
    class: sfDoctrineDatabase
 
test:
  doctrine:
    class: sfDoctrineDatabase
    param:
      dsn: 'mysql:host=localhost;dbname=jobeet_test'
 
all:
  doctrine:
    class: sfDoctrineDatabase
    param:
      dsn: 'mysql:host=localhost;dbname=jobeet'
      username: root
      password: null

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'        => '[email protected]',
    '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:

Unit tests harness

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.