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