Unit Testing XI - Refactoring the User Class Test

Adapting our User class to handle external validation


In the previous article in this series, we looked at our new Validator class. In this one, we'll take a fresh look at testing the User class now that it no longer contains the validation code.


MODX logo

Testing the User Class

Our user class no longer contains any validation code. That's now in the Validator class. We still need to make sure, though, that our User class handles the various kinds of errors properly. The possible errors are "Invalid username," "Invalid email," "Invalid phone," and "Unknown field."

We could pass the User constructor a variety of valid and invalid inputs and see what errors appeared, but that would be not only inefficient, but a violation of good unit testing practice.

Our tests would be dependent on two separate classes, the User class and the Validator class. In other words, the unit tests for the User object could fail because of a problem in the Validator class. The last thing you want in unit testing is a test that fails your class when there is nothing wrong with it. Worse yet, a problem in the User class itself could be hidden by a failure in the Validator test (technically a "false positive"). In that case, there's something wrong with the User class, but our unit tests won't find it.

Here's a nice rule of thumb to keep in mind when unit testing. It comes from StackOverflow user Drew Stevens:

A unit test should test a single codepath through a single method. When the execution of a method passes outside of that method, into another object, and back again, you have a dependency.

When you test that code path with the actual dependency, you are not unit testing; you are integration testing. While that's good and necessary, it isn't unit testing.

...

A mock replaces that dependency. You set expectations on calls to the dependent object, set the exact return values it should give you to perform the test you want, and/or what exceptions to throw so that you can test your exception handling code. In this way you can test the unit in question easily.

[Note: the author uses the term "mock" to include both mocks and stubs.]

In this article, we'll see a test of our User class that creates stubs for the validation methods used in the class. That way the class can be tested without a dependency on the Validator class. Our stubbed validation methods will return values we set during the test.


Strategy

In addition to the other tests (e.g., the constructor tests we created earlier), we'll simulate trying to create users with various validation errors in their fields. Our tests will make sure the User class sets the proper error messages.


One Catch

There's a problem with our strategy. In order to have the error messages set in the same way they would be when our class is used, the constructor needs to be called. The problem is that as soon as we call the constructor, it will test the field values using the actual code of the validation methods rather than our stubbed versions.

The testing code in this article is not really ideal. It's presented because you might someday have to use this method when developing tests of legacy code or third-party code that you don't have the option to edit. We'll look at another way of solving this problem in a future article.


The Test Code

To create the code for this test, use this command while in the _build directory:

codecept generate:test unit T3_UserErrorMessage

Here's the code (also available at GitHub here):

<?php

use Codeception\Util\Stub;

class T3_UserErrorMessageTest extends \Codeception\Test\Unit {

    /**
     * @var \UnitTester
     */
    protected $tester;

    protected function _before() {
        require_once dirname(dirname(dirname(dirname(__FILE__)))) . '/core/model/user3.class.php';
    }

    protected function _after() {
    }

    public function testWorking() {
        assertTrue(true);
    }

    public function testConstructorWithParams() {
        $user = new User3(array(
            'username' => 'BobRay',
            'email' => 'bobray@hotmail.com',
            'phone' => '218-456-1234'
        ));
        assertInstanceOf('User3', $user);
        assertEquals('BobRay', $user->get('username'));
        assertEquals('bobray@hotmail.com', $user->get('email'));
        assertEquals('218-456-1234', $user->get('phone'));
        assertFalse($user->hasErrors());
    }

    public function testConstructorNoParams() {
        $user = new User3();
        assertEquals('', $user->get('username'));
        assertEquals('', $user->get('email'));
        assertEquals('', $user->get('phone'));
        assertFalse($user->hasErrors());
    }

    /** @throws Exception */
    public function testConstructorAllBad() {
        $user =  $this->make(User3::class,
            array(
                'validateUsername' => function () {
                    return false;
                },
                'validateEmail' => function () {
                    return false;
                },
                'validatePhone' => function () {
                    return false;
                },
            ));

        $user->__construct(array(
            'username' => 'Bobby',
            'email' => 'bob@hotmail.com',
            'phone' => '218-651-1234',
            'invalidField' => '',
        ));
        assertTrue($user->hasErrors());
        $errors = $user->getErrors();
        assertEquals(4, count($errors));
        assertContains('Invalid username', $errors);
        assertContains('Invalid email', $errors);
        assertContains('Invalid phone', $errors);
        assertContains('Unknown field', $errors);
    }

    /** @throws Exception */
    public function testConstructorAllGood() {
        $user = $this->make(User3::class,
            array(
                'validateUsername' => function () {
                    return true;
                },
                'validateEmail' => function () {
                    return true;
                },
                'validatePhone' => function () {
                    return true;
                },

            ));

        $user->__construct(array(
            'username' => 'Bobby',
            'email' => 'bob@hotmail.com',
            'phone' => '218-651-1234',
        ));
        assertFalse($user->hasErrors());
        $errors = $user->getErrors();
        assertEmpty($errors);
    }

        /** @throws Exception */
    public function testConstructorBadUsernameOnly() {

        $user =  $this->make(User3::class,
            array(
                'validateUsername' => function () {
                    return false;
                },
                'validateEmail' => function () {
                    return true;
                },
                'validatePhone' => function () {
                    return true;
                }
            ));

        $user->__construct(array(
            'username' => 'Bobby',
            'email' => 'bob@hotmail.com',
            'phone' => '218-651-1234'
        ));

        assertTrue($user->hasErrors());
        $errors = $user->getErrors();
        assertEquals(1, count($errors));
        assertContains('Invalid username', $errors);
    }


    /** @throws Exception */
    public function testConstructorBadEmailOnly() {

        $user =  $this->make(User3::class,
            array(
                'validateEmail' => function () {
                    return false;
                },
                'validateUsername' => function () {
                    return true;
                },
                'validatePhone' => function () {
                    return true;
                },
            ));

        $user->__construct(array(
            'username' => 'Bobby',
            'email' => 'bob@hotmail.com',
            'phone' => '218-651-1234'
        ));

        assertTrue($user->hasErrors());
        $errors = $user->getErrors();
        assertEquals(1, count($errors));
        assertContains('Invalid email', $errors);
    }

    /** @throws Exception */
    public function testConstructorBadPhoneOnly() {
        $user =  $this->make(User3::class,
            array(
                'validateEmail' => function () {
                    return true;
                },
                'validateUsername' => function () {
                    return true;
                },
                'validatePhone' => function () {
                    return false;
                },
            ));

        $user->__construct(array(
            'username' => 'Bobby',
            'email' => 'bob@hotmail.com',
            'phone' => '218-651-1234'
        ));

        assertTrue($user->hasErrors());
        $errors = $user->getErrors();
        assertEquals(1, count($errors));
        assertContains('Invalid phone', $errors);
    }

    /** @throws Exception */
    public function testConstructorUnknownFieldOnly() {

        $user =  $this->make(User3::class,
            array(
                'validateEmail' => function () {
                    return true;
                },
                'validateUsername' => function () {
                    return true;
                },
                'validatePhone' => function () {
                    return true;
                },
            ));

        $user->__construct(array(
            'username' => 'Bobby',
            'email' => 'bob@hotmail.com',
            'phone' => '218-651-1234',
            'invalidField' => '',
        ));

        assertTrue($user->hasErrors());
        $errors = $user->getErrors();
        assertEquals(1, count($errors));
        assertContains('Unknown field', $errors);
    }
}

We've solved the problem of calling the constructor prematurely. Remember that the make ... methods create a stub class *without* calling the constructor. Later in each test, we call the constructor explicitly with $user->__construct().. Not all languages allow this, but PHP does.


How it Works

Let's look at a single test method from the code above — the one that simulates trying to create a User object when only the username is invalid:

    /** @throws Exception */
    public function testConstructorBadUsernameOnly() {

        $user =  $this->make(User3::class,
            array(
                'validateUsername' => function () {
                    return false;
                },
                'validateEmail' => function () {
                    return true;
                },
                'validatePhone' => function () {
                    return true;
                }
            ));

        $user->__construct(array(
            'username' => 'Bobby',
            'email' => 'bob@hotmail.com',
            'phone' => '218-651-1234'
        ));

        assertTrue($user->hasErrors());
        $errors = $user->getErrors();
        assertEquals(1, count($errors));
        assertContains('Invalid username', $errors);
    }

At the top, we see our comment, /** @throws Exception */ which keeps our code editor from complaining about an unhandled exception.

Inside the method, the first call is to make(). The first parameter tells Codeception that we want to create a stubbed version of the User3 class. The second parameter is the array containing the three methods we want to replace and what we want them to return. After that line executes, the $user variable holds a version of the User3 class where those three methods return the values we assigned them.

The real class calls the methods of the Validator class to validate the fields. Our stubbed version just returns the preset values. The Validator class isn't involved in any way. In fact, you could remove or change all the methods of the Validator class and the tests would run with no problem.

The advantage here is that if someone messes up the Validator class code or swaps in a different Validator, this test won't be affected at all. Remember that our Validator class is fully tested in another unit test (Validator.test.php). The two separate classes get tested without any dependencies on each other.

The next-to-last section of the code calls the stubbed User3 class constructor explicitly. Since that call is to the stub, and we've set up the return values of the three methods, those values will be used when the constructor is called, so our tests can see if the proper error messages are set in all possible circumstances.

Notice that we've sent valid values for username, email, and phone fields to the constructor in every test. These values are ignored by the validation tests since we've specified ahead of time what they will return.

Finally, we make our assertions:

assertTrue($user->hasErrors());
$errors = $user->getErrors();
assertEquals(1, count($errors));
assertContains('Invalid username', $errors);

The first line above makes sure that an error message was added to class $error array.

The second line is not a test or an assertion. It simply gets the error array from the stubbed User3 class. Because tests are written in regular PHP, you can do pretty much anything you want in the code of the tests, as long as it's valid PHP. Not every line has to be an assertion.

In the third line, we make sure that the $errors array we've retrieved has only a single error message in it.

Finally, we use assertContains() to make sure that the only error message is the correct one.

The assertContains() method can be used with strings or arrays. When used like this, with an array, it acts like PHP's in_array(). When used with a string, it acts more like strpos($haystack, $needle) !== false). The optional third parameter to assertContains() will show an error message if the assertion fails. This would be a good use of that option:

assertContains('Invalid username', $errors, print_r($errors, true));

The line above would display the whole $errors array only if the assertion failed.

There is also an optional fourth boolean parameter. If it's true, assertContains() will ignore case in its test. It is false by default. This would be a good option if your test frequently failed because people messed with the case of the error messages. There is also a assertNotContains() method which works just like you would expect it to.


Formatting

Since whitespace doesn't matter to PHP, the arrays used to create the stubbed methods could be compressed somewhat. I put them on separate lines so they wouldn't extend too far to the right. They could look like this:

$user =  $this->make(User3::class,
    array(
        'validateEmail' => function () {return true;},
        'validateUsername' => function () {return true;},
        'validatePhone' => function () {return true;},
    ));

You can decide which version you prefer, but putting paired curly braces on the same line is usually considered a bad coding practice unless the body is empty (e.g., in an abstract class that can't be instantiated).

The code in this article is not quite complete. In a unit test, you want to check all possible paths through your code, so the test should include tests where various combinations of two and three bad values are simulated. Creating those tests is left as an exercise for the reader.


PhpUnit

Because Codecept wraps PhpUnit, you can also use PhpUnit to create the stubbed classes, which some people prefer. It replaces the make() code in our example above. Here's the PhpUnit version of the stubbing:

$user = $this->getMockBuilder(User3::class)
            ->disableOriginalConstructor()
            ->getMock();

        // Configure the stub.
        $user->method('validateEmail')
            ->willReturn('foo');

        $user->method('validateUsername')
            ->willReturn(true);

        $user->method('validatePhone')
            ->willReturn(false);

$user = $this->getMockBuilder('User3')
    ->setMethods(array(
        'validateUsername',
        'validateEmail',
        'validatePhone'))
    ->disableOriginalConstructor()
    ->getMock();

$user->expects($this->any())
    ->method('validateUsername')
    ->will($this->returnValue(true));

$user->expects($this->any())
    ->method('validateEmail')
    ->will($this->returnValue(true));

$user->expects($this->any())
    ->method('validatePhone')
    ->will($this->returnValue(true));

$user->__construct(array(
    'username' => 'Bobby',
    'email' => 'bob@hotmail.com',
    'phone' => '218-651-1234'
));

The first section tells PhpUnit we want a stub/mock and lists the three methods we want to replace. It also suppresses calling the constructor by adding the chained method disableOriginalConstructor().

The next three sections create the three substitute methods and what they return. The $this->any() parameter specifies how many times we expect the method to be called. We could have used $this->once() since each method should be called only once in each test. That would make the test fail if it was called more than once. The basic options are:

  • any() — we don't care how many times it's called
  • never() — test fails if it's called
  • atLeastOnce() — test fails if it's never called
  • once() — test fails unless it's called exactly once
  • exactly($int) — test fails unless it's called $int times

Using the any() designation pushes this right up to the edge of the line between stubs and mocks we talked about earlier in this series of articles. If we had used once(), exactly(), or never() it would be over the line into mock territory, but any() won't have any effect on our test, so it's still a stub. Some would say that using one of the other choices would make this a functional test rather than a unit test.

Codeception handles the expectations about how many times a method is called in another way that we'll see in a later article. The make() method basically defaults to any().

The last section is the same as the code we used above in the Codeception version.


Coming Up

In the next article in this series, we'll see the user3.class.php code that will pass the tests in this article.



For more information on how to use MODX to create a web site, see my web site Bob's Guides, or better yet, buy my book: MODX: The Official Guide.

Looking for high-quality, MODX-friendly hosting? As of May 2016, Bob's Guides is hosted at A2 hosting. (More information in the box below.)



Comments (0)


Please login to comment.

  (Login)