3
\$\begingroup\$

The Service:

<?php

namespace TestApp\Services\Ext;

class ExtFilterParser {

    private $parsedFilters = array();
    private $filters = '';
    private $requestParam = 'filter';

    public function __toString() {
        return $this->filters;
    }

    public function getParsedFilters() {
        return $this->parsedFilters;
    }

    public function getFilters() {
        return $this->filters;
    }

    public function setFilters($filters) {
        $this->filters = $filters;
        return $this;
    }

    public function __construct(\Symfony\Component\HttpFoundation\Request $request) {
        $this->filters = $this->pullFilterJsonFromRequest($request);
    }

    private function pullFilterJsonFromRequest($request) {
        $filterJson = '';
        $filterFromGet = $request->query->get($this->requestParam);
        $filterFromPost = $request->request->get($this->requestParam);
        if(empty($filterFromGet) === FALSE) {
            $filterJson = $filterFromGet;
        } elseif(empty($filterFromPost) === FALSE) {
            $filterJson = $filterFromPost;
        }
        return $filterJson;
    }

    public function parse() {
        $decodedFilters = $this->decodeFilterJson($this->filters);
        foreach($decodedFilters as $filter) {
            switch($filter->type) {
                case 'numeric':
                    $this->parsedFilters[] = $this->parseComparisonFilter($filter);
                    break;
                case 'date':
                    $this->parsedFilters[] = $this->parseDateFilter($filter);
                    break;
                case 'list':
                    $this->parsedFilters[] = $this->parseListFilter($filter);
                    break;
                case 'string':
                    $this->parsedFilters[] = $this->parseStringFilter($filter);
                    break;
                default:
                    throw new \UnexpectedValueException(__METHOD__ . " Unknown filter type '$filter->type'");
            }
        }
        return $this;
    }

    public function parseIntoQueryBuilder(\Doctrine\ORM\QueryBuilder $queryBuilder) {
        $this->parse();
        foreach($this->parsedFilters as $filter) {
            $queryBuilder->andWhere('p.' . $filter['expression'] . ' ' . $filter['value']);
        }
        return $queryBuilder;
    }

    private function decodeFilterJson($filterJson) {
        $decodedFilters = array();
        if(empty($filterJson) === FALSE) {
            $decodedFilters = json_decode($filterJson);
            if($decodedFilters === NULL) {
                throw new \InvalidArgumentException(__METHOD__ . " Expects first parameter to be a valid JSON string");
            }
            if(!is_array($decodedFilters)) {
                $decodedFilters = array($decodedFilters);
            }
        }
        return $decodedFilters;
    }

    private function translateComparisonOperator($comparisonOperator) {
        $operator = '';
        switch($comparisonOperator) {
            case 'lt':
                $operator = '<';
                break;
            case 'gt':
                $operator = '>';
                break;
            case 'eq':
                $operator = '=';
                break;
            default:
                throw new \UnexpectedValueException(__METHOD__ . " Invalid comparison operator '$comparisonOperator'");
        }
        return $operator;
    }

    private function parseComparisonFilter($filter) {
        $comparisonOperator = $this->translateComparisonOperator($filter->comparison);
        if(!is_numeric($filter->value)) {
            $filter->value = "'$filter->value'";
        }
        return array(
            'expression' => "$filter->field $comparisonOperator",
            'value' => $filter->value
        );
    }

    private function parseDateFilter($filter) {
        $value = $filter->value;
        if($value == '0000-00-00') {
            $value = '';
        }
        $timestamp = strtotime($value);
        if($timestamp !== FALSE) {
            $value = date("Y-m-d", $timestamp);
        }
            $filter->value = $value;
        return $this->parseComparisonFilter($filter);
    }

    private function parseStringFilter($filter) {
        return array(
            'expression' => "$filter->field LIKE",
            'value' => "'%$filter->value%'"
        );
    }

    private function parseListFilter($filter) {
        if(!is_array($filter->value)) {
            $filter->value = explode(',', $filter->value);
        }
        return array(
            'expression' => "$filter->field IN",
            'value' => "('" . implode("','", $filter->value) . "')"
        );
    }

}

The unit test for the Service:

<?php

namespace TestApp\Tests\Services\Ext;

use TestApp\Services\Ext\ExtFilterParser;
use Symfony\Component\HttpFoundation\Request;

class ExtFilterParserTest extends \PHPUnit_Framework_TestCase {

    private $request;
    private $extFilterParser;

    public function dateDataProvider() {
        return array(
            array(
                '2011-06-30',
                'June 30, 2011',
                '06/30/2011',
                '30/06/2011'
            )
        );
    }

    public function setUp() {
        $this->request = Request::createFromGlobals();
        $this->extFilterParser = new ExtFilterParser($this->request);
    }

    /**
     * @param string $type The filter type to generate
     * @param string $field The field the filter applies to
     * @param mixed $value The value to filter by
     * @param string $comparison (Optional) Comparison Operator
     */
    public function generateFilterJson($type, $field, $value, $comparison = '') {
        return json_encode(array('type' => $type, 'field' => $field, 'value' => $value, 'comparison' => $comparison));
    }

    public function testForProperties() {
        $className = get_class($this->extFilterParser);
        $this->assertClassHasAttribute('parsedFilters', $className);
        $this->assertClassHasAttribute('filters', $className);
        $this->assertClassHasAttribute('requestParam', $className);
    }

    public function testPropertyDefaultValues() {
        $this->assertAttributeEmpty('parsedFilters', $this->extFilterParser, 'ExtFilterParser::parsedFilters should default to an empty array');
        $this->assertAttributeEquals('filter', 'requestParam', $this->extFilterParser, 'ExtFilterParser::requestParam should default to \'filter\'');
        $this->assertAttributeEquals('', 'filters', $this->extFilterParser, 'ExtFilterParser::filters should default to an empty string');
    }

    public function testPropertyDataTypes() {
        $this->assertAttributeInternalType('array', 'parsedFilters', $this->extFilterParser);
        $this->assertAttributeInternalType('string', 'filters', $this->extFilterParser);
        $this->assertAttributeInternalType('string', 'requestParam', $this->extFilterParser);
    }

    /**
     * @expectedException InvalidArgumentException
     * @expectedExceptionMessage ExtFilterParser::decodeFilterJson Expects first parameter to be a valid JSON string
     */
    public function testParseBadJsonException() {
        $this->extFilterParser->setFilters('bad json')->parse();
    }

    public function test__toString() {
        $filterJson = $this->generateFilterJson('string', 'firstName', 'Steve');
        $this->extFilterParser->setFilters($filterJson);
        $objectAsString = "$this->extFilterParser";
        $this->assertEquals($filterJson, $objectAsString);
    }

    /**
     * @expectedException UnexpectedValueException
     * @expectedExceptionMessage ExtFilterParser::translateComparisonOperator Invalid comparison operator 'bad'
     */
    public function testParseUnknownComparisonOperatorException() {
        $this->extFilterParser->setFilters('{"type":"numeric", "field": "foo", "value":"bar", "comparison": "bad"}')->parse();
    }

    /**
     * @expectedException UnexpectedValueException
     * @expectedExceptionMessage ExtFilterParser::parse Unknown filter type 'bad'
     */
    public function testParseUnknownFilterTypeException() {
        $this->extFilterParser->setFilters('{"type":"bad", "field": "foo", "value":"1"}')->parse();
    }

    public function testParseFromGetRequest() {
        $_GET['filter'] = $filter = $this->generateFilterJson('string', 'stringField', 'A');
        $this->extFilterParser = new ExtFilterParser(Request::createFromGlobals());
        $filter = $this->generateFilterJson('string', 'stringField', 'A');
        $parsedFilter = $this->extFilterParser->parse()->getParsedFilters();
        $this->assertEquals("stringField LIKE", $parsedFilter[0]['expression']);
        $this->assertEquals("'%A%'", $parsedFilter[0]['value']);
        $this->testParseStringFilter();
    }

    public function testParseFromPostRequest() {
        $_POST['filter'] = $filter = $this->generateFilterJson('string', 'stringField', 'A');
        $this->extFilterParser = new ExtFilterParser(Request::createFromGlobals());
        $filter = $this->generateFilterJson('string', 'stringField', 'A');
        $parsedFilter = $this->extFilterParser->parse()->getParsedFilters();
        $this->assertEquals("stringField LIKE", $parsedFilter[0]['expression']);
        $this->assertEquals("'%A%'", $parsedFilter[0]['value']);
        $this->testParseStringFilter();
    }

    /*
     * @expectedException UnexpectedValueException
     * @expectedExceptionMessage ExtFilterParser::setFilters Unknown filter type 'badValue'
     */

    public function testParseUnexpectedFilterTypeException() {
        $this->extFilterParser->setFilters('{"type":"badValue", "field": "foo", "value":"1"}');
    }

    public function testParseStringFilter() {
        $filter = $this->generateFilterJson('string', 'stringField', 'A');
        $parsedFilter = $this->extFilterParser->setFilters($filter)->parse()->getParsedFilters();
        $this->assertEquals("stringField LIKE", $parsedFilter[0]['expression']);
        $this->assertEquals("'%A%'", $parsedFilter[0]['value']);
    }

    /**
         * @dataProvider dateDataProvider
     * @param string $date The date to be parsed
     */
    public function testParseDateFilter($date) {
        $filter = $this->generateFilterJson('date', 'dateField', $date, 'lt');
        $parsedFilter = $this->extFilterParser->setFilters($filter)->parse()->getParsedFilters();
        $this->assertCount(1, $parsedFilter);
        $this->assertEquals("dateField <", $parsedFilter[0]['expression']);
        $this->assertEquals("'2011-06-30'", $parsedFilter[0]['value']);
    }

    public function testParseDateFilterEmptyDate() {
        $filter = $this->generateFilterJson('date', 'dateField', '0000-00-00', 'gt');
        $parsedFilter = $this->extFilterParser->setFilters($filter)->parse()->getParsedFilters();
        $this->assertCount(1, $parsedFilter);
        $this->assertEquals("dateField >", $parsedFilter[0]['expression']);
        $this->assertEquals("''", $parsedFilter[0]['value']);
    }

    public function testParseNumericFilterLessThan() {
        $filter = $this->generateFilterJson('numeric', 'numericField', 1, 'lt');
        $parsedFilter = $this->extFilterParser->setFilters($filter)->parse()->getParsedFilters();
        $this->assertCount(1, $parsedFilter);
        $this->assertEquals("numericField <", $parsedFilter[0]['expression']);
        $this->assertEquals("1", $parsedFilter[0]['value']);
    }

    public function testParseNumericFilterGreaterThan() {
        $filter = $this->generateFilterJson('numeric', 'numericField', 1, 'gt');
        $parsedFilter = $this->extFilterParser->setFilters($filter)->parse()->getParsedFilters();
        $this->assertCount(1, $parsedFilter);
        $this->assertEquals("numericField >", $parsedFilter[0]['expression']);
        $this->assertEquals("1", $parsedFilter[0]['value']);
    }

    public function testParseNumericFilterEqualTo() {
        $filter = $this->generateFilterJson('numeric', 'numericField', 1, 'eq');
        $parsedFilter = $this->extFilterParser->setFilters($filter)->parse()->getParsedFilters();
        $this->assertCount(1, $parsedFilter);
        $this->assertEquals("numericField =", $parsedFilter[0]['expression']);
        $this->assertEquals("1", $parsedFilter[0]['value']);
    }

    public function testParseListFilterAsArray() {
        $filter = $this->generateFilterJson('list', 'listField', array('a', 'b', 'c'));
        $parsedFilter = $this->extFilterParser->setFilters($filter)->parse()->getParsedFilters();
        $this->assertCount(1, $parsedFilter);
        $this->assertEquals("listField IN", $parsedFilter[0]['expression']);
        $this->assertEquals("('a','b','c')", $parsedFilter[0]['value']);
    }

    public function testParseListFilterAsCsv() {
        $filter = $this->generateFilterJson('list', 'listField', "a,b,c");
        $parsedFilter = $this->extFilterParser->setFilters($filter)->parse()->getParsedFilters();
        $this->assertCount(1, $parsedFilter);
        $this->assertEquals("listField IN", $parsedFilter[0]['expression']);
        $this->assertEquals("('a','b','c')", $parsedFilter[0]['value']);
    }

    public function testSetFiltersReturnsInstance() {
        $instance = $this->extFilterParser->setFilters($this->generateFilterJson('list', 'listField', "a,b,c"));- 
        $this->assertInstanceOf(get_class($this->extFilterParser), $instance);
    }

    public function testParseReturnsInstance() {
        $instance = $this->extFilterParser->setFilters($this->generateFilterJson('list', 'listField', "a,b,c"))->parse();
        $this->assertInstanceOf(get_class($this->extFilterParser), $instance);
    }

}

Facts

  • I know that the service works (I've used it).
  • I know all unit tests pass.

Concerns

  • The unit test makes use of and is dependent on the Request Object.
  • If I change the scope / configuration of my service via the YAML file that symfony uses (say to add another object to the constructor), these unit tests will all fail even though the code itself may not be "broken".
  • I feel like I've too tightly coupled things and made the unit tests not accurately reflect what's going on in my application.
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

The concerns you have raised are valid. This test is currently relying on the Request object (and is testing that object where it shouldn't be).

You have written the code well, injecting the dependency for the Request object into the constructor. This gives you the seam required for testing (Misko Hevery talks about seams and gives awesome advice on testing in many other places).

In your test you can make your seam by using mocked objects. What you really want to test is that the mock (a friendly object that you control with your test code) receives the expected stimuli. This means that you should be testing that your Request object is used correctly by your service code. You also define what you want your mock to return to your service code so that you are able to ensure that your service code reacts to the stimuli received from your Request.

PHPUnit makes it easy for you to create Mock objects. The whole of Chapter 10 is a good read.

The good news is that you only have tight coupling in your test code. Using the mock objects will remove this for you fairly easily.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.