Nie musimy chyba nikogo przekonywać, że podstawą bezpieczeństwa każdej aplikacji jest dokładna walidacja danych wprowadzanych przez użytkownika. Proces ten powinien być wykonywany nie tylko po stronie przeglądarki (klienta), ale i po stronie serwera.

Wykorzystywany przez nas framework Symfony2, oprócz udostępniania kilkudziesięciu podstawowych walidatorów, pozwala w prosty sposób tworzyć własne. W tym artykule nie będziemy jednak poruszać tematu ich pisania – na ten temat znajdziecie w sieci sporo informacji.

Tym razem chcielibyśmy skupić się na temacie testowania ich za pomocą frameworka PHPUnit.

Twórcy Symfony postanowili uprościć nam życie i stworzyli klasę AbstractConstraintValidatorTest będącą nakładką na PHPUnit_Framework_TestCase. Pozwala ona w przyjemny i bardzo czytelny sposób tworzyć testy dla obiektów ConstraintValidator.

Dla zilustrowania użyjemy przykładowego walidatora SecurePasswordValidator sprawdzającego czy hasło jest bezpieczne:

class SecurePasswordValidator extends ConstraintValidator
{
   const MIN_LENGTH = 8;
   const TWO_UPPERCASE_LETTERS_PATTERN = '/^.*([A-Z].*[A-Z]).*$/';
   const ONE_SPECIAL_CHARACTER_PATTERN = '/^.*([!@#$&*]).*$/';
   const TWO_DIGITS_PATTERN = '/^.*(\d.*\d).*$/';

   /**
    * @param string $value
    * @param SecurePassword|Constraint $constraint
    */
   public function validate($value, Constraint $constraint)
   {
       $message = null;

       if (strlen($value) < self::MIN_LENGTH) {
           $message = 'need at least 8 characters';
       } elseif (!preg_match(self::TWO_UPPERCASE_LETTERS_PATTERN, $value)) {
           $message = 'need at least two uppercase letters';
       } elseif (!preg_match(self::ONE_SPECIAL_CHARACTER_PATTERN, $value)) {
           $message = 'need at least one special character';
       } elseif (!preg_match(self::TWO_DIGITS_PATTERN, $value)) {
           $message = 'need at least two digits';
       }

       if (!is_null($message)) {
           $this
               ->context
               ->buildViolation($message)
               ->setParameter('{{ password }}', $value)
               ->atPath('password')
               ->addViolation();
       }
   }
}

Jak widać, by hasło pozytywnie przeszło walidację musi mieć:

  • co najmniej 8 znaków
  • co najmniej dwie duże litery
  • co najmniej jeden znak specjalny
  • co najmniej 2 cyfry

Przejdźmy do testów jednostkowych. Wspomniana klasa AbstractConstraintValidatorTest posiada jedną metodę abstrakcyjną, którą musimy zaimplementować. Jest nią createValidator, zwracająca w naszym przypadku obiekt testowanego przez nas walidatora SecurePasswordValidator.

use Symfony\Component\Validator\Tests\Constraints\AbstractConstraintValidatorTest;

class SecurePasswordValidatorTest extends AbstractConstraintValidatorTest
{
   /**
    * @return SecurePasswordValidator
    */
   protected function createValidator()
   {
       return new SecurePasswordValidator();
   }
}

Nasz pierwszy test będzie sprawdzał, czy w razie przekazania bezpiecznego hasła walidator nie zgłosi żadnego błędu. Służy do tego specjalna asercja assertNoViolation:

   /**
    * @param string $password
    * @dataProvider getValidPasswordDataProvider
    */
   public function testValidPasswords($password)
   {
       $constraint = new SecurePassword();
       $this->validator->validate($password, $constraint);

       $this->assertNoViolation();

   /**
    * @return array
    */
   public function getValidPasswordDataProvider()
   {
       return [
           ['thisIsMyPassword!13'],
           ['miOL45$nmd'],
           ['fx.o2GQIQ3!'],
       ];
   }
}

Drugi test to przypadek przekazania niebezpiecznego hasła. Tutaj z pomocą przychodzi nam metoda buildViolation, sprawdzająca czy walidator wykrył błąd. Jako argument przyjmuje ona komunikat błędu jaki “rzuca” walidator, zwraca natomiast obiekt ConstraintViolationAssertion pozwalający budować szczegółową asercję.

Tym sposobem możemy dodatkowo sprawdzić czy ustawiony jest parametr komunikatu (wykorzystywane do translacji), a także czy błąd zgłaszany jest dla odpowiedniej ścieżki pola (przydatne przy formularzach).

   /**
    * @param string $password
    * @param string $message
    * @dataProvider getInvalidPasswordDataProvider
    */
   public function testInvalidPasswords($password, $message)
   {
       $constraint = new SecurePassword();
       $this->validator->validate($password, $constraint);

       $this
           ->buildViolation($message)
           ->setParameter('{{ password }}', $password)
           ->atPath('property.path.password')
           ->assertRaised();
   }

   /**
    * @return array
    */
   public function getInvalidPasswordDataProvider()
   {
       return [
           [null, 'need at least 8 characters'],
           ['pass', 'need at least 8 characters'],
           ['thisismypassword', 'need at least two uppercase letters'],
           ['thisIsMyPassword', 'need at least one special character'],
           ['thisIsMyPassword!', 'need at least two digits'],
       ];
   }

Na koniec musimy wspomnieć o pewnej niedogodności – AbstractConstraintValidatorTest wykorzystuje do tworzenia mocków (m.in. translatora) metodę getMock, która od PHPUnit 5.4.0 jest już deprecated. Na szczęście niebawem możemy spodziewać się poprawki.

Poniżej pełen kod testów jednostkowych:

use Symfony\Component\Validator\Tests\Constraints\AbstractConstraintValidatorTest;

class SecurePasswordValidatorTest extends AbstractConstraintValidatorTest
{
   /**
    * @return SecurePasswordValidator
    */
   protected function createValidator()
   {
       return new SecurePasswordValidator();
   }

   /**
    * @param string $password
    * @dataProvider getValidPasswordDataProvider
    */
   public function testValidPasswords($password)
   {
       $constraint = new SecurePassword();
       $this->validator->validate($password, $constraint);

       $this->assertNoViolation();
   }

   /**
    * @param string $password
    * @param string $message
    * @dataProvider getInvalidPasswordDataProvider
    */
   public function testInvalidPasswords($password, $message)
   {
       $constraint = new SecurePassword();
       $this->validator->validate($password, $constraint);

       $this
           ->buildViolation($message)
           ->setParameter('{{ password }}', $password)
           ->atPath('property.path.password')
           ->assertRaised();
   }

   /**
    * @return array
    */
   public function getValidPasswordDataProvider()
   {
       return [
           ['thisIsMyPassword!13'],
           ['miOL45$nmd'],
           ['fx.o2GQIQ3!'],
       ];
   }

   /**
    * @return array
    */
   public function getInvalidPasswordDataProvider()
   {
       return [
           [null, 'need at least 8 characters'],
           ['pass', 'need at least 8 characters'],
           ['thisismypassword', 'need at least two uppercase letters'],
           ['thisIsMyPassword', 'need at least one special character'],
           ['thisIsMyPassword!', 'need at least two digits'],
       ];
   }
}