diff --git a/README.md b/README.md index e7c2cf4..d3aff47 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,17 @@ Redis function | Description It also mocks **PIPELINE** and **EXECUTE** functions but without any transaction behaviors, they just make the interface fluent. +## Usage + +RedisMock library provides a factory able to build a mocked class of your Redis library that can be directly injected in your application : + +```php +$factory = new \M6Web\Component\RedisMockFactory(); +$myRedisMock = $factory->getAdapter('My\Redis\Library', new \M6Web\Component\RedisMock\RedisMock()); +``` + +**WARNING !** *RedisMock doesn't implement all Redis features and commands. The mock can have undesired behavior if your parent class uses unsupported features.* + ## Tests ```shell diff --git a/src/M6Web/Component/RedisMock/RedisMock.php b/src/M6Web/Component/RedisMock/RedisMock.php index 1093bde..a804e31 100644 --- a/src/M6Web/Component/RedisMock/RedisMock.php +++ b/src/M6Web/Component/RedisMock/RedisMock.php @@ -29,8 +29,7 @@ public function getData() public function get($key) { - if (!isset(self::$data[$key]) || is_array(self::$data[$key])) - { + if (!isset(self::$data[$key]) || is_array(self::$data[$key])) { return self::$pipeline ? $this : null; } @@ -46,16 +45,11 @@ public function set($key, $value) public function incr($key) { - if (!isset(self::$data[$key])) - { + if (!isset(self::$data[$key])) { self::$data[$key] = 1; - } - elseif (!is_integer(self::$data[$key])) - { + } elseif (!is_integer(self::$data[$key])) { return self::$pipeline ? $this : null; - } - else - { + } else { self::$data[$key]++; } @@ -71,8 +65,11 @@ public function exists($key) public function del($key) { - if (!isset(self::$data[$key])) - { + if (func_num_args() > 1) { + throw new UnsupportedException('In RedisMock, `del` command can not remove more than one key at once.'); + } + + if (!isset(self::$data[$key])) { return self::$pipeline ? $this : 0; } @@ -101,17 +98,26 @@ public function keys($pattern) public function sadd($key, $member) { - $isNew = !isset(self::$data[$key]); + if (func_num_args() > 2) { + throw new UnsupportedException('In RedisMock, `sadd` command can not set more than one member at once.'); + } + + if (isset(self::$data[$key]) && !is_array(self::$data[$key])) { + return self::$pipeline ? $this : null; + } + + $isNew = !isset(self::$data[$key]) || !in_array($member, self::$data[$key]); - self::$data[$key][] = $member; + if ($isNew) { + self::$data[$key][] = $member; + } - return self::$pipeline ? $this : $isNew; + return self::$pipeline ? $this : (int) $isNew; } public function smembers($key) { - if (!isset(self::$data[$key])) - { + if (!isset(self::$data[$key])) { return self::$pipeline ? $this : array(); } @@ -120,8 +126,11 @@ public function smembers($key) public function srem($key, $member) { - if (!isset(self::$data[$key]) || !in_array($member, self::$data[$key])) - { + if (func_num_args() > 2) { + throw new UnsupportedException('In RedisMock, `srem` command can not remove more than one member at once.'); + } + + if (!isset(self::$data[$key]) || !in_array($member, self::$data[$key])) { return self::$pipeline ? $this : 0; } @@ -132,8 +141,7 @@ public function srem($key, $member) public function sismember($key, $member) { - if (!isset(self::$data[$key]) || !in_array($member, self::$data[$key])) - { + if (!isset(self::$data[$key]) || !in_array($member, self::$data[$key])) { return self::$pipeline ? $this : 0; } @@ -144,7 +152,11 @@ public function sismember($key, $member) public function hset($key, $field, $value) { - $isNew = !isset(self::$data[$key][$field]); + if (isset(self::$data[$key]) && !is_array(self::$data[$key])) { + return self::$pipeline ? $this : null; + } + + $isNew = !isset(self::$data[$key]) || !isset(self::$data[$key][$field]); self::$data[$key][$field] = $value; @@ -178,8 +190,12 @@ public function hexists($key, $field) // Sorted set - public function zrange($key, $start, $stop) + public function zrange($key, $start, $stop, $withscores = false) { + if ($withscores) { + throw new UnsupportedException('Parameter `withscores` is not supported by RedisMock for `zrange` command.'); + } + $set = $this->zrangebyscore($key, '-inf', '+inf'); if ($start < 0) { @@ -203,8 +219,12 @@ public function zrange($key, $start, $stop) return self::$pipeline ? $this : array_slice($set, $start, $length); } - public function zrevrange($key, $start, $stop) + public function zrevrange($key, $start, $stop, $withscores = false) { + if ($withscores) { + throw new UnsupportedException('Parameter `withscores` is not supported by RedisMock for `zrevrange` command.'); + } + $set = $this->zrevrangebyscore($key, '+inf', '-inf'); if ($start < 0){ @@ -228,13 +248,17 @@ public function zrevrange($key, $start, $stop) return self::$pipeline ? $this : array_slice($set, $start, $length); } - public function zrangebyscore($key, $min, $max, $options = null) + public function zrangebyscore($key, $min, $max, array $options = []) { + if (!empty($options['withscores'])) { + throw new UnsupportedException('Parameter `withscores` is not supported by RedisMock for `zrangebyscore` command.'); + } + if (!isset(self::$data[$key]) || !is_array(self::$data[$key])) { return self::$pipeline ? $this : null; } - if (!is_array($options) || !is_array($options['limit']) || count($options['limit']) != 2) { + if (!isset($options['limit']) || !is_array($options['limit']) || count($options['limit']) != 2) { $options['limit'] = [0, count(self::$data[$key])]; } @@ -285,13 +309,17 @@ public function zrangebyscore($key, $min, $max, $options = null) return self::$pipeline ? $this : array_values(array_slice($results, $options['limit'][0], $options['limit'][1], true)); } - public function zrevrangebyscore($key, $max, $min, $options = null) + public function zrevrangebyscore($key, $max, $min, array $options = []) { + if (!empty($options['withscores'])) { + throw new UnsupportedException('Parameter `withscores` is not supported by RedisMock for `zrevrangebyscore` command.'); + } + if (!isset(self::$data[$key]) || !is_array(self::$data[$key])) { return self::$pipeline ? $this : null; } - if (!is_array($options) || !is_array($options['limit']) || count($options['limit']) != 2) { + if (!isset($options['limit']) || !is_array($options['limit']) || count($options['limit']) != 2) { $options['limit'] = [0, count(self::$data[$key])]; } @@ -343,6 +371,10 @@ public function zrevrangebyscore($key, $max, $min, $options = null) } public function zadd($key, $score, $member) { + if (func_num_args() > 3) { + throw new UnsupportedException('In RedisMock, `zadd` command can not set more than one member at once.'); + } + if (isset(self::$data[$key]) && !is_array(self::$data[$key])) { return self::$pipeline ? $this : null; } @@ -369,6 +401,10 @@ public function zremrangebyscore($key, $min, $max) { } public function zrem($key, $member) { + if (func_num_args() > 2) { + throw new UnsupportedException('In RedisMock, `zrem` command can not remove more than one member at once.'); + } + if (isset(self::$data[$key]) && !is_array(self::$data[$key]) || !isset(self::$data[$key][$member])) { return self::$pipeline ? $this : 0; } diff --git a/src/M6Web/Component/RedisMock/RedisMockFactory.php b/src/M6Web/Component/RedisMock/RedisMockFactory.php new file mode 100644 index 0000000..8ec2afd --- /dev/null +++ b/src/M6Web/Component/RedisMock/RedisMockFactory.php @@ -0,0 +1,281 @@ + + */ +class RedisMockFactory +{ + protected $redisCommands = array( + 'append', + 'auth', + 'bgrewriteaof', + 'bgsave', + 'bitcount', + 'bitop', + 'blpop', + 'brpop', + 'brpoplpush', + 'client', + 'config', + 'dbsize', + 'debug', + 'decr', + 'decrby', + 'del', + 'discard', + 'dump', + 'echo', + 'eval', + 'evalsha', + 'exec', + 'exists', + 'expire', + 'expireat', + 'flushall', + 'flushdb', + 'get', + 'getbit', + 'getrange', + 'getset', + 'hdel', + 'hexists', + 'hget', + 'hgetall', + 'hincrby', + 'hincrbyfloat', + 'hkeys', + 'hlen', + 'hmget', + 'hmset', + 'hset', + 'hsetnx', + 'hvals', + 'incr', + 'incrby', + 'incrbyfloat', + 'info', + 'keys', + 'lastsave', + 'lindex', + 'linsert', + 'llen', + 'lpop', + 'lpush', + 'lpushx', + 'lrange', + 'lrem', + 'lset', + 'ltrim', + 'mget', + 'migrate', + 'monitor', + 'move', + 'mset', + 'msetnx', + 'multi', + 'object', + 'persist', + 'pexpire', + 'pexpireat', + 'ping', + 'psetex', + 'psubscribe', + 'pubsub', + 'pttl', + 'publish', + 'punsubscribe', + 'quit', + 'randomkey', + 'rename', + 'renamenx', + 'restore', + 'rpop', + 'rpoplpush', + 'rpush', + 'rpushx', + 'sadd', + 'save', + 'scard', + 'script', + 'sdiff', + 'sdiffstore', + 'select', + 'set', + 'setbit', + 'setex', + 'setnx', + 'setrange', + 'shutdown', + 'sinter', + 'sinterstore', + 'sismember', + 'slaveof', + 'slowlog', + 'smembers', + 'smove', + 'sort', + 'spop', + 'srandmember', + 'srem', + 'strlen', + 'subscribe', + 'sunion', + 'sunionstore', + 'sync', + 'time', + 'ttl', + 'type', + 'unsubscribe', + 'unwatch', + 'watch', + 'zadd', + 'zcard', + 'zcount', + 'zincrby', + 'zinterstore', + 'zrange', + 'zrangebyscore', + 'zrank', + 'zrem', + 'zremrangebyrank', + 'zremrangebyscore', + 'zrevrange', + 'zrevrangebyscore', + 'zrevrank', + 'zscore', + 'zunionstore', + 'scan', + 'sscan', + 'hscan', + 'zscan', + ); + + protected $classTemplate = <<<'CLASS' +namespace {{namespace}}; +class {{class}} extends \{{baseClass}} +{ + protected $mock; + public function __construct($mock) + { + $this->mock = $mock; + } + + public function __call($method, $args) + { + $methodName = strtolower($method); + + if (!method_exists('M6Web\Component\RedisMock\RedisMock', $methodName)) { + throw new \M6Web\Component\RedisMock\UnsupportedException(sprintf('Redis command `%s` is not supported by RedisMock.', $methodName)); + } + + return call_user_func_array(array($this->mock, $methodName), $args); + } +{{methods}} +} +CLASS; + + protected $methodTemplate = <<<'METHOD' + + public function {{method}}({{signature}}) + { + return $this->mock->{{method}}({{args}}); + } + +METHOD; + + + public function getAdapter($classToExtend, $redisMock) + { + $newClassName = sprintf('RedisMock_%s_Adapter', str_replace('\\', '_', $classToExtend)); + $namespace = __NAMESPACE__; + $class = $namespace . '\\'. $newClassName; + + if (class_exists($class)) { + return new $class($redisMock); + } + + $classCode = $this->getClassCode($namespace, $newClassName, new \ReflectionClass($classToExtend)); + + eval($classCode); + + return new $class($redisMock); + } + + protected function getClassCode($namespace, $newClassName, \ReflectionClass $class) + { + $methodsCode = ''; + + foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + $methodName = strtolower($method->getName()); + + if (!method_exists('M6Web\Component\RedisMock\RedisMock', $methodName) && in_array($methodName, $this->redisCommands)) { + throw new \M6Web\Component\RedisMock\UnsupportedException(sprintf('Redis command `%s` is not supported by RedisMock.', $methodName)); + } elseif (method_exists('M6Web\Component\RedisMock\RedisMock', $methodName)) { + $methodsCode .= strtr($this->methodTemplate, array( + '{{method}}' => $methodName, + '{{signature}}' => $this->getMethodSignature($method), + '{{args}}' => $this->getMethodArgs($method), + )); + } + } + + return strtr($this->classTemplate, array( + '{{namespace}}' => $namespace, + '{{class}}' => $newClassName, + '{{baseClass}}' => $class->getName(), + '{{methods}}' => $methodsCode, + )); + } + + protected function getMethodSignature(\ReflectionMethod $method) + { + $signatures = array(); + foreach ($method->getParameters() as $parameter) { + $signature = ''; + // typeHint + if ($parameter->isArray()) { + $signature .= 'array '; + } elseif (method_exists($parameter, 'isCallable') && $parameter->isCallable()) { + $signature .= 'callable '; + } elseif ($parameter->getClass()) { + $signature .= sprintf('\%s ', $parameter->getClass()); + } + // reference + if ($parameter->isPassedByReference()) { + $signature .= '&'; + } + // paramName + $signature .= '$' . $parameter->getName(); + // defaultValue + if ($parameter->isDefaultValueAvailable()) { + $signature .= ' = '; + if ($parameter->isDefaultValueConstant()) { + $signature .= $parameter->getDefaultValueConstantName(); + } else { + $signature .= var_export($parameter->getDefaultValue(), true); + } + } + + $signatures[] = $signature; + } + + return implode(', ', $signatures); + } + + protected function getMethodArgs(\ReflectionMethod $method) + { + $args = array(); + foreach ($method->getParameters() as $parameter) { + $args[] = '$' . $parameter->getName(); + } + + return implode(', ', $args); + } +} \ No newline at end of file diff --git a/src/M6Web/Component/RedisMock/UnsupportedException.php b/src/M6Web/Component/RedisMock/UnsupportedException.php new file mode 100644 index 0000000..c034487 --- /dev/null +++ b/src/M6Web/Component/RedisMock/UnsupportedException.php @@ -0,0 +1,11 @@ +variable($redisMock->get('test')) ->isNull() ->boolean($redisMock->exists('test')) - ->isFalse(); + ->isFalse() + ->string($redisMock->set('test1', 'something')) + ->isEqualTo('OK') + ->string($redisMock->set('test2', 'something else')) + ->isEqualTo('OK') + ->exception(function() use ($redisMock) { + $redisMock->del('test1', 'test2'); + }) + ->isInstanceOf('\M6Web\Component\RedisMock\UnsupportedException') + ->integer($redisMock->del('test1')) + ->isEqualTo(1) + ->integer($redisMock->del('test2')) + ->isEqualTo(1); } public function testIncr() @@ -88,39 +100,53 @@ public function testKeys() { ->containsValues(['something', 'someting_else']); } - public function setSAddSMembersSIsMemberSRem() + public function testSAddSMembersSIsMemberSRem() { $redisMock = new Redis(); $this->assert + ->string($redisMock->set('test', 'something')) + ->isEqualTo('OK') + ->variable($redisMock->sadd('test', 'test1')) + ->isNull() + ->integer($redisMock->del('test')) + ->isEqualTo(1) ->array($redisMock->smembers('test')) ->isEmpty() ->integer($redisMock->sismember('test', 'test1')) - ->isEquelTo(0) + ->isEqualTo(0) ->integer($redisMock->srem('test', 'test1')) - ->isEqual(0) + ->isEqualTo(0) ->integer($redisMock->sadd('test', 'test1')) - ->isEqual(1) + ->isEqualTo(1) + ->exception(function() use ($redisMock) { + $redisMock->sadd('test', 'test3', 'test4'); + }) + ->isInstanceOf('\M6Web\Component\RedisMock\UnsupportedException') ->integer($redisMock->sismember('test', 'test1')) - ->isEquelTo(1) + ->isEqualTo(1) ->integer($redisMock->sadd('test', 'test1')) - ->isEqual(0) + ->isEqualTo(0) ->array($redisMock->smembers('test')) ->hasSize(1) ->containsValues(['test1']) ->integer($redisMock->srem('test', 'test1')) - ->isEqual(1) + ->isEqualTo(1) ->integer($redisMock->sismember('test', 'test1')) - ->isEquelTo(0) + ->isEqualTo(0) ->integer($redisMock->sadd('test', 'test1')) - ->isEqual(1) + ->isEqualTo(1) ->integer($redisMock->sadd('test', 'test2')) - ->isEqual(1) + ->isEqualTo(1) ->array($redisMock->smembers('test')) ->hasSize(2) ->containsValues(['test1', 'test2']) + ->exception(function() use ($redisMock) { + $redisMock->srem('test', 'test1', 'test2'); + }) + ->isInstanceOf('\M6Web\Component\RedisMock\UnsupportedException') ->integer($redisMock->del('test')) - ->isEqual(2); + ->isEqualTo(2); } public function testZAddZRemZRemRangeByScore() @@ -128,10 +154,20 @@ public function testZAddZRemZRemRangeByScore() $redisMock = new Redis(); $this->assert + ->string($redisMock->set('test', 'something')) + ->isEqualTo('OK') + ->variable($redisMock->zadd('test', 1, 'test1')) + ->isNull() + ->integer($redisMock->del('test')) + ->isEqualTo(1) ->integer($redisMock->zrem('test', 'test1')) ->isEqualTo(0) ->integer($redisMock->zadd('test', 1, 'test1')) ->isEqualTo(1) + ->exception(function() use ($redisMock) { + $redisMock->zadd('test', 2, 'test1', 30, 'test2'); + }) + ->isInstanceOf('\M6Web\Component\RedisMock\UnsupportedException') ->integer($redisMock->zadd('test', 2, 'test1')) ->isEqualTo(0) ->integer($redisMock->zrem('test', 'test1')) @@ -148,6 +184,10 @@ public function testZAddZRemZRemRangeByScore() ->isEqualTo(1) ->integer($redisMock->zadd('test', -1, 'test3')) ->isEqualTo(1) + ->exception(function() use ($redisMock) { + $redisMock->zrem('test', 'test1', 'test2', 'test3'); + }) + ->isInstanceOf('\M6Web\Component\RedisMock\UnsupportedException') ->integer($redisMock->zremrangebyscore('test', '-inf', '+inf')) ->isEqualTo(3) ->integer($redisMock->del('test')) @@ -212,7 +252,13 @@ public function testZRange() ->isEqualTo(array( 'test2', 'test5', - )); + )) + ->exception(function() use ($redisMock) { + $redisMock->zrange('test', 1, -3, true); + }) + ->isInstanceOf('\M6Web\Component\RedisMock\UnsupportedException') + ->integer($redisMock->del('test')) + ->isEqualTo(6); } @@ -274,7 +320,13 @@ public function testZRevRange() ->isEqualTo(array( 'test1', 'test6', - )); + )) + ->exception(function() use ($redisMock) { + $redisMock->zrevrange('test', -2, -1, true); + }) + ->isInstanceOf('\M6Web\Component\RedisMock\UnsupportedException') + ->integer($redisMock->del('test')) + ->isEqualTo(6); } @@ -345,12 +397,16 @@ public function testZRangeByScore() 'test1', 'test4', )) - ->array($redisMock->zrangebyscore('test', '-inf', '15', ['limit' => [1, 3]])) + ->array($redisMock->zrangebyscore('test', '-inf', '15', ['limit' => [1, 3]])) ->isEqualTo(array( 'test1', 'test4', 'test3', )) + ->exception(function() use ($redisMock) { + $redisMock->zrangebyscore('test', '-inf', '15', ['limit' => [1, 3], 'withscores' => true]); + }) + ->isInstanceOf('\M6Web\Component\RedisMock\UnsupportedException') ->integer($redisMock->del('test')) ->isEqualTo(6); } @@ -428,6 +484,10 @@ public function testZRevRangeByScore() 'test4', 'test1', )) + ->exception(function() use ($redisMock) { + $redisMock->zrevrangebyscore('test', '15', '-inf', ['limit' => [1, 3], 'withscores' => true]); + }) + ->isInstanceOf('\M6Web\Component\RedisMock\UnsupportedException') ->integer($redisMock->del('test')) ->isEqualTo(6); } @@ -437,6 +497,12 @@ public function testHSetHGetHexistsHGetAll() $redisMock = new Redis(); $this->assert + ->string($redisMock->set('test', 'something')) + ->isEqualTo('OK') + ->variable($redisMock->hset('test', 'test1', 'something')) + ->isNull() + ->integer($redisMock->del('test')) + ->isEqualTo(1) ->variable($redisMock->hget('test', 'test1')) ->isNull() ->array($redisMock->hgetall('test')) diff --git a/tests/units/RedisMockFactory.php b/tests/units/RedisMockFactory.php new file mode 100644 index 0000000..c1a38ce --- /dev/null +++ b/tests/units/RedisMockFactory.php @@ -0,0 +1,148 @@ +getAdapter('StdClass', new Mock()); + + $this->assert + ->object($mock) + ->isInstanceOf('M6Web\Component\RedisMock\RedisMock_StdClass_Adapter') + ->class(get_class($mock)) + ->extends('StdClass') + ->string($mock->set('test', 'data')) + ->isEqualTo('OK') + ->string($mock->get('test')) + ->isEqualTo('data') + ->integer($mock->del('test')) + ->isEqualTo(1) + ->integer($mock->sadd('test', 'test1')) + ->isEqualTo(1) + ->integer($mock->sAdd('test', 'test2')) + ->isEqualTo(1) + ->array($mock->sMembers('test')) + ->isEqualTo(['test1', 'test2']) + ->integer($mock->sRem('test', 'test1')) + ->isEqualTo(1) + ->integer($mock->sRem('test', 'test2')) + ->isEqualTo(1) + ->integer($mock->del('test')) + ->isEqualTo(0) + ->exception(function() use ($mock) { + $mock->punsubscribe(); + }) + ->isInstanceOf('\M6Web\Component\RedisMock\UnsupportedException'); + + $mock2 = $factory->getAdapter('StdClass', new Mock()); + + $this->assert + ->object($mock2) + ->isInstanceOf('M6Web\Component\RedisMock\RedisMock_StdClass_Adapter') + ->class(get_class($mock)) + ->extends('StdClass'); + } + + /** + * test the mock with a complex base class + * @return void + */ + public function testMockComplex() + { + $factory = new Factory(); + $mock = $factory->getAdapter('M6Web\Component\RedisMock\tests\units\RedisWithMethods', new Mock()); + + $this->assert + ->object($mock) + ->isInstanceOf('M6Web\Component\RedisMock\RedisMock_M6Web_Component_RedisMock_tests_units_RedisWithMethods_Adapter') + ->class(get_class($mock)) + ->extends('M6Web\Component\RedisMock\tests\units\RedisWithMethods') + ->string($mock->set('test', 'data')) + ->isEqualTo('OK') + ->string($mock->get('test')) + ->isEqualTo('data') + ->integer($mock->del('test')) + ->isEqualTo(1) + ->integer($mock->zadd('test', 1, 'test1')) + ->isEqualTo(1) + ->integer($mock->zadd('test', 30, 'test2')) + ->isEqualTo(1) + ->integer($mock->zadd('test', 15, 'test3')) + ->isEqualTo(1) + ->array($mock->zrangebyscore('test', '-inf', '+inf')) + ->isEqualTo(array( + 'test1', + 'test3', + 'test2' + )) + ->array($mock->zRangeByScore('test', '-inf', '+inf', ['limit' => [1, 2]])) + ->isEqualTo(array( + 'test3', + 'test2' + )) + ->array($mock->zrevrangebyscore('test', '+inf', '-inf', ['limit' => [1, 2]])) + ->isEqualTo(array( + 'test3', + 'test1' + )); + } + + public function testUnsupportedMock() + { + $factory = new Factory(); + $this->assert + ->exception(function() use ($factory) { + $factory->getAdapter('M6Web\Component\RedisMock\Adapter\tests\units\RedisWithUnsupportedMethods', new Mock()); + }); + } +} + +class RedisWithMethods +{ + public function aNoRedisMethod() + { + + } + + public function set($key, $data) + { + throw new \Exception('Not mocked'); + } + + public function get($key) + { + throw new \Exception('Not mocked'); + } + + public function zRangeByScore($key, $min, $max, array $options = []) + { + throw new \Exception('Not mocked'); + } +} + +class RedisWithUnsupportedMethods +{ + public function set($key, $data) + { + throw new \Exception('Not mocked'); + } + + public function punsubscribe($pattern = null) + { + throw new \Exception('Not mocked'); + } +} \ No newline at end of file