Приветствую всех, в данной статье я кратко расскажу и покажу, что такое TDD на очень простом примере.

Alert!
Данная статья не претендует на тьюториал, или на светоч знаний про методологию. Это скорее шпаргалка, которая была у меня в голове. Простенький пример, которым я решил поделиться.

Также статья не удивит никого, кто уже знаком с методологией TDD, это лишь демонстрация основного ее принципа: пиши тесты, до того, как пишешь код.

Итак, концепция TDD (Test Driven Development) – достаточно проста: Разработка ведется короткими циклами, каждый из которых состоит из 3‑х стадий:

1)Написание тестов, покрывающий желаемое изменение

2)Написание кода, который позволит пройти тест

3) Рефакторинг нового кода к соответствующим стандартам, если требуется

TDD Cycle
TDD Cycle

Теория на этом закончилась, если вам не хватило, вот пару толковых статей: тык и тык.

-------------------------------------------------------------------------------------------------------------------------------------------

Теперь же представим себя разработчиком в вымышленной ИТ компании, перед которым стоит задача: написать валидатор пользовательских паролей, при этом стараясь следовать принципам TDD.

Начнем разработку нашей программы с ознакомления с требованиями службы безопасности:

Придуманный пользователем пароль:

  • Не должен быть короче 8 и длиннее 22 символов

  • Содержит буквы исключительно латинского алфавита (если вообще содержит)

  • Обязательно содержит хотя бы 1 спецсимвол 

      Спецсимволы: @ ! # $ % ^ & * ( ) — _ + = ; : , . / ? \ | ` ~ [ ] { }

Пароль считается слабым, если выполнено хотя бы 1 условие из списка:

  • Не содержит букв

  • Имеет длину 8 символов и содержит один символ 3 или более раз

Пароль считается средним, если выполнено хотя бы 1 условие из списка:

  • Не содержит цифр 

  • Состоит менее чем из 10 символов 

  • Содержит только 1 цифру, которая стоит в конце.

Пароль в остальных случаях считается сильным.

Приступим к написанию тестов. 

⚠️ Для наглядности мы будем хранить пароль в переменной типа String, что не является хорошей практикой в реальных проектах ⚠️

Хронологический порядок написания всего кода данного проекта вы можете посмотреть на моем GitHub, кликнув по истории коммитов. Здесь я буду приводить лишь небольшие выдержки из unit-тестов

Первая пачка тестов будет посвящена тому, что бы недопустимым паролям был присвоен статус INCORRECT.

Это будет 3 теста:

1) На длину пароля

2) На проверку соответствию букв в пароле буквам латинского алфавита

3) На содержание как минимум 1‑го спецсимвола.

Ссылка на коммит итерации


Примеры проверок на этой стадии:

assertEquals(PasswordValidator.validatePassword("русскийязык$77"), PasswordStatus.INCORRECT);
assertEquals(PasswordValidator.validatePassword("$7你好754你好"), PasswordStatus.INCORRECT);
assertEquals(PasswordValidator.validatePassword("helloworld"), PasswordStatus.INCORRECT);
assertEquals(PasswordValidator.validatePassword("01122000"), PasswordStatus.INCORRECT);

После написания тестов реализуем все эти проверки во вспомогательном приватном методе passwordIsCorrect(), и используем его в основном методе validatePassword()

Я реализовал данные проверки с помощью регулярных выражений:

public static PasswordStatus validatePassword(String password){
    if(!passwordIsCorrect(password)) return PasswordStatus.INCORRECT;
    return null;
}

private static boolean passwordIsCorrect(String password){
    if(!password.matches("^.{8,22}$")) return false;
    if(!password.replaceAll("[\\\\!@#$%^&*()—_+=;:,./?|`~\\[\\]{}\\d]","").matches("^[a-zA-Z]*$")) return false;
    if(password.replaceAll("[^\\\\!@#$%^&*()—_+=;:,./?|`~\\[\\]{}]","").equals("")) return false;
    return true;

}
first test set completed
first test set completed

После того как тесты прошли, мы можем переходить ко 2‑й итерации, так как рефакторить нам пока что ничего не нужно.

Во второй итерации я напишу тесты, которые уже будут проверять нашу систему оценки сложности пароля. 

Суммарно в требованиях 5 критериев, по которым мы присваиваем ту или иную степень надежности паролю, поэтому напишем 5 тестов по этим критериям и еще один дополнительный, для тестирования «эталонных», сложных паролей.

Ссылка на коммит (юнит тесты 2й итерации)

Примеры проверок:

assertEquals(PasswordValidator.validatePassword("1234567#"),PasswordStatus.WEAK);
assertEquals(PasswordValidator.validatePassword("$abc&cbat#^"),PasswordStatus.MEDIUM);
assertEquals(PasswordValidator.validatePassword("2023harl&&ff"),PasswordStatus.STRONG);

Разумеется, все написанные проверки не проходят:

second test set
second test set

Я написал простой код (коммит 2й итерации), который последовательно проверяет пароль на соответствие всем критериям с помощью метода replaceAll() и пачки регулярных выражений:

Доработка метода validatePassword :

if(password.replaceAll("[^a-zA-Z]","").equals("")) return PasswordStatus.WEAK;
if(password.length()==8 && numberOfOccurrencesOfTheMostCommonCharacterInString(password)>=3) return PasswordStatus.WEAK;
if(password.replaceAll("\\D","").equals("")) return PasswordStatus.MEDIUM;
if(password.length()<10) return PasswordStatus.MEDIUM;
if(password.matches("^\\D*\\d$")) return PasswordStatus.MEDIUM;
return PasswordStatus.STRONG;

Функция, возвращающая число повторов самого часто встречающегося символа в строке:

private static int numberOfOccurrencesOfTheMostCommonCharacterInString(String s){
    Map<Character, Integer> map = new HashMap<>();

    for (int i = 0; i < s.length(); i++) {
        char c = s.charAt(i);
        Integer val = map.get(c);
        if (val != null) map.put(c, val + 1);
        else map.put(c, 1);
    }
    return Collections.max(map.values());
}

И вот, вуаля! Все тесты проходят.

second test set completed
second test set completed

Казалось бы, что на этом все. Мы написали рабочий код, который проходит все тесты и корректно выполняет свою работу. Но как бы не так. После запуска нашего кода в работу, выяснилось следующее:

1) Иногда случаются сбои, и в нашу программу может прилететь некорректный аргумент, поэтому нужно грамотно обработать null и пробрасывать IllegalArgumentException

2) Служба безопасности прислала нам список 500 самых часто используемых паролей (файл dangerous_passwords.txt). Эти пароли взломщики будут использовать в первую очередь, поэтому данным паролям должен быть присвоен статус WEAK.

 

Итак, приступим к новой итерации.

Напишем 2 теста, первый будет проверять, что IllegalArgumentException пробрасывается с корректным сообщением ошибки, второй – проверять, что паролям из текстового файла не присваиваются статусы MEDIUM и STRONG. 

Ссылка на коммит (юнит тесты 3й итерации) -

Примеры проверок:

@Test
public void passIsNullTest(){
    try{
        PasswordValidator.validatePassword(null);
        fail();
    }
    catch (IllegalArgumentException e){
        assertEquals("Password can't be null", e.getMessage());
    }
}

И проверки паролей из «опасного списка»:

@Test
public void passFromDangerousListIsWeak(){
    assertEquals(PasswordValidator.validatePassword("tpepsucolia@1209"), PasswordStatus.WEAK);
    assertEquals(PasswordValidator.validatePassword("V6#WnsBLDES2!7Zg"), PasswordStatus.WEAK);
}

Запускаем наши тесты, удостоверяемся в том, что они не проходят, и садимся писать код.

Я создал отдельный приватный статический метод (коммит), который будет проверять, является ли входящая строка подмножеством строк файла dangerous_passwords.txt, а также немного дописал метод проверки пароля на корректность, добавив в него проверку на null:

Доработка основного метода validatePassword :

 if(passwordInDangerousList(password)) return PasswordStatus.WEAK;  

Доработка boolean метода passwordIsCorrect(String password):

 if(password==null) throw new IllegalArgumentException("Password can't be null");

Вспомогательная функция проверки в файле:

 private static boolean passwordInDangerousList(String password){
    Scanner scanner;
    try {
        scanner = new Scanner(new File("src/main/resources/dangerous_passwords.txt"));
    } catch (FileNotFoundException e) {
        throw new RuntimeException("Can't find file dangerous_passwords.txt");
    }
    while (scanner.hasNext()){
        String dangerousPassword = scanner.next();
        if(password.equals(dangerousPassword)) return true;
    }
    return false;
}

Результат:

11 of 11
11 of 11

Данный проект достаточно прост по своей структуре, поэтому мне не понадобился рефакторинг кода, проходящего тесты, в конце итераций, но в больших проектах рефакторинг, вероятно, потребуется после получения новых требований.

Спасибо за внимание!

Полный код проекта на GitHub: https://github.com/youngmyn/password-validator-TDD

Источники:

Комментарии (8)


  1. SergeiMinaev
    28.08.2024 22:32
    +2

    Теперь же представим себя разработчиком в вымышленной ИТ компании, перед которым стоит задача: написать валидатор пользовательских паролей

    Как часто вам приходится писать валидаторы паролей? Вариант пореалистичнее: прототип какого-нибудь дашборда, который заказчик посмотрит, пощупает, выдаст правки, потом снова правки и в последующие 2-3 месяца всё будет переделано вдоль и поперёк.


    1. youngmyn Автор
      28.08.2024 22:32

      Давно вынашивал схожие сомнения на этот счет.
      Тезисно выразил в статье : https://habr.com/ru/articles/839658/


  1. Kahelman
    28.08.2024 22:32
    +1

    1234567890Password

    Это сильный пароль по вашим оценкам.

    Как пример для TDD может конечно пойти, но лучше было бы выбрать тему побезопасней.

    У меня и так половина сайтов ругается что им то цифры, то символа не хватает в моих 16 символьных паролях сгенерированных password manager-ом. …

    Давайте будем писать статьи про «нормальные» пароли исходя из реальных рекомендаций.

    Если надо проверить пароль то надо проверять по базам хешей, а не по количеству символов.


    1. youngmyn Автор
      28.08.2024 22:32

      Конкретно этот пароль было бы логично держать в файле dangerous_passwords.txt

      Но вообще я не проводил ресерча по настоящим методам валидаций паролей, я понимаю, что там все сложнее, чем : 8 символов и спецзнак. Тут скорее я использовал стереотип, который был у меня в голове для примера. Я действительно брал эти условия с потолка. Прям совсем с потолка.


  1. SuperKozel
    28.08.2024 22:32
    +4

    Ахахах, только на таких примерах все это тдд и работает, когда одна-две простых функции. Удачи делать тдд когда делается фича с интеграцией, емэйлами и другими сообщениями и событиями.


    1. egribanov
      28.08.2024 22:32

      Обычно архитектура и разделение на слои хорошо в этом случае работает


  1. aProger
    28.08.2024 22:32
    +2

    ИМХО, задача тестов, при TDD - это более правильно и корректно сформулировать задачу для разработчика функционала.


    1. Kahelman
      28.08.2024 22:32

      У нас тут как раз такой тест: более правильно сформулировать задачу.

      В итоге автор реализовал проверку паролей с помощью regexp, и как мы вроде все согласились результат не очень. И надо было по списку общеизвестных паролей пройти.

      Как TTD помог в решении проблемы?