diff --git a/system/Cache/CacheFactory.php b/system/Cache/CacheFactory.php index 1cf1aff13772..6fa59e002d63 100644 --- a/system/Cache/CacheFactory.php +++ b/system/Cache/CacheFactory.php @@ -13,6 +13,7 @@ use CodeIgniter\Cache\Exceptions\CacheException; use CodeIgniter\Exceptions\CriticalError; +use CodeIgniter\Test\Mock\MockCache; use Config\Cache; /** @@ -22,59 +23,71 @@ */ class CacheFactory { - /** - * Attempts to create the desired cache handler, based upon the - * - * @param Cache $config - * @param string|null $handler - * @param string|null $backup - * - * @return CacheInterface - */ - public static function getHandler(Cache $config, string $handler = null, string $backup = null) - { - if (! isset($config->validHandlers) || ! is_array($config->validHandlers)) { - throw CacheException::forInvalidHandlers(); - } + /** + * The class to use when mocking + * + * @var string + */ + public static $mockClass = MockCache::class; - if (! isset($config->handler) || ! isset($config->backupHandler)) { - throw CacheException::forNoBackup(); - } + /** + * The service to inject the mock as + * + * @var string + */ + public static $mockServiceName = 'cache'; - $handler = ! empty($handler) ? $handler : $config->handler; - $backup = ! empty($backup) ? $backup : $config->backupHandler; + /** + * Attempts to create the desired cache handler, based upon the + * + * @param Cache $config + * @param string|null $handler + * @param string|null $backup + * + * @return CacheInterface + */ + public static function getHandler(Cache $config, string $handler = null, string $backup = null) + { + if (! isset($config->validHandlers) || ! is_array($config->validHandlers)) { + throw CacheException::forInvalidHandlers(); + } - if (! array_key_exists($handler, $config->validHandlers) || ! array_key_exists($backup, $config->validHandlers)) { - throw CacheException::forHandlerNotFound(); - } + if (! isset($config->handler) || ! isset($config->backupHandler)) { + throw CacheException::forNoBackup(); + } - // Get an instance of our handler. - $adapter = new $config->validHandlers[$handler]($config); + $handler = ! empty($handler) ? $handler : $config->handler; + $backup = ! empty($backup) ? $backup : $config->backupHandler; - if (! $adapter->isSupported()) { - $adapter = new $config->validHandlers[$backup]($config); + if (! array_key_exists($handler, $config->validHandlers) || ! array_key_exists($backup, $config->validHandlers)) { + throw CacheException::forHandlerNotFound(); + } - if (! $adapter->isSupported()) { - // Log stuff here, don't throw exception. No need to raise a fuss. - // Fall back to the dummy adapter. - $adapter = new $config->validHandlers['dummy'](); - } - } + // Get an instance of our handler. + $adapter = new $config->validHandlers[$handler]($config); - // If $adapter->initialization throws a CriticalError exception, we will attempt to - // use the $backup handler, if that also fails, we resort to the dummy handler. - try { - $adapter->initialize(); - } catch (CriticalError $e) { - // log the fact that an exception occurred as well what handler we are resorting to - log_message('critical', $e->getMessage() . ' Resorting to using ' . $backup . ' handler.'); + if (! $adapter->isSupported()) { + $adapter = new $config->validHandlers[$backup]($config); - // get the next best cache handler (or dummy if the $backup also fails) - $adapter = self::getHandler($config, $backup, 'dummy'); - } + if (! $adapter->isSupported()) { + // Log stuff here, don't throw exception. No need to raise a fuss. + // Fall back to the dummy adapter. + $adapter = new $config->validHandlers['dummy'](); + } + } - return $adapter; - } + // If $adapter->initialization throws a CriticalError exception, we will attempt to + // use the $backup handler, if that also fails, we resort to the dummy handler. + try { + $adapter->initialize(); + } catch (CriticalError $e) { + // log the fact that an exception occurred as well what handler we are resorting to + log_message('critical', $e->getMessage() . ' Resorting to using ' . $backup . ' handler.'); - //-------------------------------------------------------------------- + // get the next best cache handler (or dummy if the $backup also fails) + $adapter = self::getHandler($config, $backup, 'dummy'); + } + + return $adapter; + } } diff --git a/system/Exceptions/TestException.php b/system/Exceptions/TestException.php new file mode 100644 index 000000000000..fc270f2aa24d --- /dev/null +++ b/system/Exceptions/TestException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +/** + * Exception for automatic logging. + */ +class TestException extends CriticalError +{ + use DebugTraceableTrait; + + public static function forInvalidMockClass(string $name) + { + return new static(lang('Test.invalidMockClass', [$name])); + } +} diff --git a/system/Helpers/test_helper.php b/system/Helpers/test_helper.php index 6ff4f485ae47..ea8b61a4f140 100644 --- a/system/Helpers/test_helper.php +++ b/system/Helpers/test_helper.php @@ -9,8 +9,10 @@ * file that was distributed with this source code. */ +use CodeIgniter\Exceptions\TestException; use CodeIgniter\Model; use CodeIgniter\Test\Fabricator; +use Config\Services; /** * CodeIgniter Test Helpers @@ -43,3 +45,31 @@ function fake($model, array $overrides = null, $persist = true) return $fabricator->make(); } } + +if (! function_exists('mock')) +{ + /** + * Used within our test suite to mock certain system tools. + * + * @param string $className Fully qualified class name + */ + function mock(string $className) + { + $mockClass = $className::$mockClass; + $mockService = $className::$mockServiceName ?? ''; + + if (empty($mockClass) || ! class_exists($mockClass)) + { + throw TestException::forInvalidMockClass($mockClass); + } + + $mock = new $mockClass(); + + if (! empty($mockService)) + { + Services::injectMock($mockService, $mock); + } + + return $mock; + } +} diff --git a/system/Language/en/Test.php b/system/Language/en/Test.php new file mode 100644 index 000000000000..8df1eb8c2f8c --- /dev/null +++ b/system/Language/en/Test.php @@ -0,0 +1,15 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +// Testing language settings +return [ + 'invalidMockClass' => '{0} is not a valid Mock class', +]; diff --git a/system/Test/Mock/MockCache.php b/system/Test/Mock/MockCache.php index d5a591a833ed..42e84103e823 100644 --- a/system/Test/Mock/MockCache.php +++ b/system/Test/Mock/MockCache.php @@ -14,260 +14,325 @@ use Closure; use CodeIgniter\Cache\CacheInterface; use CodeIgniter\Cache\Handlers\BaseHandler; +use PHPUnit\Framework\Assert; class MockCache extends BaseHandler implements CacheInterface { - /** - * Mock cache storage. - * - * @var array - */ - protected $cache = []; - - /** - * Expiration times. - * - * @var ?int[] - */ - protected $expirations = []; - - //-------------------------------------------------------------------- - - /** - * Takes care of any handler-specific setup that must be done. - */ - public function initialize() - { - } - - //-------------------------------------------------------------------- - - /** - * Attempts to fetch an item from the cache store. - * - * @param string $key Cache item name - * - * @return mixed - */ - public function get(string $key) - { - $key = static::validateKey($key, $this->prefix); - - return $this->cache[$key] ?? null; - } - - //-------------------------------------------------------------------- - - /** - * Get an item from the cache, or execute the given Closure and store the result. - * - * @param string $key Cache item name - * @param int $ttl Time to live - * @param Closure $callback Callback return value - * - * @return mixed - */ - public function remember(string $key, int $ttl, Closure $callback) - { - $value = $this->get($key); - - if (! is_null($value)) { - return $value; - } - - $this->save($key, $value = $callback(), $ttl); - - return $value; - } - - //-------------------------------------------------------------------- - - /** - * Saves an item to the cache store. - * - * The $raw parameter is only utilized by Mamcache in order to - * allow usage of increment() and decrement(). - * - * @param string $key Cache item name - * @param mixed $value the data to save - * @param int $ttl Time To Live, in seconds (default 60) - * @param bool $raw Whether to store the raw value. - * - * @return bool - */ - public function save(string $key, $value, int $ttl = 60, bool $raw = false) - { - $key = static::validateKey($key, $this->prefix); - - $this->cache[$key] = $value; - $this->expirations[$key] = $ttl > 0 ? time() + $ttl : null; - - return true; - } - - //-------------------------------------------------------------------- - - /** - * Deletes a specific item from the cache store. - * - * @param string $key Cache item name - * - * @return bool - */ - public function delete(string $key) - { - $key = static::validateKey($key, $this->prefix); - - if (! isset($this->cache[$key])) { - return false; - } - - unset($this->cache[$key], $this->expirations[$key]); - - return true; - } - - //-------------------------------------------------------------------- - - /** - * Deletes items from the cache store matching a given pattern. - * - * @param string $pattern Cache items glob-style pattern - * - * @return int - */ - public function deleteMatching(string $pattern) - { - $count = 0; - - foreach (array_keys($this->cache) as $key) { - if (fnmatch($pattern, $key)) { - $count++; - unset($this->cache[$key], $this->expirations[$key]); - } - } - - return $count; - } - - //-------------------------------------------------------------------- - - /** - * Performs atomic incrementation of a raw stored value. - * - * @param string $key Cache ID - * @param int $offset Step/value to increase by - * - * @return bool - */ - public function increment(string $key, int $offset = 1) - { - $key = static::validateKey($key, $this->prefix); - $data = $this->cache[$key] ?: null; - - if (empty($data)) { - $data = 0; - } elseif (! is_int($data)) { - return false; - } - - return $this->save($key, $data + $offset); - } - - //-------------------------------------------------------------------- - - /** - * Performs atomic decrementation of a raw stored value. - * - * @param string $key Cache ID - * @param int $offset Step/value to increase by - * - * @return bool - */ - public function decrement(string $key, int $offset = 1) - { - $key = static::validateKey($key, $this->prefix); - - $data = $this->cache[$key] ?: null; - - if (empty($data)) { - $data = 0; - } elseif (! is_int($data)) { - return false; - } - - return $this->save($key, $data - $offset); - } - - //-------------------------------------------------------------------- - - /** - * Will delete all items in the entire cache. - * - * @return bool - */ - public function clean() - { - $this->cache = []; - $this->expirations = []; - - return true; - } - - //-------------------------------------------------------------------- - - /** - * Returns information on the entire cache. - * - * The information returned and the structure of the data - * varies depending on the handler. - * - * @return string[] Keys currently present in the store - */ - public function getCacheInfo() - { - return array_keys($this->cache); - } - - //-------------------------------------------------------------------- - - /** - * Returns detailed information about the specific item in the cache. - * - * @param string $key Cache item name. - * - * @return array|null - * Returns null if the item does not exist, otherwise array - * with at least the 'expire' key for absolute epoch expiry (or null). - */ - public function getMetaData(string $key) - { - // Misses return null - if (! array_key_exists($key, $this->expirations)) { - return null; - } - - // Count expired items as a miss - if (is_int($this->expirations[$key]) && $this->expirations[$key] > time()) { - return null; - } - - return [ - 'expire' => $this->expirations[$key], - ]; - } - - //-------------------------------------------------------------------- - - /** - * Determines if the driver is supported on this system. - * - * @return bool - */ - public function isSupported(): bool - { - return true; - } - - //-------------------------------------------------------------------- + /** + * Mock cache storage. + * + * @var array + */ + protected $cache = []; + + /** + * Expiration times. + * + * @var ?int[] + */ + protected $expirations = []; + + /** + * If true, will not cache any data. + * + * @var boolean + */ + protected $bypass = false; + + /** + * Takes care of any handler-specific setup that must be done. + */ + public function initialize() + { + } + + /** + * Attempts to fetch an item from the cache store. + * + * @param string $key Cache item name + * + * @return mixed + */ + public function get(string $key) + { + $key = static::validateKey($key, $this->prefix); + + return $this->cache[$key] ?? null; + } + + /** + * Get an item from the cache, or execute the given Closure and store the result. + * + * @param string $key Cache item name + * @param integer $ttl Time to live + * @param Closure $callback Callback return value + * + * @return mixed + */ + public function remember(string $key, int $ttl, Closure $callback) + { + $value = $this->get($key); + + if (! is_null($value)) + { + return $value; + } + + $this->save($key, $value = $callback(), $ttl); + + return $value; + } + + /** + * Saves an item to the cache store. + * + * The $raw parameter is only utilized by Mamcache in order to + * allow usage of increment() and decrement(). + * + * @param string $key Cache item name + * @param mixed $value the data to save + * @param integer $ttl Time To Live, in seconds (default 60) + * @param boolean $raw Whether to store the raw value. + * + * @return boolean + */ + public function save(string $key, $value, int $ttl = 60, bool $raw = false) + { + if ($this->bypass) + { + return true; + } + + $key = static::validateKey($key, $this->prefix); + + $this->cache[$key] = $value; + $this->expirations[$key] = $ttl > 0 ? time() + $ttl : null; + + return true; + } + + /** + * Deletes a specific item from the cache store. + * + * @param string $key Cache item name + * + * @return boolean + */ + public function delete(string $key) + { + $key = static::validateKey($key, $this->prefix); + + if (! isset($this->cache[$key])) + { + return false; + } + + unset($this->cache[$key], $this->expirations[$key]); + + return true; + } + + /** + * Deletes items from the cache store matching a given pattern. + * + * @param string $pattern Cache items glob-style pattern + * + * @return integer + */ + public function deleteMatching(string $pattern) + { + $count = 0; + + foreach (array_keys($this->cache) as $key) + { + if (fnmatch($pattern, $key)) + { + $count++; + unset($this->cache[$key], $this->expirations[$key]); + } + } + + return $count; + } + + /** + * Performs atomic incrementation of a raw stored value. + * + * @param string $key Cache ID + * @param integer $offset Step/value to increase by + * + * @return boolean + */ + public function increment(string $key, int $offset = 1) + { + $key = static::validateKey($key, $this->prefix); + $data = $this->cache[$key] ?: null; + + if (empty($data)) + { + $data = 0; + } + elseif (! is_int($data)) + { + return false; + } + + return $this->save($key, $data + $offset); + } + + /** + * Performs atomic decrementation of a raw stored value. + * + * @param string $key Cache ID + * @param integer $offset Step/value to increase by + * + * @return boolean + */ + public function decrement(string $key, int $offset = 1) + { + $key = static::validateKey($key, $this->prefix); + + $data = $this->cache[$key] ?: null; + + if (empty($data)) + { + $data = 0; + } + elseif (! is_int($data)) + { + return false; + } + + return $this->save($key, $data - $offset); + } + + /** + * Will delete all items in the entire cache. + * + * @return boolean + */ + public function clean() + { + $this->cache = []; + $this->expirations = []; + + return true; + } + + /** + * Returns information on the entire cache. + * + * The information returned and the structure of the data + * varies depending on the handler. + * + * @return string[] Keys currently present in the store + */ + public function getCacheInfo() + { + return array_keys($this->cache); + } + + /** + * Returns detailed information about the specific item in the cache. + * + * @param string $key Cache item name. + * + * @return array|null + * Returns null if the item does not exist, otherwise array + * with at least the 'expire' key for absolute epoch expiry (or null). + */ + public function getMetaData(string $key) + { + // Misses return null + if (! array_key_exists($key, $this->expirations)) + { + return null; + } + + // Count expired items as a miss + if (is_int($this->expirations[$key]) && $this->expirations[$key] > time()) + { + return null; + } + + return [ + 'expire' => $this->expirations[$key], + ]; + } + + /** + * Determines if the driver is supported on this system. + * + * @return boolean + */ + public function isSupported(): bool + { + return true; + } + + //-------------------------------------------------------------------- + // Test Helpers + //-------------------------------------------------------------------- + + /** + * Instructs the class to ignore all + * requests to cache an item, and always "miss" + * when checked for existing data. + * + * @return $this + */ + public function bypass(bool $bypass = true) + { + $this->clean(); + + $this->bypass = $bypass; + + return $this; + } + + //-------------------------------------------------------------------- + // Additional Assertions + //-------------------------------------------------------------------- + + /** + * Asserts that the cache has an item named $key. + * The value is not checked since storing false or null + * values is valid. + * + * @param string $key + */ + public function assertHas(string $key) + { + Assert::assertNotNull($this->get($key), "The cache does not have an item named: `{$key}`"); + } + + /** + * Asserts that the cache has an item named $key with a value matching $value. + * + * @param string $key + * @param null $value + */ + public function assertHasValue(string $key, $value = null) + { + $item = $this->get($key); + + // Let assertHas handle throwing the error for consistency + // if the key is not found + if (empty($item)) + { + $this->assertHas($key); + } + + Assert::assertEquals($value, $this->get($key), "The cached item `{$key}` does not equal match expectation. Found: " . print_r($value, true)); + } + + /** + * Asserts that the cache does NOT have an item named $key. + * + * @param string $key + */ + public function assertMissing(string $key) + { + Assert::assertArrayNotHasKey($key, $this->cache, "The cached item named `{$key}` exists."); + } } diff --git a/tests/system/Cache/CacheMockTest.php b/tests/system/Cache/CacheMockTest.php new file mode 100644 index 000000000000..7112f5bf972b --- /dev/null +++ b/tests/system/Cache/CacheMockTest.php @@ -0,0 +1,39 @@ +assertInstanceOf(BaseHandler::class, service('cache')); + + $mock = mock(CacheFactory::class); + + // Should return MockCache class + $this->assertInstanceOf(MockCache::class, $mock); + + // Should inject MockCache + $this->assertInstanceOf(MockCache::class, service('cache')); + } + + public function testMockCaching() + { + $mock = mock(CacheFactory::class); + + // Ensure it stores the value normally + $mock->save('foo', 'bar'); + $mock->assertHas('foo'); + $mock->assertHasValue('foo', 'bar'); + + // Try it again with bypass on + $mock->bypass(); + $mock->save('foo', 'bar'); + $mock->assertMissing('foo'); + } +} diff --git a/user_guide_src/source/testing/index.rst b/user_guide_src/source/testing/index.rst index 878cc9918355..949a68b99b51 100644 --- a/user_guide_src/source/testing/index.rst +++ b/user_guide_src/source/testing/index.rst @@ -2,7 +2,7 @@ Testing ####### -CodeIgniter ships with a number of tools to help you test and debug your application thoroughly. +CodeIgniter ships with a number of tools to help you test and debug your application thoroughly. The following sections should get you quickly testing your applications. .. toctree:: @@ -16,3 +16,4 @@ The following sections should get you quickly testing your applications. response benchmark debugging + Mocking diff --git a/user_guide_src/source/testing/mocking.rst b/user_guide_src/source/testing/mocking.rst new file mode 100644 index 000000000000..03b464cf6570 --- /dev/null +++ b/user_guide_src/source/testing/mocking.rst @@ -0,0 +1,53 @@ +###################### +Mocking System Classes +###################### + +Several components within the framework provide mocked versions of their classes that can be used during testing. These classes +can take the place of the normal class during test execution, often providing additional assertions to test that actions +have taken place (or not taken place) during the execution of the test. This might be checking data gets cached correctly, +emails were sent correctly, etc. + +.. contents:: + :local: + :depth: 1 + +Cache +===== + +You can mock the cache with the ``mock()`` method, using the ``CacheFactory`` as its only parameter. +:: + + $mock = mock(CodeIgniter\Cache\CacheFactory::class); + +While this returns an instance of ``CodeIgniter\Test\Mock\MockCache`` that you can use directly, it also inserts the +mock into the Service class, so any calls within your code to ``service('cache')`` or ``Config\Services::cache()`` will +use the mocked class within its place. + +When using this in more than one test method within a single file you should call either the ``clean()`` or ``bypass()`` +methods during the test ``setUp()`` to ensure a clean slate when your tests run. + +Additional Methods +------------------ + +You can instruct the mocked cache handler to never do any caching with the ``bypass()`` method. This will emulate +using the dummy handler and ensures that your test does not rely on cached data for your tests. +:: + + $mock = mock(CodeIgniter\Cache\CacheFactory::class); + // Never cache any items during this test. + $mock->bypass(); + +Available Assertions +-------------------- + +The following new assertions are available on the mocked class for using during testing: +:: + + $mock = mock(CodeIgniter\Cache\CacheFactory::class); + + // Assert that a cached item named $key exists + $mock->assertHas($key); + // Assert that a cached item named $key exists with a value of $value + $mock->assertHasValue($key, $value); + // Assert that a cached item named $key does NOT exist + $mock->assertMissing($key);