Mastering JavaScript unit tests with the RPG Combat Kata - Iteration Three

Photo by Artem Kniaz on Unsplash

Mastering JavaScript unit tests with the RPG Combat Kata - Iteration Three

Welcome back to our RPG Combat Kata series, where we're not just leveling up our characters but also our skills in JavaScript unit testing.

In this third installment, we tackle Iteration Three, a stage that challenges us to think spatially and introduces the concept of attack range.

If you're eager to sharpen your understanding of object-oriented programming (OOP) and test-driven development (TDD) in JavaScript, you've come to the right place.

Introduction to Iteration Three

In our previous posts, we've conquered the basics and built a solid foundation with JavaScript unit tests. We've seen our characters grow, and with them, our test suites have expanded, becoming more robust and reliable.

Introducing attack range

Attack range is a game-changer—literally. It dictates how close characters need to be to land a hit.

The concept of attack range is a significant change from the previous iterations that did not consider the spatial relationship between characters, and this is sure to have an impact on our combat logic as well as Character creation.

This is one of the reasons that I like this code challenge so much: even though it is just a simple game, it mirrors things that can happen in the real world of software development.

Alternatives to tackle Iteration Three

We have some alternatives as to how we approach this:

  • Use the strategy pattern by defining a family of algorithms (in this case, attack strategies for melee and ranged) and making them interchangeable. The Character class could have a setAttackStrategy method allowing us to change the attack behavior at runtime.

  • Use the decorator pattern to dynamically add additional responsibilities to the Character. We could keep our base Character untouched and then decorate it with MeleeAttack or RangedAttack decorators that would manage the range logic.

  • Add a fighterType property to the Character class that specifies the type of fighter (melee or ranged) and uses a simple conditional logic within the attack method to handle the range. This is less flexible and can lead to bloated classes if too many types are added.

  • Using subclassing (aka inheritance), we can create specialized versions of our Character class that inherit its properties and methods but also introduce additional behavior or override existing behavior to accommodate different types of fighters, such as melee and ranged.

Deep dive: the Decorator pattern

As explained by Refactoring Guru, which is awesome by the way and you should definitely check it out, Decorator is a structural design pattern that lets you attach new behaviors to objects by placing these objects inside special wrapper objects that contain the behaviors.

Our code would look more or less like this:

// MeleeFighter.js
class MeleeFighter extends Character {
  constructor(character) {
    super(character);
    this.range = 2; // Melee range
  }

  attack(target, distance) {
    if (distance <= this.range) {
      super.attack(target);
    }
  }
}

// RangedFighter.js
class RangedFighter extends Character {
  constructor(character) {
    super(character);
    this.range = 20; // Ranged range
  }

  attack(target, distance) {
    if (distance <= this.range) {
      super.attack(target);
    }
  }
}

// Usage
const basicCharacter = new Character();
const meleeFighter = new MeleeFighter(basicCharacter);
const rangedFighter = new RangedFighter(basicCharacter);

// Now we can use meleeFighter and rangedFighter
// with range-specific attack logic
meleeFighter.attack(target, 1); // Will attack if within 2 meters
rangedFighter.attack(target, 15); // Will attack if within 20 meters

Embracing the decorator pattern opens up a world of possibilities, allowing us to imbue Character objects with new capabilities without altering the core Character class.

Imagine the flexibility, like equipping characters with multiple combat styles, seamlessly switching between Melee and Ranged attacks. The potential for dynamic gameplay is boundless!

However, when we circle back to the core requirements, each character must have a maximum attack range and must be within this range to inflict damage, it becomes clear that these rules should be bound to our characters. They're not just add-ons; they're essential traits.

This realization steers us towards subclassing, embedding the attack range into our character class. It's a strategic choice that aligns with the game's intrinsic rules and paves the way for clear, maintainable code.

Subclassing it is

Let's begin our work by addressing the first trio of requirements:

  1. Characters possess a maximum attack range.

  2. Melee fighters boast a range of 2 meters.

  3. Ranged fighters command a range of 20 meters.

Our test stubs will take shape as follows:

describe("characters have an attack max range", () => {
  test("melee fighters have a range of 2 meters", () => {
    const melee = new MeleeFighter();
    expect(melee.attackMaxRange).toBe(2);
  });
  test("ranged fighters have a range of 20 meters", () => {
    const ranged = new RangedFighter();
    expect(ranged.attackMaxRange).toBe(20);
  });
});

With our test blueprints in hand, we proceed to forge the MeleeFighter and RangedFighter classes, each inheriting from the Character class. The Character class is now equipped with an attackMaxRange property, tailored to the specific fighter class.

// /src/Character.js
export default class Character {
  #health = STARTING_HEALTH;
  #level = STARTING_LEVEL;
  #attackMaxRange;

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

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

  // ...the rest of the code remains unchanged (for now)
}

// /src/MeleeFighter.js
import Character from "./Character";
export default class MeleeFighter extends Character {
  constructor() {
    super(2); // Melee range
  }
}

// /src/RangedFighter.js
import Character from "./Character";
export default class RangedFighter extends Character {
  constructor() {
    super(20); // Ranged range
  }
}

After integrating the MeleeFighter and RangedFighter classes into our Character.test.js file, we're greeted with the green light of passing tests.

// Character.test.js
// Added imports
import MeleeFighter from "../src/MeleeFighter";
import RangedFighter from "../src/RangedFighter";

Breaking everything!

We're now approaching a pivotal moment: the introduction of a game-changing rule where characters must be within their attack range to deal damage. This calls for the addition of a distance parameter to the attack method, a significant shift in our combat mechanics.

Before we tackle this, though, let's ensure that our characters are distinctly defined as either Melee or Ranged fighters.

We'll refine the Character's constructor as follows:

constructor(attackMaxRange) {
  if (new.target === Character) {
    throw new Error("Character class cannot be instantiated directly.");
  }
  this.#health = STARTING_HEALTH;
  this.#level = STARTING_LEVEL;
  this.#attackMaxRange = attackMaxRange;
}

With this change, a direct instantiation of Character is no longer possible, which aligns with our new design that requires a clear distinction between fighter types.

Run the tests to see that everything breaks apart!

Now, to mend our test suite, we'll replace all instances of new Character() with new MeleeFighter(). A simple, yet effective fix that brings our JavaScript unit tests back to green.

Adding the distance parameter to the attack method

The next step in refining our combat logic involves enhancing the attack method to include a new parameter: distance.

// /src/Character.js
attack(target, distance) {
  if (distance === undefined) {
    throw new Error("Distance must be specified.");
  }
  if (this === target) {
    return; // Characters cannot attack themselves.
  }
  const damage = this.#getDamagePoints(target);
  target.#health -= damage;
}

As I said earlier, this addition will temporarily break our tests, but we'll fix them again in a bit.

To address this, we introduce constants at the start of our test file to define clear distance thresholds:

const DISTANCE_CLOSE = 1;
const DISTANCE_FAR = 3;
const DISTANCE_FARTHER = 21;

These constants are then applied to all existing attack method invocations to reflect the new distance requirement.

Implementing rules for the attack range

Moving forward, we confront the final mandate: characters must be within their attack range to inflict damage.

To validate this, we craft tests that, initially, are expected to fail:

test("melee fighters can only attack targets within 2 meters", () => {
  const attacker = new MeleeFighter();
  const defender = new MeleeFighter();
  attacker.attack(defender, DISTANCE_CLOSE);
  expect(defender.health).toBe(900); // Assumes starting health is 1000
  attacker.attack(defender, DISTANCE_FAR);
  expect(defender.health).toBe(900); // No damage should be dealt at this distance
});

test("ranged fighters can only attack targets within 20 meters", () => {
  const attacker = new RangedFighter();
  const defender = new RangedFighter();
  attacker.attack(defender, DISTANCE_FAR);
  expect(defender.health).toBe(900); // Assumes starting health is 1000
  attacker.attack(defender, DISTANCE_FARTHER);
  expect(defender.health).toBe(900); // No damage should be dealt at this distance
});

To align our code with these tests, we introduce a #isWithinAttackRange private method and incorporate it into the attack method to ensure damage is only dealt when appropriate.

attack(target, distance) {
  if (distance === undefined) throw new Error("Distance is required.");
  if (this === target) return;
  if (!this.#isWithinAttackRange(distance)) return; // added line
  const damage = this.#getDamagePoints(target);
  target.#health -= damage;
}

#isWithinAttackRange(distance) {
  return distance <= this.#attackMaxRange;
}

With these adjustments, we've successfully met all four requirements, and the impact on our codebase has been minimal.

The cons of subclassing

While subclassing is powerful, it's not without its trade-offs, especially in a dynamically typed language like JavaScript.

One of the main issues is that JavaScript does not have a compile-time type checking system by default. This means that errors related to incorrect use of classes or subclasses, such as calling methods that don't exist on a subclass, passing the wrong type of arguments, or failing to implement required methods, will not be caught until runtime.

Overuse of subclassing can also lead to deep inheritance hierarchies that make the code harder to understand and maintain. Each layer of inheritance introduces a level of complexity and coupling, which can make future changes more error-prone and difficult to implement.

Additionally, subclassing can lead to the temptation to extend classes for reuse rather than composing simpler objects to achieve the same goal, which can violate the composition over inheritance principle that is often favored for maintaining a flexible and modular codebase.

Why go with subclassing anyway

Subclassing makes sense for this challenge specifically due to a couple of reasons:

  1. Clear semantic distinction: the challenge introduces two distinct types of characters—melee and ranged fighters—each with unique behavior regarding their attack range. Subclassing allows us to clearly define these distinctions in a way that is semantically clear and aligned with object-oriented design principles.

  2. Few changes to existing code: we could extend the functionality of the existing Character class without altering much of its implementation. We were able to introduce new features while ensuring that existing functionality remained unchanged and stable. Sure, we had breaking changes, but they were easily identified and fixed.

In the context of JavaScript, where the language's flexibility can sometimes lead to unstructured and hard-to-maintain code, I think that using inheritance in this case provided us with a structured approach to organizing our code that ultimately helped us manage complexity.

It's a deliberate choice that leverages the strengths of object-oriented design to create a maintainable and extensible architecture for the game's character system. But we’ll have to wait for new requirements to see if this decision will really pay off.

Conclusion and look ahead

As we continue our series, JavaScript unit tests will remain our trusty companions, guiding us through each iteration with confidence and ensuring that our code remains clean and functional.

Stay tuned for our next post, where we'll tackle Iteration Four. We'll face new challenges and learn more about the power of JavaScript unit tests in maintaining and evolving our game.

Thank you for following along in this TDD adventure. May your tests always pass, and your code be bug-free! 🧙‍♂️

Full code until now

Before we live, here you have the full code up to this point:

// /src/Character.js
const STARTING_HEALTH = 1000;
const STARTING_LEVEL = 1;
const POWER = 100;

export default class Character {
  #health;
  #level;
  #attackMaxRange;

  constructor(attackMaxRange) {
    if (new.target === Character)
      throw new Error("Character cannot be instantiated directly.");

    this.#health = STARTING_HEALTH;
    this.#level = STARTING_LEVEL;
    this.#attackMaxRange = attackMaxRange;
  }

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

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

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

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

  attack(target, distance) {
    if (distance === undefined) throw new Error("Distance is required.");
    if (this === target) return;
    if (!this.#isWithinAttackRange(distance)) 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;
  }

  #isWithinAttackRange(distance) {
    return distance <= this.#attackMaxRange;
  }
}
// /src/MeleeFighter.js
import Character from "./Character";

export default class MeleeFighter extends Character {
  constructor() {
    super(2);
  }
}
// /src/RangedFighter.js
import Character from "./Character";

export default class RangedFighter extends Character {
  constructor() {
    super(20);
  }
}
// /test/Character.test.js
import { beforeEach, describe, expect, jest, test } from "@jest/globals";
import Character from "../src/Character";
import MeleeFighter from "../src/MeleeFighter";
import RangedFighter from "../src/RangedFighter";

const DISTANCE_CLOSE = 1;
const DISTANCE_FAR = 3;
const DISTANCE_FARTHER = 21;

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

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

  describe("when healed", () => {
    test("is not healed if not by itself", () => {
      const attacker = new MeleeFighter();
      const defender = new MeleeFighter();
      attacker.attack(defender, DISTANCE_CLOSE);
      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 MeleeFighter();
      const defender = new MeleeFighter();
      for (let i = 0; i < 10; i++) attacker.attack(defender, DISTANCE_CLOSE);
      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 MeleeFighter();
      const defender = new MeleeFighter();
      for (let i = 0; i < 5; i++) attacker.attack(defender, DISTANCE_CLOSE);
      expect(defender.health).toBe(500);
      defender.heal(defender);
      expect(defender.health).toBe(600);
    });
    test("cannot be healed above 1000 health points", () => {
      const attacker = new MeleeFighter();
      const defender = new MeleeFighter();
      attacker.attack(defender, DISTANCE_CLOSE);
      expect(defender.health).toBe(900);
      defender.heal(defender);
      defender.heal(defender);
      expect(defender.health).toBe(1000);
    });
  });

  describe("characters have an attack max range", () => {
    test("melee fighters have a range of 2 meters", () => {
      const melee = new MeleeFighter();
      expect(melee.attackMaxRange).toBe(2);
    });
    test("ranged fighters have a range of 20 meters", () => {
      const ranged = new RangedFighter();
      expect(ranged.attackMaxRange).toBe(20);
    });
    test("melee fighters can only attack targets within 2 meters", () => {
      const attacker = new MeleeFighter();
      const defender = new MeleeFighter();
      attacker.attack(defender, DISTANCE_CLOSE);
      expect(defender.health).toBe(900);
      attacker.attack(defender, DISTANCE_FAR);
      expect(defender.health).toBe(900);
    });
    test("ranged fighters can only attack targets within 20 meters", () => {
      const attacker = new RangedFighter();
      const defender = new RangedFighter();
      attacker.attack(defender, DISTANCE_FAR);
      expect(defender.health).toBe(900);
      attacker.attack(defender, DISTANCE_FARTHER);
      expect(defender.health).toBe(900);
    });
  });
});