Refactoring VoteValidator with tests

Jul 21, 2015

Some lessons learned about testing

A few days ago I decided that the t-shirt a day application doesn’t need the complexity of a vote validator with a validation handler and only a method stating if a vote is valid or not returning a boolean and then starting refactoring.

The process of recfactoring was not painfull thanks to the test suite already there but I must admit that I have spoted some flaws in my VoteValidator tests.

A cleaner VoteValidator

A vote is valid if :

  • The voter has note already voted for a Tshirt for the given day
  • The Tshirt has not been elected yet
  • A voting session is opened for that day

and I wanted the code to express this requirements clearly.

The isValid has been refactored to use 3 private methods each one dealing with one rule :

<?php
class VoteValidator
{
    public function isValid(Vote $vote)
    {
        return $this->voterHasNotAlreadyVotedForDay($vote->voterId(), $vote->day())
            && $this->tshirtExistsAndHasNotBeenElectedYet($vote->tshirtId())
            && $this->aVotingSessionIsOpenedForDay($vote->day());
    }
}
?>

This is the point I started to think that I probably should be able to switch lines and still have the test going green and tried to do so. Well, tests were failing.

Testing the right thing

I’ll take a simpler example that the one above with only two conditions to describe what I did wrong when I first wrote the tests.

Let’s define a simple class :

<?php
class Tuple
{
    public $a;

    public $b;

    public function __construct($a, $b)
    {
        $this->a = $a;
        $this->b = $b;
    }
}
?>

A tuple is valid if :

  • Element A exists
  • Element B exists

The tuple validator would have been something like this :

<?php
class TupleValidator
{
    private $elementARepository;

    public function __construct(ElementRepository $elementRepository)
    {
        $this->elementRepository = $elementRepository;
    }

    public function isValid(Tuple $tuple)
    {
        return $this->elementAExists($tuple->a)
            && $this->elementBExists($tuple->b);
    }

    private function elementAExists($a)
    {
        return $this->elementRepository->exists($a) !== null;
    }

    private function elementBExists($b)
    {
        return $this->elementRepository->exists($b) !== null;
    }
}
?>

In the TupleValidatorTest the method asserting that isValid returns false if A doesn’t exist would have been :

<?php
class TupleValidatorTest
{
    private $elementRepository;

    public function setUp()
    {
        $this->elementRepository = \Mockery::mock('ElementRepository');
    }

    /**
     * @test
     */
    public function should_return_false_if_A_doesnt_exist()
    {
        $a = 'A';
        $b = 'B';

        $tuple = new Tuple($a, $b);

        $validator = new TupleValidator($elementRepository);

        $this->elementRepository->shouldReceive('exists')->with($a)->andReturn(false);

        $this->assertFalse($validator->isValid($tuple));
    }
}
?>

The test will pass.

Now if I invert the two test in the isValid method :

<?php
// TupleValidator
public function isValid(Tuple $tuple)
{
    return $this->elementBExists($tuple->b)
        && $this->elementAExists($tuple->a);
}
?>

the test is failing because the ElementRepository mock wasn’t expecting a call for “exists” method with B as parameter. The implementation and the test are tightly coupled, which is bad.

The mistake here is that I don’t set up the system properly. If I want to test that isValid returns false when A doesn’t exist I should ensure that B exists because I don’t want a false positive test passing because B is considered unexistant too.

Let me introduce two more methods in the test class in order to make tests easier to read :

<?php
// TupleValidatorTest
public function elementExists($element)
{
    $this->elementRepository->shouldReceive('exists')->with($element)->andReturn(true);
}

public function elementDoesntExist($element)
{
    $this->elementRepository->shouldReceive('exists')->with($element)->andReturn(false);
}
?>

We can now write the tests with the correct set up :

<?php
// TupleValidatorTest
/**
 * @test
 */
public function should_return_false_if_A_doesnt_exist()
{
    $a = 'A';
    $b = 'B';

    $tuple = new Tuple($a, $b);

    $validator = new TupleValidator($elementRepository);

    $this->elementDoesntExist($a);
    $this->elementExists($b);

    $this->assertFalse($validator->isValid($tuple));
}
?>

This test will pass whatever the order of the assertions in isValid is.

I think starting with the test for the positive result helps because you have to set up the whole system in order to make it pass and you have a template for all the negative results.

Here are all the correct tests for the TupleValidator :

<?php
// TupleValidatorTest
/**
 * @test
 */
public function should_return_true_if_A_and_B_exist()
{
    $a = 'A';
    $b = 'B';

    $tuple = new Tuple($a, $b);

    $validator = new TupleValidator($elementRepository);

    $this->elementExists($a);
    $this->elementExists($b);

    $this->assertTrue($validator->isValid($tuple));
}

/**
 * @test
 */
public function should_return_false_if_A_doesnt_exist()
{
    $a = 'A';
    $b = 'B';

    $tuple = new Tuple($a, $b);

    $validator = new TupleValidator($elementRepository);

    $this->elementDoesntExist($a);
    $this->elementExists($b);

    $this->assertFalse($validator->isValid($tuple));
}

/**
 * @test
 */
public function should_return_false_if_B_doesnt_exist()
{
    $a = 'A';
    $b = 'B';

    $tuple = new Tuple($a, $b);

    $validator = new TupleValidator($elementRepository);

    $this->elementExists($a);
    $this->elementDoesntExist($b);

    $this->assertFalse($validator->isValid($tuple));
}
?>

When writing a test be sur to set up the system in a state that allows you to test what you think you are testing.

This is a rather long post and even if I noticed something else during the refactoring I’ll stop now and keep this for a futur article.

Hey ! I’m on Twitter too, if you want to chat about testing or something else. Feel free to comment below as well.