Я, как и многие программисты, довольно много слышал и читал о практиках TDD. О пользе хорошего покрытия кода юнит-тестами — и о вреде его отсутствия — я знаю по собственному опыту в коммерческих проектах, но применять TDD в чистом виде не получалось по разным причинам. Начав на днях писать свой игровой проект, я решил, что это хорошая возможность попробовать. Как оказалось, разницу по сравнению с обычным подходом можно почувствовать даже при реализации простейшего класса. Я распишу этот пример по шагам и в конце опишу результаты, которые для себя увидел. Думаю топик будет полезен тем, кто интересуется TDD. От более опытных коллег хотелось бы услышать комментарии и критику.
Теорию описывать не буду, ее можно легко найти самостоятельно. Пример написан на Java, в качестве Unit-test фреймворка использован TestNG.
Я начал с разработки базового класса для боевой единицы — юнита. На базовом уровне мне нужно чтобы юнит имел запас здоровья и урон, который он может наносить другим юнитам.
Казалось бы, что может быть проще:
Реализация очень наивная. Наверняка, когда я начну использовать этот класс, придется в него добавить более удобные методы, конструктор и т.д. Но пока я не знаю, что понадобится, а что нет, и не хочу сразу писать лишнее.
Итак, это то, что получилось традиционным методом «в лоб». Теперь попробуем реализовать тот же класс через TDD.
В реальности я не писал приведенную выше реализацию, изначально никакого класса Unit не существует. Мы начинаем с создания класса теста.
Начинаем думать о требованиях к классу юнита. Первое, что приходит в голову — неплохо бы уметь создавать юнит, задавая его здоровье и урон. Так и пишем.
Тест, понятное дело, даже не компилируется — делаем так чтобы он прошел.
Рефакторить пока нечего. Пишем следующий тест — я хочу иметь возможность узнать текущее здоровье юнита.
Тест падает из-за ошибки компиляции — метода getHealth у класса Unit нет. Правим код, чтобы тест прошел.
Рефакторить опять нечего. Думаем дальше — наверное было бы неплохо, чтобы юнит умел получать урон.
Правим код, чтобы тест прошел.
Ах да, полученный урон должен вычитаться из здоровья юнита. Напишу отдельный тест для этого.
Первый тест, который падает из-за поведения класса. Правим.
Тут уже можно немного порефакторить. Тут можно оставить и так, но я привык что геттеры находятся в конце класса.
Двигаемся дальше. Наш юнит уже имеет запас здоровья и ему можно наносить урон. Научим его наносить урон другим юнитам!
Дорабатываем класс юнита.
Понятное дело, если наш юнит нанес урон другому юниту, у того должно уменьшиться здоровье.
Свеженаписаный тест падает — поправим класс юнита.
Наведем немного блеска: переменная damage может быть final, параметр в методе takeDamage неплохо бы переименовать чтобы не путать с переменной класса.
Дальше нужно писать тесты на то, что здоровье не может упасть ниже нуля, если оно на нуле юнит должен уметь сказать что он мертв и т.д. Чтобы не добавлять лишнего объема, я остановлюсь тут. Думаю для понимания примера достаточно и можно сделать некоторые выводы.
Я понимаю что пример очень простой и прошу к этому не придираться. Топик не о том как написать класс из двух полей, а о том что можно увидеть преимущества 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;
}
}
Дальше нужно писать тесты на то, что здоровье не может упасть ниже нуля, если оно на нуле юнит должен уметь сказать что он мертв и т.д. Чтобы не добавлять лишнего объема, я остановлюсь тут. Думаю для понимания примера достаточно и можно сделать некоторые выводы.
Выводы
- На реализацию простейшего класса потрачено времени в несколько раз больше, чем при реализации «в лоб» — это то, что так часто пугает менеджеров и не владеющих данной техникой программистов в TDD.
- Можно сравнить первую наивную реализацию и последнюю, полученную через TDD. Главное отличие в том, что последняя реализация действительно объектно-ориентированная, с юнитом можно работать как с самостоятельным объектом, спрашивать его состояние и просить выполнить определенные действия. Код, который будет работать с этим классом также будет более объектно-ориентированным.
- Кроме самого класса мы получили полный набор тестов к нему. Знаю по своему опыту, что большего блага для разработчика, чем полностью покрытый тестами код, сложно представить. Из того же опыта, если тесты пишутся после кода, бывает сложно обеспечить полное покрытие — на что-то обязательно забудут написать тест, что-то будет выглядеть слишком простым, чтобы тестировать и т.п. Сами тесты часто получаются сложными и громоздкими, т.к. велик соблазн одним тестом проверить несколько аспектов работы тестируемого кода. Здесь же мы получили набор простых, легких в понимании тестов, которые будет гораздо проще поддерживать.
- Мы получили живую документацию к коду! Любому человеку достаточно прочитать названия методов в тесте, чтобы понять задумку автора, назначение и поведение класса. Разбираться в коде, имея эту информацию, будет на порядок проще и не нужно отвлекать коллег с просьбами объяснить что тут к чему.
- Предыдущие пункты и так хорошо известны, но я сделал для себя один новый вывод — при разработке через TDD гораздо лучше продумывается что хочется получить от класса, его поведение и варианты использования. Имея хорошее понимание уже разработанных компонент, будет понятнее как писать более сложные.
- Не относится к данному примеру, но захотелось добавить сюда еще один пункт. При разработке через тесты адекватнее оцениваешь трудоемкость задач — всегда знаешь что уже работает, что осталось доделать. Без тестов часто возникает ощущение, что все уже написано, но оказывается, что на отладку и доработку нужно еще значительное количество времени.
Я понимаю что пример очень простой и прошу к этому не придираться. Топик не о том как написать класс из двух полей, а о том что можно увидеть преимущества TDD даже на таком элементарном примере.
Всем спасибо за внимание. Изучайте прогрессивные техники программирования и получайте удовольствие от работы!