Advancing with Iteration Two: Test-Driven Development in JavaScript

Welcome back!

In our previous post, we tackled the first iteration of the RPG Combat Kata, setting the foundation for a robust and testable codebase. If you're new here, I recommend checking out that post to get up to speed.

Now, let's dive into Iteration Two, where we'll build upon our solid start.

Setting the stage for Iteration Two

Iteration Two introduces new challenges: characters must not harm themselves, they can only heal themselves, and the damage they deal or receive varies with level differences.

  1. A Character cannot Deal Damage to itself.

  2. A Character can only Heal itself.

  3. When dealing damage:

    • If the target is 5 or more Levels above the attacker, Damage is reduced by 50%

    • If the target is 5 or more Levels below the attacker, Damage is increased by 50%

These rules add complexity, requiring a deeper understanding of object-oriented programming principles.

Preparing the Test Environment

Before we write a single line of code, we set up our test environment.

This proactive approach ensures that our JavaScript code will be thoroughly tested and our features correctly implemented.

describe("when damaged", () => {
  test.todo("is not damaged if attacking itself");
  test.todo("damage is reduced by 50% if the target is 5+ levels above the attacker");
  test.todo("damage is increased by 50% if the target is 5- levels below the attacker");
});

describe("when healed", () => {
  test.todo("is not healed if healing itself");
});

I've omitted the tests we've already written for brevity, but I'll show the full code at the end of the article.

Writing tests for self-inflicted actions

Ensuring a Character Cannot Damage Itself

Our first test ensures a character cannot turn into its own enemy. We write a test expecting that when a character attempts to attack itself, its health remains unscathed.

test("is not damaged if attacking itself", () => {
  const character = new Character();
  character.attack(character);
  expect(character.health).toBe(1000);
});

The code for that is fairly simple:

attack(target) {
  if (this === target) return;
  target.#health -= POWER;
}

Validating a character can only heal itself

Similarly, we enforce the rule that a character can only heal itself, not others.

test("is not healed if not by itself", () => {
  const attacker = new Character();
  const defender = new Character();
  attacker.attack(defender);
  expect(defender.health).toBe(900);
  attacker.heal(defender);
  expect(defender.health).toBe(900);
  defender.heal(defender);
  expect(defender.health).toBe(1000);});

The implementation, also very simple:

heal(target) {
  if (!target.isAlive) return;
  if (this !== target) return; // We added just this line
  const newHealth = target.#health + POWER;
  target.#health = Math.min(newHealth, STARTING_HEALTH);
}

But now that the rules for healing have changed, we have to go back to the other healing tests where we had the attacker heal the defender; now, the defender must heal itself.

test("is not healed when dead", () => {
  const attacker = new Character();
  const defender = new Character();
  for (let i = 0; i < 10; i++) attacker.attack(defender);
  expect(defender.isAlive).toBe(false);
  defender.heal(defender); // Changed here
  expect(defender.health).toBe(0);
  expect(defender.isAlive).toBe(false);
});
test("is healed by the exact amount of health points received", () => {
  const attacker = new Character();
  const defender = new Character();
  for (let i = 0; i < 5; i++) attacker.attack(defender);
  expect(defender.health).toBe(500);
  defender.heal(defender); // Changed here
  expect(defender.health).toBe(600);
});
test("cannot be healed above 1000 health points", () => {
  const attacker = new Character();
  const defender = new Character();
  attacker.attack(defender);
  expect(defender.health).toBe(900);
  defender.heal(defender); // Changed here
  defender.heal(defender); // Changed here
  expect(defender.health).toBe(1000);
});

Implementing level-based damage modifiers

Handling reduced damage for higher-level targets

In a twist of fairness, our game reduces damage dealt to mightier opponents. Writing a test for this, we simulate an underdog attacking a higher-level character, expecting the damage to be halved.

test("damage is reduced by 50% if the target is 5+ levels above the attacker", () => {
  const attacker = new Character();
  const defender = new Character();
  for (let i = 0; i < 5; i++) defender.levelUp();
  attacker.attack(defender);
  expect(defender.health).toBe(950);
});

We'll handle the implementation in a bit.

Increasing damage for lower-level targets

Conversely, our David-versus-Goliath scenario sees increased damage when a higher-level character challenges a weaker one.

test("damage is increased by 50% if the target is 5- levels below the attacker", () => {
  const attacker = new Character();
  const defender = new Character();
  for (let i = 0; i < 5; i++) attacker.levelUp();
  attacker.attack(defender);
  expect(defender.health).toBe(850);
});

Refining the Character class with test-driven development

Adjusting the attack method

With our tests in place, we adjust the Character class. Our attack method now includes logic to modify damage based on level differences.

We do this by introducing a private getDamagePoints method, which is used internally by the attack method before applying the damage to the target.

Inside the getDamagePoints method, we actually begin by implementing just the damage reduction, which is the first if, and see one test pass and another one fail. Then we follow up with the second conditional to make both tests pass.

attack(target) {
  if (this === target) return;
  const damage = this.#getDamagePoints(target);
  target.#health -= damage;
}

#getDamagePoints(target) {
  if (target.level - this.level >= 5) {
    return Math.round(POWER * 0.5);
  }
  if (this.level - target.level >= 5) {
    return Math.round(POWER * 1.5);
  }
  return POWER;
}

Running the tests and interpreting the results

Observing test failures

Initially, our tests fail, which is expected in TDD. These failures guide our development, ensuring we write only the necessary code to pass the tests. As we iterate over our code, we see our tests turn green one by one.

Conclusion

We've now completed Iteration Two, with a suite of passing tests and a more sophisticated Character class. Our code is cleaner, our features are well-tested, and we're ready to face the next set of challenges.

This is the full code so far:

// /test/Character.test.js

import { beforeEach, describe, expect, jest, test } from "@jest/globals";
import Character from "../src/Character";

describe("Character", () => {
  describe("when created", () => {
    test("has 1000 health points", () => {
      const character = new Character();
      expect(character.health).toBe(1000);
    });
    test("has 1 level", () => {
      const character = new Character();
      expect(character.level).toBe(1);
    });
    test("is alive", () => {
      const character = new Character();
      expect(character.isAlive).toBe(true);
    });
  });

  describe("when damaged", () => {
    test("is not damaged if attacking itself", () => {
      const character = new Character();
      character.attack(character);
      expect(character.health).toBe(1000);
    });
    test("damage is reduced by 50% if the target is 5+ levels above the attacker", () => {
      const attacker = new Character();
      const defender = new Character();
      for (let i = 0; i < 5; i++) defender.levelUp();
      attacker.attack(defender);
      expect(defender.health).toBe(950);
    });
    test("damage is increased by 50% if the target is 5- levels below the attacker", () => {
      const attacker = new Character();
      const defender = new Character();
      for (let i = 0; i < 5; i++) attacker.levelUp();
      attacker.attack(defender);
      expect(defender.health).toBe(850);
    });
    test("decreases health points by the exact amount of damage received", () => {
      const attacker = new Character();
      const defender = new Character();
      attacker.attack(defender);
      expect(defender.health).toBe(900);
    });
    test("dies when damage received exceeds current health points", () => {
      const attacker = new Character();
      const defender = new Character();
      for (let i = 0; i < 10; i++) attacker.attack(defender);
      expect(defender.isAlive).toBe(false);
    });
  });

  describe("when healed", () => {
    test("is not healed if not by itself", () => {
      const attacker = new Character();
      const defender = new Character();
      attacker.attack(defender);
      expect(defender.health).toBe(900);
      attacker.heal(defender);
      expect(defender.health).toBe(900);
      defender.heal(defender);
      expect(defender.health).toBe(1000);
    });
    test("is not healed when dead", () => {
      const attacker = new Character();
      const defender = new Character();
      for (let i = 0; i < 10; i++) attacker.attack(defender);
      expect(defender.isAlive).toBe(false);
      defender.heal(defender);
      expect(defender.health).toBe(0);
      expect(defender.isAlive).toBe(false);
    });
    test("is healed by the exact amount of health points received", () => {
      const attacker = new Character();
      const defender = new Character();
      for (let i = 0; i < 5; i++) attacker.attack(defender);
      expect(defender.health).toBe(500);
      defender.heal(defender);
      expect(defender.health).toBe(600);
    });
    test("cannot be healed above 1000 health points", () => {
      const attacker = new Character();
      const defender = new Character();
      attacker.attack(defender);
      expect(defender.health).toBe(900);
      defender.heal(defender);
      defender.heal(defender);
      expect(defender.health).toBe(1000);
    });
  });
});
// /src/Character.js

const STARTING_HEALTH = 1000;
const STARTING_LEVEL = 1;
const POWER = 100;

export default class Character {
  #health;
  #level;

  constructor() {
    this.#health = STARTING_HEALTH;
    this.#level = STARTING_LEVEL;
  }

  get health() {
    return this.#health;
  }

  get level() {
    return this.#level;
  }

  get isAlive() {
    return this.#health > 0;
  }

  attack(target) {
    if (this === target) return;
    const damage = this.#getDamagePoints(target);
    target.#health -= damage;
  }

  heal(target) {
    if (!target.isAlive) return;
    if (this !== target) return;
    const newHealth = target.#health + POWER;
    target.#health = Math.min(newHealth, STARTING_HEALTH);
  }

  levelUp() {
    this.#level += 1;
  }

  #getDamagePoints(target) {
    if (target.level - this.level >= 5) {
      return Math.round(POWER * 0.5);
    }
    if (this.level - target.level >= 5) {
      return Math.round(POWER * 1.5);
    }
    return POWER;
  }
}

Previewing upcoming iterations

Stay tuned as we continue to explore test-driven development in JavaScript. In our next posts, we'll tackle further iterations, introducing new features and tests that will challenge our understanding and demonstrate the power of TDD.

Iteration Two has been a success, and we're just getting started. Happy coding, and see you in the next iteration!