version: 0.7.0 | v0.6.2 | v0.5.0 | v0.4.0 | v0.3.0 | v0.2.0
- Introduction
- Testing Environment
- Can and Can't
- Basic Conventions
- Models
- Libraries
- Controllers
- Request to Controller
- Request to URI string
- REST Request
- Ajax Request
- Request and Use Mocks
- Request and Use Monkey Patching
- Check Status Code
- Examine DOM in Controller Output
- Controller with Authentication
redirect()
show_error()
andshow_404()
- Session
- Controller with Hooks
- Controller with Name Collision
- Mock Libraries
- Monkey Patching
- More Samples
- Third Party Libraries
Here is my advice:
- You don't have to write your business logic in your controllers. Write them in your models.
- You should test models first, and test them well.
And PHPUnit has great documentation. You should read Writing Tests for PHPUnit.
Tests always run on testing
environment.
If you don't know well about config files and environments, see CodeIgniter User Guide.
CI PHPUnit Test does not want to modify CodeIgniter files. The more you modify them, the more you get difficulities when you update CodeIgniter.
In fact, it uses a modified class and a few functions. But I try to modify as little as possible.
The core functions and a class which are modified:
- function
load_class()
- function
is_loaded()
- function
is_cli()
- function
show_error()
- function
show_404()
- function
set_status_header()
- class
CI_Loader
and a helper which is modified:
- function
redirect()
in URL helper
All of them are in tests/_ci_phpunit_test/replacing
folder.
And CI PHPUnit Test adds a property dynamically:
- property
CI_Output::_status
CI PHPUnit Test replaces CI_Loader
and modifies below methods:
CI_Loader::model()
CI_Loader::_ci_load_library()
CI_Loader::_ci_load_stock_library()
But if you place MY_Loader, your MY_Loader extends the loader of CI PHPUnit Test.
If your MY_Loader overrides the above methods, probably CI PHPUnit Test does not work correctly.
When a test exercises code that contains exit()
or die()
statement, the execution of the whole test suite is aborted.
For example, if you write exit()
in your controller code, your testing ends with it.
I recommend you not using exit()
or die()
in your code.
Monkey Patching on exit()
CI PHPUnit Test has functionality that makes all exit()
and die()
in your code throw CIPHPUnitTestExitException
.
See Monkey Patching for details.
show_error()
and show_404()
And CI PHPUnit Test has special show_error() and show_404().
redirect()
CI PHPUnit Test replaces redirect()
function in URL helper. Using it, you can easily test controllers that contain redirect()
. See redirect() for details.
CodeIgniter has a function get_instance()
to get the CodeIgniter object (CodeIgniter instance or CodeIgniter super object).
CI PHPUnit Test has a new function reset_instance() which reset the current CodeIgniter object. After resetting, you can (and must) create a new your Controller instance with new state.
If you enable CodeIgniter's hooks, hook pre_system
is called once in PHPUnit bootstrap.
If you use $this->request->enableHooks()
and $this->request()
, hook pre_controller
, post_controller_constructor
and post_controller
are called on every $this->request()
to a controller.
See Controller with Hooks for details.
- The tests for a class
Class
go into a classClass_test
. Class_test
inherits from TestCase class in CI PHPUnit Test.- The tests are public methods that are named
test_*
. (Or you can use the@test
annotation in a method's docblock to mark it as a test method.)
- Don't forget to write
parent::setUpBeforeClass();
if you overridesetUpBeforeClass()
method. - Don't forget to write
parent::tearDown();
if you overridetearDown()
method.
tests/libraries/Foo_test.php
class Foo_test extends TestCase
{
public function setUp()
{
$this->resetInstance();
$this->CI->load->library('Foo');
$this->obj = $this->CI->foo;
}
public function test_doSomething()
{
$actual = $this->obj->doSomething();
$expected = 'something';
$this->assertEquals($expected, $actual);
}
}
$this->resetInstance() method in CI PHPUnit Test is a helper method to reset CodeIgniter instance and assign new CodeIgniter instance as $this->CI
.
tests/models/Inventory_model_test.php
<?php
class Inventory_model_test extends TestCase
{
public function setUp()
{
$this->resetInstance();
$this->CI->load->model('shop/Inventory_model');
$this->obj = $this->CI->Inventory_model;
}
public function test_get_category_list()
{
$expected = [
1 => 'Book',
2 => 'CD',
3 => 'DVD',
];
$list = $this->obj->get_category_list();
foreach ($list as $category) {
$this->assertEquals($expected[$category->id], $category->name);
}
}
public function test_get_category_name()
{
$actual = $this->obj->get_category_name(1);
$expected = 'Book';
$this->assertEquals($expected, $actual);
}
}
See working sample.
I put Seeder Library and a sample Seeder File.
They are not installed, so if you want to use, copy them manually.
You can use them like below:
public static function setUpBeforeClass()
{
parent::setUpBeforeClass();
$CI =& get_instance();
$CI->load->library('Seeder');
$CI->seeder->call('CategorySeeder');
}
See working sample.
You can use $this->getMockBuilder()
method in PHPUnit and $this->verifyInvoked*() helper method in CI PHPUnit Test.
If you don't know well about PHPUnit Mock Objects, see Test Doubles.
public function setUp()
{
$this->resetInstance();
$this->CI->load->model('Category_model');
$this->obj = $this->CI->Category_model;
}
public function test_get_category_list()
{
// Create mock objects for CI_DB_pdo_result and CI_DB_pdo_sqlite_driver
$return = [
0 => (object) ['id' => '1', 'name' => 'Book'],
1 => (object) ['id' => '2', 'name' => 'CD'],
2 => (object) ['id' => '3', 'name' => 'DVD'],
];
$db_result = $this->getMockBuilder('CI_DB_pdo_result')
->disableOriginalConstructor()
->getMock();
$db_result->method('result')->willReturn($return);
$db = $this->getMockBuilder('CI_DB_pdo_sqlite_driver')
->disableOriginalConstructor()
->getMock();
$db->method('get')->willReturn($db_result);
// Verify invocations
$this->verifyInvokedOnce(
$db_result,
'result',
[]
);
$this->verifyInvokedOnce(
$db,
'order_by',
['id']
);
$this->verifyInvokedOnce(
$db,
'get',
['category']
);
// Replace property db with mock object
$this->obj->db = $db;
$expected = [
1 => 'Book',
2 => 'CD',
3 => 'DVD',
];
$list = $this->obj->get_category_list();
foreach ($list as $category) {
$this->assertEquals($expected[$category->id], $category->name);
}
}
See working sample.
If your library depends on CodeIgniter functionality, I recommend using setUp()
method like this:
public function setUp()
{
$this->resetInstance();
$this->CI->load->library('Someclass');
$this->obj = $this->CI->someclass;
}
If your library is decoupled from CodeIgniter functionality, you can use setUp()
method like this:
public function setUp()
{
$this->obj = new Someclass();
}
In this case, CI PHPUnit Test autoloads your libraries in application/libraries
folder.
You can use $this->request() method in CI PHPUnit Test.
tests/controllers/Welcome_test.php
<?php
class Welcome_test extends TestCase
{
public function test_index()
{
$output = $this->request('GET', ['Welcome', 'index']);
$this->assertContains('<title>Welcome to CodeIgniter</title>', $output);
}
}
See working sample.
public function test_uri_sub_sub_index()
{
$output = $this->request('GET', 'sub/sub/index');
$this->assertContains('<title>Page Title</title>', $output);
}
See working sample.
You can specify request method in 2nd argument of $this->request() method and request body in 3rd argument of $this->request()
.
$output = $this->request(
'PUT', 'api/user', json_encode(['name' => 'mike'])
);
$output = $this->request(
'DELETE', 'api/key', 'key=12345678'
);
You can set request header with $this->request->setHeader() method in CI PHPUnit Test. And you can confirm response header with $this->assertResponseHeader() method in CI PHPUnit Test.
public function test_users_get_id_with_http_accept_header()
{
$this->request->setHeader('Accept', 'application/csv');
$output = $this->request('GET', 'api/example/users/id/1');
$this->assertEquals(
'id,name,email,fact
1,John,john@example.com,"Loves coding"
',
$output
);
$this->assertResponseCode(200);
$this->assertResponseHeader(
'Content-Type', 'application/csv; charset=utf-8'
);
}
See working sample.
You can use $this->ajaxRequest() method in CI PHPUnit Test.
public function test_index_ajax_call()
{
$output = $this->ajaxRequest('GET', ['Ajax', 'index']);
$expected = '{"name":"John Smith","age":33}';
$this->assertEquals($expected, $output);
}
See working sample.
You can use $this->request->setCallable() method in CI PHPUnit Test. $this->getDouble() is a helper method in CI PHPUnit Test.
public function test_send_okay()
{
$this->request->setCallable(
function ($CI) {
$email = $this->getDouble('CI_Email', ['send' => TRUE]);
$CI->email = $email;
}
);
$output = $this->request(
'POST',
['Contact', 'send'],
[
'name' => 'Mike Smith',
'email' => 'mike@example.jp',
'body' => 'This is test mail.',
]
);
$this->assertContains('Mail sent', $output);
}
Note: When you have never loaded a class with CodeIgniter loader, if you make mock object for the class, your application code may not work correclty. If you have got error, please try to load it before getting mock object.
See working sample.
The function you set by $this->request->setCallable()
runs after controller instantiation. So you can't inject mocks into controller constructor.
For example, if you have a controller like this:
class Auth extends CI_Controller
{
public function __construct()
{
parent::__construct();
$this->load->library('ion_auth');
if ( ! $this->ion_auth->logged_in())
{
$this->load->helper('url');
redirect('auth/login');
}
}
...
}
In this case, You can use $this->request->setCallablePreConstructor() method and load_class_instance() function in CI PHPUnit Test.
public function test_index_logged_in()
{
$this->request->setCallablePreConstructor(
function () {
// Get mock object
$auth = $this->getDouble(
'Ion_auth', ['logged_in' => TRUE]
);
// Inject mock object
load_class_instance('ion_auth', $auth);
}
);
$output = $this->request('GET', 'auth/login');
$this->assertContains('You are logged in.', $output);
}
Note: When you have never loaded a class with CodeIgniter loader, if you make mock object for the class, it may not work. If you have got error, please try to load it before getting mock object.
See working sample.
Note: If you can't create mocks or it is too hard to create mocks, it may be better to use Monkey Patching.
To use Monkey Patching, you have to enable it. See Monkey Patching.
public function test_index_logged_in()
{
MonkeyPatch::patchMethod('Ion_auth', ['logged_in' => TRUE]);
$output = $this->request('GET', 'auth/login');
$this->assertContains('You are logged in.', $output);
}
See also Patching Methods in User-defined Classes.
You can use $this->assertResponseCode() method in CI PHPUnit Test.
$this->request('GET', 'welcome');
$this->assertResponseCode(200);
I recommend using symfony/dom-crawler.
$output = $this->request('GET', ['Welcome', 'index']);
$crawler = new Symfony\Component\DomCrawler\Crawler($output);
// Get the text of the first <h1>
$text = $crawler->filter('h1')->eq(0)->text();
See working sample.
I recommend using PHPUnit mock objects. $this->getDouble() is a helper method in CI PHPUnit Test.
public function test_index_logged_in()
{
$this->request->setCallable(
function ($CI) {
// Get mock object
$auth = $this->getDouble(
'Ion_auth', ['logged_in' => TRUE, 'is_admin' => TRUE]
);
// Inject mock object
$CI->ion_auth = $auth;
}
);
$output = $this->request('GET', ['Auth', 'index']);
$this->assertContains('<p>Below is a list of the users.</p>', $output);
}
See working sample.
By default, CI PHPUnit Test replaces redirect()
function in URL helper. Using it, you can easily test controllers that contain redirect()
.
But you could still override redirect()
using your MY_url_helper.php
. If you place MY_url_helper.php
, your redirect()
will be used.
If you use redirect()
in CI PHPUnit Test, you can write tests like this:
public function test_index()
{
$this->request('GET', ['Admin', 'index']);
$this->assertRedirect('login', 302);
}
$this->assertRedirect() is a method in CI PHPUnit Test.
See working sample.
Upgrade Note for v0.4.0
v0.4.0 has new MY_url_helper.php
. If you use it, you must update your tests.
before:
/**
* @expectedException PHPUnit_Framework_Exception
* @expectedExceptionCode 302
* @expectedExceptionMessageRegExp !\ARedirect to http://localhost/\z!
*/
public function test_index()
{
$this->request('GET', ['Redirect', 'index']);
}
↓
after:
public function test_index()
{
$this->request('GET', ['Redirect', 'index']);
$this->assertRedirect('/', 302);
}
You can use $this->assertResponseCode() method in CI PHPUnit Test.
public function test_index()
{
$this->request('GET', ['nocontroller', 'noaction']);
$this->assertResponseCode(404);
}
See working sample.
If you don't call $this->request()
in your tests, show_error()
throws CIPHPUnitTestShowErrorException
and show_404()
throws CIPHPUnitTestShow404Exception
. So you must expect the exceptions. You can use @expectedException
annotation in PHPUnit.
Upgrade Note for v0.4.0
v0.4.0 has changed how to test show_error()
and show_404()
. You must update your tests.
before:
/**
* @expectedException PHPUnit_Framework_Exception
* @expectedExceptionCode 404
*/
public function test_index()
{
$this->request('GET', 'show404');
}
↓
after:
public function test_index()
{
$this->request('GET', 'show404');
$this->assertResponseCode(404);
}
If you don't want to update your tests, set property $bc_mode_throw_PHPUnit_Framework_Exception
true
in CIPHPUnitTestRequest class. But $bc_mode_throw_PHPUnit_Framework_Exception
is deprecated.
If you run CodeIgniter via CLI, CodeIgniter's Session class does not call session_start()
. So normally you don't see warning like "session_start(): Cannot send session cookie - headers already sent by ...".
But if libraries which you use have logic runs only when not in CLI mode, you have to use set_is_cli(FALSE)
for testing. (Don't forget calling set_is_cli(TRUE)
after running the code.)
In that case, Session class calls session_start()
and you will see "Cannot send session cookie" warning.
To test that code, you can add $this->warningOff()
to your test code (don't forget calling $this->warningOn()
after running the code), or you can use MY_Session class like this: application/libraries/Session/MY_Session.php.
If you want to enable hooks, call $this->request->enableHooks() method. It enables pre_controller
, post_controller_constructor
, post_controller
hooks.
$this->request->enableHooks();
$output = $this->request('GET', 'products/shoes/show/123');
See working sample.
If you have two controllers with the exact same name, PHP Fatal error stops PHPUnit testing.
In this case, you can use PHPUnit annotations @runInSeparateProcess
and @preserveGlobalState disabled
. But tests in a separate PHP process are very slow.
tests/controllers/sub/Welcome_test.php
<?php
class sub_Welcome_test extends TestCase
{
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_uri_sub_welcome_index()
{
$output = $this->request('GET', 'sub/welcome/index');
$this->assertContains('<title>Page Title</title>', $output);
}
}
See working sample.
You can put mock libraries in tests/mocks/libraries
folder. You can see application/tests/mocks/libraries/email.php as a sample.
With mock libraries, you could replace your object in CodeIgniter instance.
This is how to replace Email library with Mock_Libraries_Email
class.
public function setUp()
{
$this->resetInstance();
$this->CI->load->model('Mail_model');
$this->obj = $this->CI->Mail_model;
$this->CI->email = new Mock_Libraries_Email();
}
Mock library class name must be Mock_Libraries_*
, and it is autoloaded.
CI PHPUnit Test has three monkey patchers.
ExitPatcher
: Convertingexit()
to ExceptionFunctionPatcher
: Patching FunctionsMethodPatcher
: Patching Methods in User-defined Classes
Note: This functionality has a negative impact on speed of tests.
To enable monkey patching, uncomment below code in tests/Bootstrap.php
and configure them:
/*
require __DIR__ . '/_ci_phpunit_test/patcher/bootstrap.php';
MonkeyPatchManager::init([
'cache_dir' => APPPATH . 'tests/_ci_phpunit_test/tmp/cache',
// Directories to patch on source files
'include_paths' => [
APPPATH,
BASEPATH,
],
// Excluding directories to patch
'exclude_paths' => [
APPPATH . 'tests/',
],
// All patchers you use.
'patcher_list' => [
'ExitPatcher',
'FunctionPatcher',
'MethodPatcher',
],
// Additional functions to patch
'functions_to_patch' => [
//'random_string',
],
'exit_exception_classname' => 'CIPHPUnitTestExitException',
]);
*/
Upgrade Note for v0.6.0
Add the above code (require
and MonkeyPatchManager::init()
) before
/*
* -------------------------------------------------------------------
* Added for CI PHPUnit Test
* -------------------------------------------------------------------
*/
TestCase::$enable_patcher
was removed. Please remove it.
This patcher converts exit()
or die()
statements to exceptions on the fly.
If you have a controller like below:
public function index()
{
$this->output
->set_status_header(200)
->set_content_type('application/json', 'utf-8')
->set_output(json_encode(['foo' => 'bar']))
->_display();
exit();
}
A test case could be like this:
public function test_index()
{
try {
$this->request('GET', 'welcome/index');
} catch (CIPHPUnitTestExitException $e) {
$output = ob_get_clean();
}
$this->assertContains('{"foo":"bar"}', $output);
}
See working sample.
This patcher allows replacement of global functions that can't be mocked by PHPUnit.
But it has a few limitations. Some functions can't be replaced and it might cause errors.
So by default we can replace only a dozen pre-defined functions in FunctionPatcher.
public function test_index()
{
MonkeyPatch::patchFunction('mt_rand', 100, 'Welcome::index');
$output = $this->request('GET', 'welcome/index');
$this->assertContains('100', $output);
}
MonkeyPatch::patchFunction() replaces PHP native function mt_rand()
in Welcome::index
method, and it will return 100
in the test method.
See working sample.
Note: If you call MonkeyPatch::patchFunction()
without 3rd argument, all the functions (located in include_paths
and not in exclude_paths
) called in the test method will be replaced. So, for example, a function in CodeIgniter code might be replaced and it results in unexpected outcome.
Change Return Value
You could change return value of patched function using PHP closure:
MonkeyPatch::patchFunction(
'function_exists',
function ($function) {
if ($function === 'random_bytes')
{
return true;
}
elseif ($function === 'openssl_random_pseudo_bytes')
{
return false;
}
elseif ($function === 'mcrypt_create_iv')
{
return false;
}
else
{
return __GO_TO_ORIG__;
}
},
'Welcome'
);
See working sample.
Patch on Other Functions
If you want to patch other functions, you can add them to functions_to_patch in MonkeyPatchManager::init()
.
But there are a few known limitations:
- Patched functions which have parameters called by reference don't work.
- You may see visibility errors if you pass non-public callbacks to patched functions. For example, you pass
[$this, 'method']
toarray_map()
and themethod()
method in the class is not public.
This patcher allows replacement of methods in user-defined classes.
public function test_index()
{
MonkeyPatch::patchMethod(
'Category_model',
['get_category_list' => [(object) ['name' => 'Nothing']]]
);
$output = $this->request('GET', 'welcome/index');
$this->assertContains('Nothing', $output);
}
MonkeyPatch::patchMethod() replaces get_category_list()
method in Category_model
, and it will return [(object) ['name' => 'Nothing']]
in the test method.
See working sample.
Want to see more tests?
- https://github.com/kenjis/ci-app-for-ci-phpunit-test/tree/v0.7.0/application/tests
- https://github.com/kenjis/codeigniter-tettei-apps/tree/develop/application/tests
CI PHPUnit Test has powerful functionality for testing. So normally you don't have to modify your application or library code.
But there are still libraries which can't be tested without code modification.
codeigniter-restserver calls exit()
. So you have to enable Monkey Patching and at least you have to use ExitPatcher
.
Additionally you have to apply patch on application/libraries/REST_Controller.php
.
This is patch for codeigniter-restserver 2.7.2:
--- a/application/libraries/REST_Controller.php
+++ b/application/libraries/REST_Controller.php
@@ -653,6 +653,11 @@ abstract class REST_Controller extends CI_Controller {
{
call_user_func_array([$this, $controller_method], $arguments);
}
+ catch (CIPHPUnitTestExitException $ex)
+ {
+ // This block is for ci-phpunit-test
+ throw $ex;
+ }
catch (Exception $ex)
{
// If the method doesn't exist, then the error will be caught and an error response shown
Then, you can write test case class like this:
tests/controllers/api/Example_test.php
<?php
class Example_test extends TestCase
{
public function test_users_get()
{
try {
$output = $this->request('GET', 'api/example/users');
} catch (CIPHPUnitTestExitException $e) {
$output = ob_get_clean();
}
$this->assertEquals(
'[{"id":1,"name":"John","email":"john@example.com","fact":"Loves coding"},{"id":2,"name":"Jim","email":"jim@example.com","fact":"Developed on CodeIgniter"},{"id":3,"name":"Jane","email":"jane@example.com","fact":"Lives in the USA","0":{"hobbies":["guitar","cycling"]}}]',
$output
);
$this->assertResponseCode(200);
}
}
And if you copy sample api controllers, you must change require
statement to require_once
:
--- a/application/controllers/api/Example.php
+++ b/application/controllers/api/Example.php
@@ -3,7 +3,7 @@
defined('BASEPATH') OR exit('No direct script access allowed');
// This can be removed if you use __autoload() in config.php OR use Modular Extensions
-require APPPATH . '/libraries/REST_Controller.php';
+require_once APPPATH . '/libraries/REST_Controller.php';
/**
* This is an example of a few basic user interaction methods you could use
If you require REST_Controller.php
more than once, you get Fatal error: Cannot redeclare class REST_Controller
.
See working sample.