Простой, но показательный пример использования TDD

в 15:43, , рубрики: tdd, Песочница, Программирование, метки: ,

Я, как и многие программисты, довольно много слышал и читал о практиках TDD. О пользе хорошего покрытия кода юнит-тестами — и о вреде его отсутствия — я знаю по собственному опыту в коммерческих проектах, но применять TDD в чистом виде не получалось по разным причинам. Начав на днях писать свой игровой проект, я решил, что это хорошая возможность попробовать. Как оказалось, разницу по сравнению с обычным подходом можно почувствовать даже при реализации простейшего класса. Я распишу этот пример по шагам и в конце опишу результаты, которые для себя увидел. Думаю топик будет полезен тем, кто интересуется TDD. От более опытных коллег хотелось бы услышать комментарии и критику.

Теорию описывать не буду, ее можно легко найти самостоятельно. Пример написан на Java, в качестве Unit-test фреймворка использован TestNG.

Задача

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

Казалось бы, что может быть проще:

public class Unit {

    private int health;
    private int damage;

    public int getHealth() {
        return health;
    }

    public int setHealth(int health) {
        this.health = health;
    }

    public int getDamage() {
        return damage;
    }

    public int setDamage(int damage) {
        this.damage = damage;
    }

}

Реализация очень наивная. Наверняка, когда я начну использовать этот класс, придется в него добавить более удобные методы, конструктор и т.д. Но пока я не знаю, что понадобится, а что нет, и не хочу сразу писать лишнее.

Итак, это то, что получилось традиционным методом «в лоб». Теперь попробуем реализовать тот же класс через TDD.

Применяем TDD

В реальности я не писал приведенную выше реализацию, изначально никакого класса Unit не существует. Мы начинаем с создания класса теста.

@Test
public class UnitTest {

}

Начинаем думать о требованиях к классу юнита. Первое, что приходит в голову — неплохо бы уметь создавать юнит, задавая его здоровье и урон. Так и пишем.

@Test
public class UnitTest {

    @Test
    public void youCreateAUnitGivenItsHealthAndDamage() {
        new Unit(100, 25);
    }

}

Тест, понятное дело, даже не компилируется — делаем так чтобы он прошел.

public class Unit {

    public Unit(int health, int damage) {
    }

}

Рефакторить пока нечего. Пишем следующий тест — я хочу иметь возможность узнать текущее здоровье юнита.

@Test
public class UnitTest {

    @Test
    public void youCreateAUnitGivenItsHealthAndDamage() {
        new Unit(100, 25);
    }

    @Test
    public void youCheckUnitHealthWithGetter() {
        Unit unit = new Unit(100, 25);
        assertEquals(100, unit.getHealth());
    }

}

Тест падает из-за ошибки компиляции — метода getHealth у класса Unit нет. Правим код, чтобы тест прошел.

public class Unit {

    private int health;

    public Unit(int health, int damage) {
        this.health = health;
    }

    public int getHealth() {
        return health;
    }

}

Рефакторить опять нечего. Думаем дальше — наверное было бы неплохо, чтобы юнит умел получать урон.

@Test
public class UnitTest {

    @Test
    public void youCreateAUnitGivenItsHealthAndDamage() {
        new Unit(100, 25);
    }

    @Test
    public void youCheckUnitHealthWithGetter() {
        Unit unit = new Unit(100, 25);
        assertEquals(100, unit.getHealth());
    }

     @Test
    public void unitCanTakeDamage() {
        Unit unit = new Unit(100, 25);
        unit.takeDamage(25);
    }

}

Правим код, чтобы тест прошел.

public class Unit {

    private int health;

    public Unit(int health, int damage) {
        this.health = health;
    }

    public int getHealth() {
        return health;
    }

    public void takeDamage(int damage) {
    }

}

Ах да, полученный урон должен вычитаться из здоровья юнита. Напишу отдельный тест для этого.

@Test
public class UnitTest {

    @Test
    public void youCreateAUnitGivenItsHealthAndDamage() {
        new Unit(100, 25);
    }

    @Test
    public void youCheckUnitHealthWithGetter() {
        Unit unit = new Unit(100, 25);
        assertEquals(100, unit.getHealth());
    }

    @Test
    public void unitCanTakeDamage() {
        Unit unit = new Unit(100, 25);
        unit.takeDamage(25);
    }

    @Test
    public void damageTakenReducesUnitHealth()  {
        Unit unit = new Unit(100, 25);
        unit.takeDamage(25);
        assertEquals(75, unit.getHealth());
    }

}

Первый тест, который падает из-за поведения класса. Правим.

public class Unit {

    private int health;

    public Unit(int health, int damage) {
        this.health = health;
    }

    public int getHealth() {
        return health;
    }

    public void takeDamage(int damage) {
        health -= damage;
    }

}

Тут уже можно немного порефакторить. Тут можно оставить и так, но я привык что геттеры находятся в конце класса.

public class Unit {

    private int health;

    public Unit(int health, int damage) {
        this.health = health;
    }

    public void takeDamage(int damage) {
        health -= damage;
    }

    public int getHealth() {
        return health;
    }

}

Двигаемся дальше. Наш юнит уже имеет запас здоровья и ему можно наносить урон. Научим его наносить урон другим юнитам!

@Test
public class UnitTest {

    @Test
    public void youCreateAUnitGivenItsHealthAndDamage() {
        new Unit(100, 25);
    }

    @Test
    public void youCheckUnitHealthWithGetter() {
        Unit unit = new Unit(100, 25);
        assertEquals(100, unit.getHealth());
    }

    @Test
    public void unitCanTakeDamage() {
        Unit unit = new Unit(100, 25);
        unit.takeDamage(25);
    }

    @Test
    public void damageTakenReducesUnitHealth()  {
        Unit unit = new Unit(100, 25);
        unit.takeDamage(25);
        assertEquals(75, unit.getHealth());
    }

    @Test
    public void unitCanDealDamageToAnotherUnit() {
        Unit damageDealer = new Unit(100, 25);
        Unit damageTaker = new Unit(100, 25);
        damageDealer.dealDamage(damageTaker);
    }

}

Дорабатываем класс юнита.

public class Unit {

    private int health;

    public Unit(int health, int damage) {
        this.health = health;
    }

    public void takeDamage(int damage) {
        health -= damage;
    }

    public void dealDamage(Unit damageTaker) {
    }

    public int getHealth() {
        return health;
    }

}

Понятное дело, если наш юнит нанес урон другому юниту, у того должно уменьшиться здоровье.

@Test
public class UnitTest {

    @Test
    public void youCreateAUnitGivenItsHealthAndDamage() {
        new Unit(100, 25);
    }

    @Test
    public void youCheckUnitHealthWithGetter() {
        Unit unit = new Unit(100, 25);
        assertEquals(100, unit.getHealth());
    }

    @Test
    public void unitCanTakeDamage() {
        Unit unit = new Unit(100, 25);
        unit.takeDamage(25);
    }

    @Test
    public void damageTakenReducesUnitHealth()  {
        Unit unit = new Unit(100, 25);
        unit.takeDamage(25);
        assertEquals(75, unit.getHealth());
    }

    @Test
    public void unitCanDealDamageToAnotherUnit() {
        Unit damageDealer = new Unit(100, 25);
        Unit damageTaker = new Unit(100, 25);
        damageDealer.dealDamage(damageTaker);
    }

    @Test
    public void unitThatDamageDealtToTakesDamageDealerUnitDamage() {
        Unit damageDealer = new Unit(100, 25);
        Unit damageTaker = new Unit(100, 25);
        damageDealer.dealDamage(damageTaker);
        assertEquals(75, damageTaker.getHealth());
    }

}

Свеженаписаный тест падает — поправим класс юнита.

public class Unit {

    private int health;
    private int damage;

    public Unit(int health, int damage) {
        this.health = health;
        this.damage = damage;
    }

    public void takeDamage(int damage) {
        health -= damage;
    }

    public void dealDamage(Unit damageTaker) {
        damageTaker.takeDamage(damage);
    }

   public int getHealth() {
        return health;
    }

}

Наведем немного блеска: переменная damage может быть final, параметр в методе takeDamage неплохо бы переименовать чтобы не путать с переменной класса.

public class Unit {

    private int health;
    private final int damage;

    public Unit(int health, int damage) {
        this.health = health;
        this.damage = damage;
    }

    public void takeDamage(int incomingDamage) {
        health -= incomingDamage;
    }

    public void dealDamage(Unit damageTaker) {
        damageTaker.takeDamage(damage);
    }

    public int getHealth() {
        return health;
    }

}

Дальше нужно писать тесты на то, что здоровье не может упасть ниже нуля, если оно на нуле юнит должен уметь сказать что он мертв и т.д. Чтобы не добавлять лишнего объема, я остановлюсь тут. Думаю для понимания примера достаточно и можно сделать некоторые выводы.

Выводы

  1. На реализацию простейшего класса потрачено времени в несколько раз больше, чем при реализации «в лоб» — это то, что так часто пугает менеджеров и не владеющих данной техникой программистов в TDD.
  2. Можно сравнить первую наивную реализацию и последнюю, полученную через TDD. Главное отличие в том, что последняя реализация действительно объектно-ориентированная, с юнитом можно работать как с самостоятельным объектом, спрашивать его состояние и просить выполнить определенные действия. Код, который будет работать с этим классом также будет более объектно-ориентированным.
  3. Кроме самого класса мы получили полный набор тестов к нему. Знаю по своему опыту, что большего блага для разработчика, чем полностью покрытый тестами код, сложно представить. Из того же опыта, если тесты пишутся после кода, бывает сложно обеспечить полное покрытие — на что-то обязательно забудут написать тест, что-то будет выглядеть слишком простым, чтобы тестировать и т.п. Сами тесты часто получаются сложными и громоздкими, т.к. велик соблазн одним тестом проверить несколько аспектов работы тестируемого кода. Здесь же мы получили набор простых, легких в понимании тестов, которые будет гораздо проще поддерживать.
  4. Мы получили живую документацию к коду! Любому человеку достаточно прочитать названия методов в тесте, чтобы понять задумку автора, назначение и поведение класса. Разбираться в коде, имея эту информацию, будет на порядок проще и не нужно отвлекать коллег с просьбами объяснить что тут к чему.
  5. Предыдущие пункты и так хорошо известны, но я сделал для себя один новый вывод — при разработке через TDD гораздо лучше продумывается что хочется получить от класса, его поведение и варианты использования. Имея хорошее понимание уже разработанных компонент, будет понятнее как писать более сложные.
  6. Не относится к данному примеру, но захотелось добавить сюда еще один пункт. При разработке через тесты адекватнее оцениваешь трудоемкость задач — всегда знаешь что уже работает, что осталось доделать. Без тестов часто возникает ощущение, что все уже написано, но оказывается, что на отладку и доработку нужно еще значительное количество времени.

Я понимаю что пример очень простой и прошу к этому не придираться. Топик не о том как написать класс из двух полей, а о том что можно увидеть преимущества TDD даже на таком элементарном примере.

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

Автор: nhekfqn

  1. Ольга:

    Хороший пример!

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js